diff --git a/.github/workflows/builddevdoc.yml b/.github/workflows/builddevdoc.yml index 1c9f3bb79..b2e931ce8 100755 --- a/.github/workflows/builddevdoc.yml +++ b/.github/workflows/builddevdoc.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ '3.8' ] + python-version: [ '3.9' ] name: Python ${{ matrix.python-version }} steps: - name: update OS (Ubuntu) diff --git a/.github/workflows/pr_unittests.yml b/.github/workflows/pr_unittests.yml new file mode 100755 index 000000000..5348cc5e0 --- /dev/null +++ b/.github/workflows/pr_unittests.yml @@ -0,0 +1,67 @@ +name: "Unittests Plugins" +#on: [workflow_dispatch, push] +on: + workflow_dispatch: + pull_request: + branches: + - 'develop' + +jobs: + build: + runs-on: ubuntu-20.04 #latest + strategy: + fail-fast: false + matrix: + python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11' ] + name: Python ${{ matrix.python-version }} + steps: + - name: Setup OS (Ubuntu) + run: | + sudo apt-get update + sudo apt-get install libudev-dev + sudo apt-get install librrd-dev libpython3-dev + sudo apt-get install gcc --only-upgrade + + - name: Get branch name + run: | + echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + echo ${GITHUB_REF#refs/heads/} + id: extract_branch + + - name: Checkout core from develop branch + uses: actions/checkout@v3 + with: + repository: smarthomeNG/smarthome + ref: develop + + - name: Checkout plugins from ${{steps.extract_branch.outputs.branch}} branch + uses: actions/checkout@v3 + with: + repository: smarthomeNG/plugins + ref: ${{steps.extract_branch.outputs.branch}} + path: plugins + + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - run: python3 -m pip install --upgrade pip + + - name: Install requirements for unit testing + run: pip install -r tests/requirements.txt + - name: Build Requirements for SmartHomeNG + run: python3 tools/build_requirements.py + - name: Install SmartHomeNG base requirements + # base requirements are needed for pytest to run + run: pip install -r requirements/base.txt + + # --- up to here, the workflow is identical for CORE and PLUGINS --- + + - name: Install SmartHomeNG all requirements + # all requirements are needed for pytest to run plugin tests + run: pip install -r requirements/all.txt + + - name: '>>> Run Python Unittests for PLUGINS <<<' + working-directory: ./plugins + run: pytest diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index ca5d25c35..9f9ce13c0 100755 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ '3.7', '3.8', '3.9', '3.10' ] + python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11' ] name: Python ${{ matrix.python-version }} steps: - name: Setup OS (Ubuntu) diff --git a/__init__.py b/__init__.py index 7e1f7bf73..81149b466 100755 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,5 @@ def plugin_release(): - return '1.9.3' + return '1.9.4' def plugin_branch(): return 'master' diff --git a/alexa4p3/plugin.yaml b/alexa4p3/plugin.yaml index 999e021f7..6f83c5f54 100755 --- a/alexa4p3/plugin.yaml +++ b/alexa4p3/plugin.yaml @@ -143,9 +143,24 @@ item_attributes: description: de: 'URL für das Standbild einer Kamera' en: 'URL for the preview picture of the camera' -# alexa_alias: -# type: str -# mandatory: False -# description: -# de: 'nur für Payload-V2 benötigt' -# en: 'only for Payload V2 needed' + + alexa_alias: + type: str + mandatory: False + description: + de: 'Alternative Alexa-Gerätenamen, um das Gerät anzusprechen' + en: 'alternative Alexa device names to access the device' + + alexa_range_delta: + type: str + mandatory: False + description: + de: '' + en: '' + + alexa_color_temp_delta: + type: str + mandatory: False + description: + de: '' + en: '' diff --git a/avdevice/__init__.py b/avdevice/__init__.py index 30af170a3..0b7d30c99 100755 --- a/avdevice/__init__.py +++ b/avdevice/__init__.py @@ -122,7 +122,6 @@ def __init__(self, smarthome): rs232_timeout = self.get_parameter_value('rs232_timeout') update_exclude = self.get_parameter_value('update_exclude') statusquery = self.get_parameter_value('statusquery') - self.webif_pagelength = self.get_parameter_value('webif_pagelength') # Initializing all variables self.logger.debug("Initializing {}: Resendwait: {}. Seconds to keep: {}.".format(self._name, self._resend_wait, diff --git a/avdevice/plugin.yaml b/avdevice/plugin.yaml index ec5f540be..8afaf1461 100755 --- a/avdevice/plugin.yaml +++ b/avdevice/plugin.yaml @@ -3,17 +3,17 @@ plugin: # Global plugin attributes type: interface # plugin type (gateway, interface, protocol, system, web) description: - de: 'Steuerung von diversen AV Geräten über TCP/IP und RS232 Schnittstelle' - en: 'Controlling AV devices via TCP/IP and RS232' + de: 'Steuerung eines AV Gerätes über TCP/IP oder RS232 Schnittstelle' + en: 'Controlling AV devices via TCP/IP or RS232' description_long: - de: 'Steuerung von diversen AV Geräten über TCP/IP und RS232 Schnittstelle. + de: 'Steuerung eines AV Gerätes über TCP/IP oder RS232 Schnittstelle. Das Plugin unterstützt eine Vielzahl von AV-Geräten und wurde mit folgenden Geräten getestet: - Pioneer AV Receiver < 2016 - Denon AV Receiver > 2016 - Epson Projektor < 2010 - Oppo UHD Player ' - en: 'Controlling AV devices via TCP/IP and RS232 + en: 'Controlling AV devices via TCP/IP or RS232 The plugin supports a variety of AV devices and was tested with the following models: - Pioneer AV Receiver < 2016 - Denon AV Receiver > 2016 @@ -28,7 +28,6 @@ plugin: state: ready keywords: av denon pioneer epson oppo player amp receiver projector rs232 telnet tcpip remote control -# documentation: https://github.com/smarthomeNG/smarthome/wiki/CLI-Plugin # url of documentation (wiki) page support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1097870-neues-plugin-av-device-f%C3%BCr-yamaha-pioneer-denon-etc version: 1.6.4 # Plugin version @@ -269,27 +268,6 @@ parameters: de: "Verbindet sich das Plugin, werden die Werte automatisch abgefragt, auch wenn kein Depend=Init im Item angegeben ist. Sollen nur Items abgefragt werden, bei denen das depend-Attribut auf init gesetzt ist, sollte dieser Wert auf False gestellt werden." en: "As soon as the plugin connects to the device the values get queried automatically even if the Depend=Init is not set in the items. If you want to query only those items that are set explicitly change this value to False." - webif_pagelength: - type: int - default: 0 - valid_list: - - -1 - - 0 - - 25 - - 50 - - 100 - description: - de: 'Anzahl an Items, die standardmäßig in einer Web Interface Tabelle pro Seite angezeigt werden. - 0 = automatisch, -1 = alle' - en: 'Amount of items being listed in a web interface table per page by default. - 0 = automatic, -1 = all' - description_long: - de: 'Anzahl an Items, die standardmäßig in einer Web Interface Tabelle pro Seite angezeigt werden.\n - Bei 0 wird die Tabelle automatisch an die Höhe des Browserfensters angepasst.\n - Bei -1 werden alle Tabelleneinträge auf einer Seite angezeigt.' - en: 'Amount of items being listed in a web interface table per page by default.\n - 0 adjusts the table height automatically based on the height of the browser windows.\n - -1 shows all table entries on one page.' item_attributes: diff --git a/avdevice/user_doc.rst b/avdevice/user_doc.rst index 6f79f14a3..a6f1683b3 100755 --- a/avdevice/user_doc.rst +++ b/avdevice/user_doc.rst @@ -1,15 +1,35 @@ .. index:: Plugins; avdevice .. index:: avdevice +======== avdevice -######## +======== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + +Steuerung eines AV Gerätes über TCP/IP oder RS232 Schnittstelle. + +Das Plugin unterstützt eine Vielzahl von AV-Geräten und wurde mit folgenden Geräten getestet: +- Pioneer AV Receiver < 2016 +- Denon AV Receiver > 2016 +- Epson Projektor < 2010 +- Oppo UHD Player + Konfiguration ============= -.. important:: +Diese Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/avdevice` beschrieben. + - Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/avdevice` beschrieben. +plugin.yaml +----------- .. code-block:: yaml @@ -428,8 +448,8 @@ die richtige Zuordnung eine Rolle spielt (außer bei der Angabe von {str}). Dieses Feature befindet sich immer noch in der Entwicklung. Erfahrungen bitte im Support Thread im KNX-Forum teilen. -Webinterface -============ +Web Interface +============= Das Webinterface kann genutzt werden, um die Items und deren Werte auf einen Blick zu sehen, die dem Plugin zugeordner sind. Außerdem können Historien von Kommandos und Abfragen diff --git a/avdevice/webif/__init__.py b/avdevice/webif/__init__.py index 215caaed9..b63dd7f10 100755 --- a/avdevice/webif/__init__.py +++ b/avdevice/webif/__init__.py @@ -95,10 +95,7 @@ def index(self, action=None, item_id=None, item_path=None, reload=None): keep_cleared = True tmpl = self.tplenv.get_template('index.html') - try: - pagelength = self.plugin.webif_pagelength - except Exception: - pagelength = 100 + pagelength = self.plugin.get_parameter_value('webif_pagelength') # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) return tmpl.render(p=self.plugin, config_reloaded=config_reloaded, query_cleared=query_cleared, diff --git a/avdevice/webif/templates/index.html b/avdevice/webif/templates/index.html index 44374c299..d86af636b 100755 --- a/avdevice/webif/templates/index.html +++ b/avdevice/webif/templates/index.html @@ -18,31 +18,34 @@ {% endblock pluginscripts %} + +{% if tr064_item_count > 0 %} + {% set start_tab = 1 %} +{% endif %} - + {% set tabcount = 6 %} -{% set tab1title = _(""'AVM Items'" (" ~ avm_item_count ~ ") ") %} + +{% if p._fritz_device and tr064_item_count > 0 %} + {% set tab1title = _(""'AVM TR-064 Items'" (" ~ tr064_item_count ~ ") ") %} +{% else %} + {% set tab2title = "hidden" %} +{% endif %} -{% if p.aha_http_interface and smarthome_item_count > 0 %} - {% set tab2title = _(""'AVM Smarthome Items'" (" ~ smarthome_item_count ~ ") ") %} +{% if p._fritz_home and aha_item_count > 0 %} + {% set tab2title = _(""'AVM AHA Items'" (" ~ aha_item_count ~ ") ") %} {% else %} {% set tab2title = "hidden" %} {% endif %} -{% if p.aha_http_interface %} - {% set tab3title = _(""'AVM Smarthome Devices'" (" ~ len(p._fritz_device._smarthome_devices) ~ ") ") %} +{% if p._fritz_home and len(p._fritz_home._aha_devices) > 0%} + {% set tab3title = _(""'AVM AHA Devices'" (" ~ len(p._fritz_home._aha_devices) ~ ") ") %} {% else %} {% set tab3title = "hidden" %} {% endif %} {% if p._call_monitor and call_monitor_item_count > 0 %} - {% set tab4title = _(""'Call Monitor Items'" (" ~ call_monitor_item_count ~ ") ") %} + {% set tab4title = _(""'AVM Call Monitor Items'" (" ~ call_monitor_item_count ~ ") ") %} {% else %} {% set tab4title = "hidden" %} {% endif %} -{% set tab5title = _(""'Log-Einträge'"") %} - -{% set tab6title = _(""'Plugin-API'"") %} +{% set tab5title = _(""'AVM Log-Einträge'"") %} +{% if not maintenance %} + {% set tab6title = _(""'AVM Plugin-API'"") %} +{% else %} + {% set tab6title = _(""'AVM Maintenance'"") %} +{% endif %} {% set language = p.get_sh().get_defaultlanguage() %} @@ -136,13 +183,15 @@ {% set language = 'en' %} {% endif %} + {% block headtable %} + - + - + - +
- {% if p.get_fritz_device().is_available() %} + {% if p._fritz_device %} {{ _('Gerät verfügbar') }} {% else %} {{ _('Gerät nicht verfügbar') }} @@ -150,8 +199,8 @@ {{ _('Verbunden') }} - {% if p.get_fritz_device().is_available() %} - {{ _('Ja') }}{% if p._fritz_device.is_ssl() %}, SSL{% endif %} + {% if p._fritz_device %} + {{ _('Ja') }}{% if p._fritz_device.ssl %}, SSL{% endif %} {% else %} {{ _('Nein') }} {% endif %} @@ -161,136 +210,145 @@
- {% if p._call_monitor %} - {% if p.get_monitoring_service()._listen_active %} + {% if p._monitoring_service and p._monitoring_service._listen_active %} {{ _('Call Monitor verbunden') }} - {% else %} + {% else %} {{ _('Call Monitor nicht verbunden') }} - {% endif %} {% endif %} {{ _('Call Monitor') }} {% if p._call_monitor %}{{ _('Ja') }}{% if not p.get_monitoring_service()._listen_active %}, {{ _('nicht verbunden') }}{% endif %}{% else %}{{ _('Nein') }}{% endif %} + {% if p._monitoring_service %}{{ _('Ja') }}{% if not p._monitoring_service._listen_active %}, {{ _('nicht verbunden') }}{% endif %}{% else %}{{ _('Nein') }}{% endif %} {{ _('Passwort') }} {{ p.get_parameter_value_for_display('password') }}
{{ _('Host') }}{{ p._fritz_device.get_host() }}{{ p._fritz_device.host }} {{ _('Port') }}{{ p._fritz_device.get_port() }} {% if p._fritz_device.is_ssl() %}(HTTPS){% endif %}{{ p._fritz_device.port }} {% if p._fritz_device.ssl %}(HTTPS){% endif %}
{% endblock %} - + {% block buttons %} - + + {% endblock buttons %} + {% block bodytab1 %}
- +
+ + - - + + - {% for item in avm_items %} - {% set item_id = item.id() %} - {% if p.get_instance_name() %} - {% set instance_key = "avm_data_type@"+p.get_instance_name() %} - {% else %} - {% set instance_key = "avm_data_type" %} - {% endif %} - - - - - - - - - {% endfor %} + {% if tr064_items %} + {% for item in tr064_items %} + + + + + + + + + + + {% endfor %} + {% endif %}
{{ _('Pfad') }} {{ _('Typ') }} {{ _('AVM Datentyp') }}{{ _('Cycle') }} {{ _('Wert') }}{{ _('Letztes Update') }}{{ _('Letzter Change') }}{{ _('Letztes Update') }}{{ _('Letzter Change') }}
{{ item_id }}{{ item.property.type }}{{ item.conf[instance_key] }}{{ item() }}{{ item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') }}{{ item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') }}
{{ item.id() }}{{ item.property.type }}{{ p.fritz_device.item_dict[item][0] }}{{ p.fritz_device.item_dict[item][2] }}{{ item.property.value }}{{ item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') }}{{ item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') }}
{% endblock %} + {% block bodytab2 %}
- +
+ + - - + + - {% for item in smarthome_items %} - {% set item_id = item.id() %} - {% if p.get_instance_name() %} - {% set instance_key = "avm_data_type@"+p.get_instance_name() %} - {% else %} - {% set instance_key = "avm_data_type" %} - {% endif %} - - - - - - - - - {% endfor %} + {% if aha_items %} + {% for item in aha_items %} + + + + + + + + + + + {% endfor %} + {% endif %}
{{ _('Pfad') }} {{ _('Typ') }} {{ _('AVM Datentyp') }}{{ _('Cycle') }} {{ _('Wert') }}{{ _('Letztes Update') }}{{ _('Letzter Change') }}{{ _('Letztes Update') }}{{ _('Letzter Change') }}
{{ item_id }}{{ item.property.type }}{{ item.conf[instance_key] }}{{ item() }}{{ item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') }}{{ item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') }}
{{ item.id() }}{{ item.property.type }}{{ p.fritz_home.item_dict[item][0] }}{{ p.fritz_home.item_dict[item][2] }}{{ item.property.value }}{{ item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') }}{{ item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') }}
{% endblock %} + {% block bodytab3 %} -{% if p._fritz_device._smarthome_devices %}
- +
- - + + + + - {% for ain in p._fritz_device._smarthome_devices %} - - - - - - {% endfor %} + {% if p._fritz_home %} + {% for ain in p._fritz_home._aha_devices %} + + + + + + + {% endfor %} + {% endif %}
{{ 'No' }}{{ 'Device AIN' }}{{ 'Device AIN' }}{{ '' }} {{ 'Device Details (dict)' }}
{{ loop.index }}{{ ain }}{{ p._fritz_device._smarthome_devices[ain] }}
{{ ain }} + {{ p._fritz_home._aha_devices[ain] }}
-{% endif %} {% endblock %} + {% block bodytab4 %}
- +
+ @@ -300,7 +358,7 @@ - {% if p._call_monitor %} + {% if call_monitor_items %} {% for item in call_monitor_items %} {% set item_id = item.id() %} {% if p.get_instance_name() %} @@ -309,12 +367,13 @@ {% set instance_key = "avm_data_type" %} {% endif %} + - - - - + + + + {% endfor %} {% endif %} @@ -323,22 +382,24 @@ {% endblock %} + {% block bodytab5 %}
-
{{ _('Pfad') }} {{ _('Typ') }} {{ _('AVM Datentyp') }}
{{ item_id }} {{ item.property.type }}{{ item.conf[instance_key] }}{{ item() }}{{ item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') }}{{ item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') }}{{ item.conf[instance_key]}}{{ item.property.value }}{{ item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') }}{{ item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') }}
+
+ - - + + - {% set logentries = p.get_device_log_from_lua_separated() %} {% if logentries %} {% for logentry in logentries%} +
{{ 'Datum/Uhrzeit' }} {{ 'Meldung' }}{{ 'Typ' }}{{ 'Kategorie' }}{{ 'Typ' }}{{ 'Kategorie' }}
{{ logentry[0] }} {{ logentry[1] }} @@ -352,37 +413,89 @@ {% endif %}
+
{% endblock %} + {% block bodytab6 %}
- {% for function, dict in p.metadata.plugin_functions.items() %} -
-
- {{ dict['type'] }} {{ function }}({% if dict['parameters'] is not none %}{% for name, paramdict in dict['parameters'].items() %}{% if loop.index > 1 %}, {% endif %}{{ name }}: {{ paramdict['type'] }}{% endfor %}{% endif %}) -
-
- {{ dict['description'][language] }}
- {% if dict['parameters'] is not none %} -
-
- {{ _('Parameter') }}: -
-
-
    - {% for name, paramdict in dict['parameters'].items() %} -
  • - {{ name }}: {{ paramdict['type'] }}
    - {{ paramdict['description'][language] }} -
  • - {% endfor %} -
+ {% if not maintenance %} + {% for function, dict in p.metadata.plugin_functions.items() %} +
+ + {{ dict['type'] }} {{ function }} + ({% if dict['parameters'] is not none %} + {% for name in dict['parameters'] %} + {% if loop.index > 1 %}, {% endif %}{{ name }}: {{ dict['parameters'][name]['type'] }} + {% endfor %} + {% endif %}) + +
+
+ {{ dict['description'][language] }}
+ {% if dict['parameters'] is not none %} +
+
+ {{ _('Parameter') }}: +
+
+
    + {% for name in dict['parameters'] %} +
  • + {{ name }}: {{ dict['parameters'][name]['type'] }}
    + {{ dict['parameters'][name]['description'][language] }} +
  • + {% endfor %} +
+
-
- {% endif %} -
-
- {% endfor %} + {% endif %} +
+ {% endfor %} + {% endif %} + + {% if maintenance %} + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'Befehl' }}{{ 'Ergebnis' }}
{{ "fritz_device._items" }}{{ p.fritz_device._items }}
{{ "fritz_device._item_blacklist" }}{{ p.fritz_device._item_blacklist }}
{{ "_fritz_home._items" }}{{ p.fritz_home._items }}
{{ "self._data_cache" }}{{ p._fritz_device._data_cache }}
+ {% endif %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/backend/BackendItems.py b/backend/BackendItems.py deleted file mode 100755 index 6d347537d..000000000 --- a/backend/BackendItems.py +++ /dev/null @@ -1,383 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf8 -*- -######################################################################### -# Copyright 2016- René Frieß rene.friess@gmail.com -# Martin Sinn m.sinn@gmx.de -# Bernd Meiners -# Christian Strassburg c.strassburg@gmx.de -######################################################################### -# Backend plugin for SmartHomeNG -# -# This plugin is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This plugin is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this plugin. If not, see -######################################################################### - -import cherrypy -import platform -import collections -import datetime -#import pwd -import html -import subprocess -import socket -import sys -import threading -import os -import lib.config -from lib.item import Items -from lib.logic import Logics -import lib.logic # zum Test (für generate bytecode -> durch neues API ersetzen) -from lib.model.smartplugin import SmartPlugin -from .utils import * - -import lib.item_conversion - - -class BackendItems: - - def __init__(self): - - self.items = Items.get_instance() - self.logger.info("BackendItems __init__ {}".format(self.items)) - - # ----------------------------------------------------------------------------------- - # ITEMS - # ----------------------------------------------------------------------------------- - - @cherrypy.expose - def items_html(self, item_path=None): - """ - display a list of items - """ - return self.render_template('items.html', item_count=self.items.item_count(), item_path=item_path, - items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path']), - reverse=False)) - - @cherrypy.expose - def items_json(self, mode="tree"): - """ - returns a list of items as json structure - - :param mode: tree (default) or list structure - """ - items_sorted = sorted(self.items.return_items(), key=lambda k: str.lower(k['_path']), reverse=False) - - if mode == 'tree': - parent_items_sorted = [] - for item in items_sorted: - if "." not in item._path: - parent_items_sorted.append(item) - - item_data = self._build_item_tree(parent_items_sorted) - return json.dumps(item_data) - else: - item_list = [] - for item in items_sorted: - item_list.append(item._path) - return json.dumps(item_list) - - @cherrypy.expose - def cache_check_json_html(self): - """ - returns a list of items as json structure - """ - cache_path = "%s/var/cache/" % self._sh_dir - from os import listdir - from os.path import isfile, join - onlyfiles = [f for f in listdir(cache_path) if isfile(join(cache_path, f))] - unused_cache_files = [] - for file in onlyfiles: - if not file.find(".") == 0: # filter .gitignore etc. - item = self.items.return_item(file) - no_cache_file = False; - if item is None: - no_cache_file = True - elif not item._cache: - no_cache_file = True - - if no_cache_file: - file_data = {} - file_data['last_modified'] = datetime.datetime.fromtimestamp( - int(os.path.getmtime(cache_path + file)) - ).strftime('%Y-%m-%d %H:%M:%S') - file_data['created'] = datetime.datetime.fromtimestamp( - int(os.path.getctime(cache_path + file)) - ).strftime('%Y-%m-%d %H:%M:%S') - file_data['filename'] = file - file_data['filename'] = file - unused_cache_files.append(file_data) - - return json.dumps(unused_cache_files) - - @cherrypy.expose - def cache_file_delete_html(self, filename=''): - """ - deletes a file from cache - """ - if len(filename) > 0: - file_path = "%s/var/cache/%s" % (self._sh_dir, filename) - os.remove(file_path); - - return - - @cherrypy.expose - def item_change_value_html(self, item_path, value): - """ - Is called by items.html when an item value has been changed - """ - item_data = [] - item = self.items.return_item(item_path) - if self.updates_allowed: - if 'num' in item.type(): - if "." in value or "," in value: - value = float(value) - else: - value = int(value) - item(value, caller='Backend', source='item_change_value_html()') - - return - - def disp_str(self, val): - s = str(val) - if s == 'False': - s = '-' - elif s == 'None': - s = '-' - return s - - def age_to_string(self, days, hours, minutes, seconds): - s = '' - if days > 0: - s += str(int(days)) + ' ' - if days == 1: - s += translate('Tag') - else: - s += translate('Tage') - s += ', ' - if (hours > 0) or (s != ''): - s += str(int(hours)) + ' ' - if hours == 1: - s += translate('Stunde') - else: - s += translate('Stunden') - s += ', ' - if (minutes > 0) or (s != ''): - s += str(int(minutes)) + ' ' - if minutes == 1: - s += translate('Minute') - else: - s += translate('Minuten') - s += ', ' - if days > 0: - s += str(int(seconds)) - else: - s += str("%.2f" % seconds) - s += ' ' + translate('Sekunden') - return s - - def disp_age(self, age): - days = 0 - hours = 0 - minutes = 0 - seconds = age - if seconds >= 60: - minutes = int(seconds / 60) - seconds = seconds - 60 * minutes - if minutes > 59: - hours = int(minutes / 60) - minutes = minutes - 60 * hours - if hours > 23: - days = int(hours / 24) - hours = hours - 24 * days - return self.age_to_string(days, hours, minutes, seconds) - - def list_to_displaystring(self, l): - """ - """ - if type(l) is str: - return l - - edit_string = '' - for entry in l: - if edit_string != '': - edit_string += ' | ' - edit_string += str(entry) - if edit_string == '': - edit_string = '-' - # self.logger.info("list_to_displaystring: >{}< --> >{}<".format(l, edit_string)) - return edit_string - - def build_on_list(self, on_dest_list, on_eval_list): - """ - build on_xxx data - """ - on_list = [] - if on_dest_list is not None: - if isinstance(on_dest_list, list): - for on_dest, on_eval in zip(on_dest_list, on_eval_list): - if on_dest != '': - on_list.append(on_dest + ' = ' + on_eval) - else: - on_list.append(on_eval) - else: - if on_dest_list != '': - on_list.append(on_dest_list + ' = ' + on_eval_list) - else: - on_list.append(on_eval_list) - return on_list - - @cherrypy.expose - def item_detail_json_html(self, item_path): - """ - returns a list of items as json structure - """ - item_data = [] - item = self.items.return_item(item_path) - if item is not None: - #if item.type() is None or item.type() is '': - if item.type() is None or item.type() == '': - prev_value = '' - value = '' - else: - prev_value = item.prev_value() - value = item._value - - if isinstance(prev_value, datetime.datetime): - prev_value = str(prev_value) - - if 'str' in item.type(): - value = html.escape(value) - prev_value = html.escape(prev_value) - - cycle = '' - crontab = '' - for entry in self._sh.scheduler._scheduler: - if entry == "items." + item._path: - if self._sh.scheduler._scheduler[entry]['cycle']: - cycle = self._sh.scheduler._scheduler[entry]['cycle'] - if self._sh.scheduler._scheduler[entry]['cron']: - crontab = html.escape(str(self._sh.scheduler._scheduler[entry]['cron'])) - break - if cycle == '': - cycle = '-' - if crontab == '': - crontab = '-' - - changed_by = item.changed_by() - if changed_by[-5:] == ':None': - changed_by = changed_by[:-5] - - updated_by = item.updated_by() - if updated_by[-5:] == ':None': - updated_by = updated_by[:-5] - - if item.prev_age() < 0: - prev_age = '' - else: - prev_age = self.disp_age(item.prev_update_age()) - if item.prev_update_age() < 0: - prev_update_age = '' - else: - prev_update_age = self.disp_age(item.prev_update_age()) - - if str(item._cache) == 'False': - cache = 'off' - else: - cache = 'on' - if str(item._enforce_updates) == 'False': - enforce_updates = 'off' - else: - enforce_updates = 'on' - - item_conf_sorted = collections.OrderedDict(sorted(item.conf.items(), key=lambda t: str.lower(t[0]))) - if item_conf_sorted.get('sv_widget', '') != '': - item_conf_sorted['sv_widget'] = html.escape(item_conf_sorted['sv_widget']) - - logics = [] - for trigger in item.get_logic_triggers(): - logics.append(html.escape(format(trigger))) - triggers = [] - for trigger in item.get_method_triggers(): - trig = format(trigger) - trig = trig[1:len(trig) - 27] - triggers.append(html.escape(format(trig.replace("<", "")))) - - try: - upd_age = item.update_age() - except: - # if used lib.items doesn't support update_age() function - upd_age = item.age() - - # build on_update and on_change data - on_update_list = self.build_on_list(item._on_update_dest_var, item._on_update) - on_change_list = self.build_on_list(item._on_change_dest_var, item._on_change) - - self._trigger_condition_raw = item._trigger_condition_raw - if self._trigger_condition_raw == []: - self._trigger_condition_raw = '' - - data_dict = {'path': item._path, - 'name': item._name, - 'type': item.type(), - 'value': value, - 'age': self.disp_age(item.age()), - 'update_age': self.disp_age(item.update_age()), - 'last_update': str(item.last_update()), - 'last_change': str(item.last_change()), - 'changed_by': changed_by, - 'updated_by': updated_by, - 'previous_value': prev_value, - 'previous_age': prev_age, - 'previous_update_age': prev_update_age, - 'previous_update': str(item.prev_update()), - 'previous_change': str(item.prev_change()), - 'enforce_updates': enforce_updates, - 'cache': cache, - 'eval': html.escape(self.disp_str(item._eval)), - 'trigger': self.disp_str(item._trigger), - 'trigger_condition': self.disp_str(item._trigger_condition), - 'trigger_condition_raw': self.disp_str(self._trigger_condition_raw), - 'on_update': html.escape(self.list_to_displaystring(on_update_list)), - 'on_change': html.escape(self.list_to_displaystring(on_change_list)), - 'log_change': self.disp_str(item._log_change), - 'cycle': str(cycle), - 'crontab': str(crontab), - 'autotimer': self.disp_str(item._autotimer), - 'threshold': self.disp_str(item._threshold), - 'config': json.dumps(item_conf_sorted), - 'logics': json.dumps(logics), - 'triggers': json.dumps(triggers), - 'filename': str(item._filename), - } - - # cast raw data to a string - if item.type() in ['foo', 'list', 'dict']: - data_dict['value'] = str(item._value) - data_dict['previous_value'] = str(prev_value) - - item_data.append(data_dict) - return json.dumps(item_data) - else: - self.logger.error("Requested item %s is None, check if item really exists." % item_path) - return - - def _build_item_tree(self, parent_items_sorted): - item_data = [] - - for item in parent_items_sorted: - nodes = self._build_item_tree(item.return_children()) - tags = [] - tags.append(len(nodes)) - item_data.append({'path': item._path, 'name': item._name, 'tags': tags, 'nodes': nodes}) - - return item_data diff --git a/backend/BackendLogging.py b/backend/BackendLogging.py deleted file mode 100755 index 54fbac2d5..000000000 --- a/backend/BackendLogging.py +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf8 -*- -######################################################################### -# Copyright 2016- René Frieß rene.friess@gmail.com -# Martin Sinn m.sinn@gmx.de -# Bernd Meiners -# Christian Strassburg c.strassburg@gmx.de -######################################################################### -# Backend plugin for SmartHomeNG -# -# This plugin is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This plugin is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this plugin. If not, see . -######################################################################### - -import cherrypy - -import logging -import os -import html - -class BackendLogging: - - - def __init__(self): - - self.logger.info("BackendLogging __init__ {}".format('')) - - - # ----------------------------------------------------------------------------------- - # LOGGING - # ----------------------------------------------------------------------------------- - - @cherrypy.expose - def logging_html(self): - """ - display a list of all loggers - """ - loggerDict = {} - # Filter to get only active loggers - for l in logging.Logger.manager.loggerDict: - if (logging.getLogger(l).level > 0) or (logging.getLogger(l).handlers != []): - loggerDict[l] = logging.Logger.manager.loggerDict[l] - - - # get information about active loggers - loggerList_sorted = sorted(loggerDict) - loggerList_sorted.insert(0, "root") # Insert information about root logger at the beginning of the list - loggers = [] - for ln in loggerList_sorted: - if ln == 'root': - logger = logging.root - else: - logger = logging.getLogger(ln) - l = dict() - l['name'] = logger.name - l['disabled'] = logger.disabled - - # get information about loglevels - if logger.level == 0: - l['level'] = '' - elif logger.level in logging._levelToName: - l['level'] = logging._levelToName[logger.level] - else: - l['level'] = logger.level - - l['filters'] = logger.filters - - # get information about handlers and filenames - l['handlers'] = list() - l['filenames'] = list() - for h in logger.handlers: - l['handlers'].append(h.__class__.__name__) - try: - fn = str(h.baseFilename) - except: - fn = '' - l['filenames'].append(fn) - - if l['handlers'] == ['NullHandler']: - self.logger.debug("logging_html: Filtered out logger {}: l['handlers'] = {}".format(l['name'], l['handlers'])) - else: - loggers.append(l) - - return self.render_template('logging.html', loggers=loggers) - - - @cherrypy.expose - def log_view_html(self, text_filter='', log_level_filter='ALL', page=1, logfile='smarthome.log'): - """ - returns the smarthomeNG logfile as view - """ - log = '/var/log/' + os.path.basename(logfile) - log_name = self._sh_dir + log - fobj = open(log_name) - log_lines = [] - start = (int(page) - 1) * 1000 - end = start + 1000 - counter = 0 - log_level_hit = False - total_counter = 0 - for line in fobj: - line_text = html.escape(line) - if log_level_filter != "ALL" and not self.validate_date(line_text[0:10]) and log_level_hit: - if start <= counter < end: - log_lines.append(line_text) - counter += 1 - else: - log_level_hit = False - if (log_level_filter == "ALL" or line_text.find(log_level_filter) in [19, 20, 21, 22, - 23]) and text_filter in line_text: - if start <= counter < end: - log_lines.append(line_text) - log_level_hit = True - counter += 1 - fobj.close() - num_pages = -(-counter // 1000) - if num_pages == 0: - num_pages = 1 - return self.render_template('log_view.html', - current_page=int(page), pages=num_pages, log_level_filter=log_level_filter, - logfile=os.path.basename(log_name), log_lines=log_lines, text_filter=text_filter) - - - @cherrypy.expose - def log_dump_html(self, logfile='smarthome.log'): - """ - returns the smarthomeNG logfile as download - """ - log = '/var/log/' + os.path.basename(logfile) - log_name = self._sh_dir + log - mime = 'application/octet-stream' - return cherrypy.lib.static.serve_file(log_name, mime, log_name) - - diff --git a/backend/BackendLogics.py b/backend/BackendLogics.py deleted file mode 100755 index 32ac0e14d..000000000 --- a/backend/BackendLogics.py +++ /dev/null @@ -1,518 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf8 -*- -######################################################################### -# Copyright 2016- René Frieß rene.friess@gmail.com -# Martin Sinn m.sinn@gmx.de -# Bernd Meiners -# Christian Strassburg c.strassburg@gmx.de -######################################################################### -# Backend plugin for SmartHomeNG -# -# This plugin is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This plugin is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this plugin. If not, see . -######################################################################### - -import cherrypy -import platform -#import collections -import datetime -#import pwd -import html -import subprocess -import socket -import sys -import threading -import os - -import lib.config -from lib.plugin import Plugins -from lib.logic import Logics -from lib.scheduler import Scheduler - -import lib.logic # zum Test (für generate bytecode -> durch neues API ersetzen) -from lib.utils import Utils - -from lib.model.smartplugin import SmartPlugin - -from .utils import * - -import lib.item_conversion - -class BackendLogics: - - logics = None - _logicname_prefix = 'logics.' # prefix for scheduler names - - def __init__(self): - - # !! Cannot initialze self.logics here, because at startup logics are initialized after plugins !! - self.logics = Logics.get_instance() - self.logger.info("BackendLogics __init__ self.logics = {}".format(self.logics)) - self.plugins = Plugins.get_instance() - self.logger.info("BackendLogics __init__ self.plugins = {}".format(str(self.plugins))) - self.scheduler = Scheduler.get_instance() - self.logger.info("BackendLogics __init__ self.scheduler = {}".format(self.scheduler)) - - - def logics_initialize(self): - """ - Initialize access to logics API and test if Blockly plugin is loaded - - This can't be done during __init__, since not all components are loaded/initialized - at that time. - """ - if self.logics is not None: - return - - self.logics = Logics.get_instance() - self.yaml_updates=(self.logics.return_config_type() == '.yaml') - - # find out if blockly plugin is loaded - if self.blockly_plugin_loaded == None: - self.blockly_plugin_loaded = False -# for x in self._sh._plugins: -# for x in self._sh.return_plugins(): - for x in self.plugins.return_plugins(): - try: - if x.get_shortname() == 'blockly': - self.blockly_plugin_loaded = True - except: - pass - return - - - # ----------------------------------------------------------------------------------- - # LOGICS - # ----------------------------------------------------------------------------------- - - def fill_logicdict(self, logicname): - """ - Returns a dict filled with information of the specified loaded logic - """ - mylogic = dict() - loaded_logic = self.logics.return_logic(logicname) - if loaded_logic is not None: - mylogic['name'] = loaded_logic.name - mylogic['enabled'] = loaded_logic.enabled - mylogic['logictype'] = self.logics.return_logictype(loaded_logic.name) - mylogic['userlogic'] = self.logics.is_userlogic(loaded_logic.name) - mylogic['filename'] = loaded_logic.filename - mylogic['pathname'] = loaded_logic.pathname - mylogic['cycle'] = '' - if hasattr(self.logics.return_logic(logicname), 'cycle'): - mylogic['cycle'] = loaded_logic.cycle - if mylogic['cycle'] == None: - mylogic['cycle'] = '' - - mylogic['crontab'] = '' - if hasattr(loaded_logic, 'crontab'): - if loaded_logic.crontab is not None: -# mylogic['crontab'] = Utils.strip_quotes_fromlist(str(loaded_logic.crontab)) - mylogic['crontab'] = Utils.strip_quotes_fromlist(self.list_to_editstring(loaded_logic.crontab)) - - mylogic['crontab'] = Utils.strip_square_brackets(mylogic['crontab']) - - mylogic['watch_item'] = '' - mylogic['watch_item_list'] = [] - if hasattr(loaded_logic, 'watch_item'): - # Attention: watch_items are always stored as a list in logic object - mylogic['watch_item'] = Utils.strip_quotes_fromlist(str(loaded_logic.watch_item)) - mylogic['watch_item_list'] = loaded_logic.watch_item - - mylogic['next_exec'] = '' -# if self._sh.scheduler.return_next(self._logicname_prefix+loaded_logic.name): -# mylogic['next_exec'] = self._sh.scheduler.return_next(self._logicname_prefix+loaded_logic.name).strftime('%Y-%m-%d %H:%M:%S%z') - if self.scheduler.return_next(self._logicname_prefix+loaded_logic.name): - mylogic['next_exec'] = self.scheduler.return_next(self._logicname_prefix+loaded_logic.name).strftime('%Y-%m-%d %H:%M:%S%z') - - mylogic['last_run'] = '' - if loaded_logic.last_run(): - mylogic['last_run'] = loaded_logic.last_run().strftime('%Y-%m-%d %H:%M:%S%z') - - mylogic['visu_acl'] = '' - if hasattr(loaded_logic, 'visu_acl'): - if loaded_logic.visu_acl != 'None': - mylogic['visu_acl'] = Utils.strip_quotes_fromlist(str(loaded_logic.visu_acl)) - - return mylogic - - - @cherrypy.expose - def logics_html(self, logic=None, trigger=None, reload=None, enable=None, disable=None, unload=None, add=None, delete=None): - """ - returns information to display a list of all known logics - """ - self.logics_initialize() - - # process actions triggerd by buttons on the web page - logicname=logic - if trigger is not None: - self.logics.trigger_logic(logicname) - elif reload is not None: - self.logics.load_logic(logicname) # implies unload_logic() - self.logics.trigger_logic(logicname) - elif enable is not None: - self.logics.enable_logic(logicname) - elif disable is not None: - self.logics.disable_logic(logicname) - elif unload is not None: - self.logics.unload_logic(logicname) - - elif add is not None: - if not self.logics.load_logic(logicname): - self.logger.error("Could not load logic '{}', syntax error".format(logicname)) - - elif delete is not None: - self.logics.delete_logic(logicname) - - # create a list of dicts, where each dict contains the information for one logic - logics_list = [] - import time - for ln in self.logics.return_loaded_logics(): - logic = self.fill_logicdict(ln) - if logic['logictype'] == 'Blockly': - logic['pathname'] = os.path.splitext(logic['pathname'])[0] + '.blockly' - logics_list.append(logic) - self.logger.debug("Backend: logics_html: - logic = {}, enabled = {}, , logictype = {}, filename = {}, userlogic = {}, watch_item = {}".format(str(logic['name']), str(logic['enabled']), str(logic['logictype']), str(logic['filename']), str(logic['userlogic']), str(logic['watch_item'])) ) - - newlogics = sorted(self.logic_findnew(logics_list), key=lambda k: k['name']) - logics_sorted = sorted(logics_list, key=lambda k: k['name']) - return self.render_template('logics.html', updates=self.updates_allowed, yaml_updates=self.yaml_updates, logics=logics_sorted, newlogics=newlogics, - blockly_loaded=self.blockly_plugin_loaded) - - - def logic_findnew(self, loadedlogics): - """ - Find new logics (logics defined in /etc/logic.yaml but not loaded) - """ - _config = {} -# _config.update(self._sh._logics._read_logics(self._sh._logic_conf_basename, self._sh._logic_dir)) - _config.update(self.logics._read_logics(self.logics._get_logic_conf_basename(), self.logics.get_logics_dir())) - - self.logger.info("logic_findnew: _config = '{}'".format(_config)) - newlogics = [] - for configlogic in _config: - found = False - for l in loadedlogics: - if configlogic == str(l['name']): - found = True - if not found: - self.logger.info("Backend (logic_findnew): name = {}".format(configlogic)) - if _config[configlogic] != 'None': - mylogic = {} - mylogic['name'] = configlogic - mylogic['userlogic'] = True - mylogic['logictype'] = self.logics.return_logictype(mylogic['name']) - if mylogic['logictype'] == 'Python': - mylogic['filename'] = _config[configlogic]['filename'] - mylogic['pathname'] = self.logics.get_logics_dir() + mylogic['filename'] - elif mylogic['logictype'] == 'Blockly': - mylogic['filename'] = _config[configlogic]['filename'] - mylogic['pathname'] = os.path.splitext(self.logics.get_logics_dir() + _config[configlogic]['filename'])[0] + '.blockly' -# mylogic['pathname'] = os.path.splitext(_config[configlogic]['filename'])[0] + '.blockly' - else: - mylogic['filename'] = '' - - newlogics.append(mylogic) -# self.logger.info("Backend (logic_findnew): newlogics = '{}'".format(newlogics)) - return newlogics - - - # ----------------------------------------------------------------------------------- - # LOGICS - VIEW - # ----------------------------------------------------------------------------------- - - @cherrypy.expose - def logics_view_html(self, logicname, file_path=None, - trigger=None, enable=None, disable=None, save=None, savereload=None, savereloadtrigger=None, - logics_code=None, cycle=None, crontab=None, watch=None, visu_acl=None): - """ - returns information to display a logic in an editor window - """ - self.logics_initialize() - -# self.logger.info("logics_view_html: logicname = {}, trigger = {}, enable = {}, disable = {}, save = {}, savereload = {}, savereloadtrigger = {}".format( logicname, trigger, enable, disable, save, savereload, savereloadtrigger )) -# self.logger.info("logics_view_html: logicname = {}, cycle = {}, crontab = {}, watch = {}".format( logicname, cycle, crontab, watch )) - - # process actions triggerd by buttons on the web page - if trigger is not None: - self.logics.trigger_logic(logicname) - elif enable is not None: - self.logics.enable_logic(logicname) - elif disable is not None: - self.logics.disable_logic(logicname) - elif save is not None: - if logicname in self.logics.return_loaded_logics(): - self.logic_save_code(logicname, logics_code) - else: - filename = os.path.basename(file_path) - self.logic_create_codefile(filename, logics_code, overwrite=True) - self.logic_save_config(logicname, cycle, crontab, watch, visu_acl, file_path) - elif savereload is not None: - if logicname in self.logics.return_loaded_logics(): - self.logic_save_code(logicname, logics_code) - else: - filename = os.path.basename(file_path) - self.logic_create_codefile(filename, logics_code, overwrite=True) - self.logic_save_config(logicname, cycle, crontab, watch, visu_acl, file_path) - if not self.logics.load_logic(logicname): - self.logger.error("Could not load logic '{}' after saving; syntax error in logic".format(logicname)) - elif savereloadtrigger is not None: - if logicname in self.logics.return_loaded_logics(): - self.logic_save_code(logicname, logics_code) - else: - filename = os.path.basename(file_path) - self.logic_create_codefile(filename, logics_code, overwrite=True) - self.logic_save_config(logicname, cycle, crontab, watch, visu_acl, file_path) - if not self.logics.load_logic(logicname): - self.logger.error("Could not load logic '{}' after saving; syntax error in logic".format(logicname)) - else: - self.logics.trigger_logic(logicname) - - # assemble data for displaying/editing of a logic - mylogic = self.fill_logicdict(logicname) - - if file_path is None: - if 'pathname' in mylogic: - file_path = mylogic['pathname'] - else: - self.logger.error('No pathname for logic given or pathname cannot be retrieved via logic name!') - - config_list = self.logics.read_config_section(logicname) - for config in config_list: - if config[0] == 'cycle': - mylogic['cycle'] = config[1] - if config[0] == 'crontab': -# mylogic['crontab'] = config[1] - self.logger.debug("logics_view_html: crontab = >{}<".format(config[1])) - edit_string = self.list_to_editstring(config[1]) - mylogic['crontab'] = Utils.strip_quotes_fromlist(edit_string) - if config[0] == 'watch_item': - # Attention: watch_items are always stored as a list in logic object - edit_string = self.list_to_editstring(config[1]) - mylogic['watch'] = Utils.strip_quotes_fromlist(edit_string) - mylogic['watch_item'] = Utils.strip_quotes_fromlist(edit_string) - mylogic['watch_item_list'] = config[1] - if config[0] == 'visu_acl': - mylogic['visu_acl'] = config[1] - - if os.path.splitext(file_path)[1] == '.blockly': - mode = 'xml' - updates = False - else: - mode = 'python' - updates=self.updates_allowed - if not 'userlogic' in mylogic: - mylogic['userlogic'] = True - if mylogic['userlogic'] == False: - updates = False - - file_lines = [] -# if mylogic != {}: - if mylogic.get('enabled', None) is not None: - file_lines = self.logic_load_code(logicname, os.path.splitext(file_path)[1]) - else: - file_lines = self.unloaded_logic_load_code(file_path) - - # create a list of dicts, where each dict contains the information for one logic - logics_list = [] - import time - for ln in self.logics.return_loaded_logics(): - logic = self.fill_logicdict(ln) - if logic['logictype'] == 'Blockly': - logic['pathname'] = os.path.splitext(logic['pathname'])[0] + '.blockly' - logics_list.append(logic) - self.logger.debug("Backend: logics_html: - logic = {}, enabled = {}, , logictype = {}, filename = {}, userlogic = {}, watch_item = {}".format(str(logic['name']), str(logic['enabled']), str(logic['logictype']), str(logic['filename']), str(logic['userlogic']), str(logic['watch_item'])) ) - - newlogics = sorted(self.logic_findnew(logics_list), key=lambda k: k['name']) - logic_loadable=(mylogic in newlogics) - logic_loadable=True - return self.render_template('logics_view.html', logicname=logicname, thislogic=mylogic, logic_lines=file_lines, file_path=file_path, - updates=updates, yaml_updates=self.yaml_updates, mode=mode, logic_loadable=logic_loadable) - - - # ----------------------------------------------------------------------------------- - # LOGICS - NEW - # ----------------------------------------------------------------------------------- - - @cherrypy.expose - def logics_new_html(self, create=None, filename='', logicname=''): - """ - returns information to display a logic in an editor window - """ - self.logics_initialize() - - self.logger.info("logics_new_html: create = {}, filename = '{}', logicname = '{}'".format(create, filename, logicname)) - - # process actions triggerd by buttons on the web page - message = '' - if create is not None: - if filename != '': - if logicname == '': - logicname = filename - filename = filename.lower() + '.py' - - if logicname in self.logics.return_defined_logics(): - message = translate("Der Logikname wird bereits verwendet") - else: - logics_code = '#!/usr/bin/env python3\n' + '# ' + filename + '\n\n' - if self.logic_create_codefile(filename, logics_code): - self.logic_create_config(logicname, filename) - if not self.logics.load_logic(logicname): - self.logger.error("Could not load logic '{}', syntax error".format(logicname)) - -# self.logics.disable_logic(logicname) - redir = ''.format(self.logics.get_logics_dir()+filename, logicname) - return redir - - else: - message = translate("Logik-Datei")+" '"+filename+"' "+translate("existiert bereits") - else: - message = translate('Bitte Dateinamen angeben') - - filename = os.path.splitext(filename)[0] - return self.render_template('logics_new.html', message=message, filename=filename, logicname=logicname, - updates=self.updates_allowed, yaml_updates=self.yaml_updates) - - - # ----------------------------------------------------------------------------------- - - - def list_to_editstring(self, l): - """ - """ - if type(l) is str: - self.logger.debug("list_to_editstring: >{}< --> >{}<".format(l, l)) - return l - - edit_string = '' - for entry in l: - if edit_string != '': - edit_string += ' | ' - edit_string += str(entry) - self.logger.debug("list_to_editstring: >{}< --> >{}<".format(l, edit_string)) - return edit_string - - - def editstring_to_list(self, param_string): - - if param_string is None: - return '' - else: - l1 = param_string.split('|') - if len(l1) > 1: - # string contains a list - l2 = [] - for s in l1: - l2.append(Utils.strip_quotes(s.strip())) - param_string = l2 - else: - # string contains a single entry - param_string = Utils.strip_quotes(param_string) - return param_string - - - def logic_create_config(self, logicname, filename): - """ - Create a new configuration for a logic - """ - config_list = [] - config_list.append(['filename', filename, '']) - config_list.append(['enabled', False, '']) - self.logics.update_config_section(True, logicname, config_list) -# self.logics.set_config_section_key(logicname, 'visu_acl', False) - return - - - def logic_save_config(self, logicname, cycle, crontab, watch, visu_acl, file_path): - """ - Save configuration data of a logic - - Convert input strings to lists (if necessary) and write configuration to /etc/logic.yaml - """ - config_list = [] - thislogic = self.logics.return_logic(logicname) - if thislogic is None: - config_list.append(['filename', os.path.basename(file_path), '']) - else: - config_list.append(['filename', thislogic.filename, '']) - if Utils.is_int(cycle): - cycle = int(cycle) - if cycle > 0: - config_list.append(['cycle', cycle, '']) - - crontab = self.editstring_to_list(crontab) - if crontab != '': - config_list.append(['crontab', str(crontab), '']) - - watch = self.editstring_to_list(watch) - if watch != '': - config_list.append(['watch_item', str(watch), '']) - - self.logics.update_config_section(True, logicname, config_list) - if visu_acl == '': - visu_acl = None -# visu_acl = 'false' - self.logics.set_config_section_key(logicname, 'visu_acl', visu_acl) - return - - - def unloaded_logic_load_code(self, file_path): - - file_lines = [] - fobj = open(file_path) - for line in fobj: - file_lines.append(html.escape(line)) - fobj.close() - return file_lines - - - def logic_load_code(self, logicname, code_type='.python'): - - file_lines = [] - if logicname in self.logics.return_loaded_logics(): - mylogic = self.logics.return_logic(logicname) - if code_type == '.blockly': - pathname = os.path.splitext(mylogic.pathname)[0] + '.blockly' - else: - pathname = mylogic.pathname - file_lines = self.unloaded_logic_load_code(pathname) - return file_lines - - - def logic_save_code(self, logicname, logics_code): - - self.logger.info("logic_save_code: type(logics_code) = {}".format(str(type(logics_code)))) - if self.updates_allowed: - if logicname in self.logics.return_loaded_logics(): - mylogic = self.logics.return_logic(logicname) - - f = open(mylogic.pathname, 'w') - f.write(logics_code) - f.close() - return - - - def logic_create_codefile(self, filename, logics_code, overwrite=False): - - pathname = self.logics.get_logics_dir() + filename - if not overwrite: - if os.path.isfile(pathname): - return False - - f = open(pathname, 'w') - f.write(logics_code) - f.close() - - return True - diff --git a/backend/BackendPlugins.py b/backend/BackendPlugins.py deleted file mode 100755 index 6c5fa0bbf..000000000 --- a/backend/BackendPlugins.py +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf8 -*- -######################################################################### -# Copyright 2016- René Frieß rene.friess@gmail.com -# Martin Sinn m.sinn@gmx.de -# Bernd Meiners -# Christian Strassburg c.strassburg@gmx.de -######################################################################### -# Backend plugin for SmartHomeNG -# -# This plugin is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This plugin is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this plugin. If not, see . -######################################################################### - -import cherrypy - -import lib.config -from lib.plugin import Plugins -from lib.model.smartplugin import SmartPlugin -import inspect - -from .utils import * - - -# import lib.item_conversion - -class BackendPlugins: - plugins = None - - def __init__(self): - - self.plugins = Plugins.get_instance() - self.logger.info("BackendPlugins __init__ self.plugins = {}".format(str(self.plugins))) - - # ----------------------------------------------------------------------------------- - # PLUGINS - # ----------------------------------------------------------------------------------- - - @cherrypy.expose - def plugins_html(self, configname=None, shortname=None, instancename=None, enable=None, disable=None, unload=None): - """ - display a list of all known plugins - """ - # process actions triggerd by buttons on the web page - if enable is not None: - myplg = self.plugins.return_plugin(configname) - myplg2 = self.plugins.get_pluginthread(configname) - myplg.run() - self.logger.warning( - "disable: configname = {}, myplg = {}, myplg.alive = {}, myplg2 = {}".format(configname, myplg, - myplg.alive, myplg2)) - elif disable is not None: - myplg = self.plugins.return_plugin(configname) - myplg2 = self.plugins.get_pluginthread(configname) - myplg.stop() - self.logger.warning( - "disable: configname = {}, myplg = {}, myplg.alive = {}, myplg2 = {}".format(configname, myplg, - myplg.alive, myplg2)) - elif unload is not None: - result = self.plugins.unload_plugin(configname) - - # get data for display of page - conf_plugins = {} - _conf = lib.config.parse(self.plugins._get_plugin_conf_filename()) - - for plugin in _conf: - conf_plugins[plugin] = {} - conf_plugins[plugin] = _conf[plugin] - - plugin_list = [] - for x in self.plugins.return_plugins(): - plugin = dict() - plugin['stopped'] = False - plugin['metadata'] = x._metadata - if isinstance(x, SmartPlugin): - if bool(x._parameters): - plugin['attributes'] = x._parameters - else: - plugin['attributes'] = conf_plugins.get(x.get_configname(), {}) - plugin['smartplugin'] = True - plugin['instancename'] = x.get_instance_name() - plugin['instance'] = x - plugin['multiinstance'] = x.is_multi_instance_capable() - plugin['version'] = x.get_version() - plugin['configname'] = x.get_configname() - plugin['shortname'] = x.get_shortname() - plugin['classpath'] = x._classpath - plugin['classname'] = x.get_classname() - else: - plugin['attributes'] = {} - plugin['smartplugin'] = False - plugin['instance'] = x - plugin['configname'] = x._configname - plugin['shortname'] = x._shortname - plugin['classpath'] = x._classpath - plugin['classname'] = x._classname - plugin['stopped'] = False - - try: - plugin['stopped'] = not x.alive - plugin['stoppable'] = True - except: - plugin['stopped'] = False - plugin['stoppable'] = False - if plugin['shortname'] == 'backend': - plugin['stoppable'] = False - - plugin_list.append(plugin) - plugins_sorted = sorted(plugin_list, key=lambda k: k['classpath']) - - return self.render_template('plugins.html', plugins=plugins_sorted, lang=get_translation_lang(), - mod_http=self._bs.mod_http) - - @cherrypy.expose - def plugins_json(self): - """ - returns a list of plugin names (from config) as json structure - """ - not_allowed_functions = ['__init__', 'parse_item', 'parse_logic', 'update_item', 'init_webinterface', - 'init_webinterfaces'] - plugin_list = [] - for x in self.plugins.return_plugins(): - if isinstance(x, SmartPlugin): - plugin_config_name = x.get_configname() - if x.metadata is not None: - api = x.metadata.get_plugin_function_defstrings(with_type=True, with_default=True) - if api is not None: - for function in api: - plugin_list.append(plugin_config_name + "." +function) - - return json.dumps(plugin_list) \ No newline at end of file diff --git a/backend/BackendScenes.py b/backend/BackendScenes.py deleted file mode 100755 index d77421232..000000000 --- a/backend/BackendScenes.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf8 -*- -######################################################################### -# Copyright 2016- René Frieß rene.friess@gmail.com -# Martin Sinn m.sinn@gmx.de -# Bernd Meiners -# Christian Strassburg c.strassburg@gmx.de -######################################################################### -# Backend plugin for SmartHomeNG -# -# This plugin is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This plugin is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this plugin. If not, see . -######################################################################### - -import cherrypy - -class BackendScenes: - - - def __init__(self): - - self.logger.info("BackendScenes __init__ {}".format('')) - - - # ----------------------------------------------------------------------------------- - # SCENES - # ----------------------------------------------------------------------------------- - - @cherrypy.expose - def scenes_html(self): - - from lib.scene import Scenes - get_param_func = getattr(Scenes, "get_instance", None) - if callable(get_param_func): - supported = True - self.scenes = Scenes.get_instance() - scene_list = self.scenes.get_loaded_scenes() - - disp_scene_list = [] - for scene in scene_list: - scene_dict = {} - scene_dict['path'] = scene - scene_dict['name'] = str(self._sh.return_item(scene)) - - action_list = self.scenes.get_scene_actions(scene) - scene_dict['value_list'] = action_list - scene_dict[scene] = action_list - - disp_action_list = [] - for value in action_list: - action_dict = {} - action_dict['action'] = value - action_dict['action_name'] = self.scenes.get_scene_action_name(scene, value) - action_list = self.scenes.return_scene_value_actions(scene, value) - for action in action_list: - if not isinstance(action[0], str): - action[0] = action[0].id() - action_dict['action_list'] = action_list - - disp_action_list.append(action_dict) - scene_dict['values'] = disp_action_list - self.logger.info("scenes_html: disp_action_list for scene {} = {}".format(scene, disp_action_list)) - - disp_scene_list.append(scene_dict) - else: - supported = False - return self.render_template('scenes.html', supported=supported, scene_list=disp_scene_list) - - - diff --git a/backend/BackendSchedulers.py b/backend/BackendSchedulers.py deleted file mode 100755 index 7e4643172..000000000 --- a/backend/BackendSchedulers.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf8 -*- -######################################################################### -# Copyright 2016- René Frieß rene.friess@gmail.com -# Martin Sinn m.sinn@gmx.de -# Bernd Meiners -# Christian Strassburg c.strassburg@gmx.de -######################################################################### -# Backend plugin for SmartHomeNG -# -# This plugin is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This plugin is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this plugin. If not, see . -######################################################################### - -import cherrypy -import html -# -from lib.scheduler import Scheduler - - -class BackendSchedulers: - - - def __init__(self): - - self.scheduler = Scheduler.get_instance() - self.logger.info("BackendSchedulers __init__ self.scheduler = {}".format(self.scheduler)) - - - # ----------------------------------------------------------------------------------- - # SCHEDULERS - # ----------------------------------------------------------------------------------- - - @cherrypy.expose - def schedules_html(self): - """ - display a list of all known schedules - """ - - schedule_list = [] -# for entry in self._sh.scheduler._scheduler: - for entry in self.scheduler._scheduler: - schedule = dict() -# s = self._sh.scheduler._scheduler[entry] - s = self.scheduler._scheduler[entry] - if s['next'] != None and s['cycle'] != '' and s['cron'] != '': - schedule['fullname'] = entry - schedule['name'] = entry - schedule['group'] = '' - schedule['next'] = s['next'].strftime('%Y-%m-%d %H:%M:%S%z') - schedule['cycle'] = s['cycle'] - schedule['cron'] = html.escape(str(s['cron'])) - - if schedule['cycle'] == None: - schedule['cycle'] = '-' - if schedule['cron'] == None: - schedule['cron'] = '-' - - nl = entry.split('.') - if nl[0].lower() in ['items','logics','plugins']: - schedule['group'] = nl[0].lower() - del nl[0] - schedule['name'] = '.'.join(nl) - - schedule_list.append(schedule) - - schedule_list_sorted = sorted(schedule_list, key=lambda k: k['fullname'].lower()) - return self.render_template('schedules.html', schedule_list=schedule_list_sorted) - - - diff --git a/backend/BackendServices.py b/backend/BackendServices.py deleted file mode 100755 index eb5f5bf3b..000000000 --- a/backend/BackendServices.py +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf8 -*- -######################################################################### -# Copyright 2016- René Frieß rene.friess@gmail.com -# Martin Sinn m.sinn@gmx.de -# Bernd Meiners -# Christian Strassburg c.strassburg@gmx.de -######################################################################### -# Backend plugin for SmartHomeNG -# -# This plugin is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This plugin is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this plugin. If not, see . -######################################################################### - -import cherrypy -import platform -import collections -import datetime -#import pwd -import html -import subprocess -import socket -import sys -import threading -import os - -import lib.config -import lib.daemon -from lib.logic import Logics -import lib.logic # zum Test (für generate bytecode -> durch neues API ersetzen) -from lib.model.smartplugin import SmartPlugin -from .utils import * - -import lib.item_conversion - -class BackendServices: - - - def __init__(self): - - self.logger.info("BackendServices __init__ {}".format('')) - - - # ----------------------------------------------------------------------------------- - # SERVICES - # ----------------------------------------------------------------------------------- - - @cherrypy.expose - def services_html(self): - """ - shows a page with info about some services needed by smarthome - """ - knxd_service = get_process_info("systemctl status knxd.service") - smarthome_service = get_process_info("systemctl status smarthome.service") - knxd_socket = get_process_info("systemctl status knxd.socket") - - knxdeamon = '' - if get_process_info("ps cax|grep eibd") != '': - knxdeamon = 'eibd' - if get_process_info("ps cax|grep knxd") != '': - if knxdeamon != '': - knxdeamon += ' and ' - knxdeamon += 'knxd' - - sql_plugin = False - database_plugin = [] - - if self._sh.plugins is not None: - # Only, if plugins are in initialized - for x in self._sh.plugins: # TO DO: umstellen auf plugin api - if x.__class__.__name__ == "SQL": - sql_plugin = True - break - elif x.__class__.__name__ == "Database": - database_plugin.append(x.get_instance_name()) - - service_ctrl = os_service_controllable() - shng_service = False - if service_ctrl: - shng_service = os_service_status('smarthome') - - return self.render_template('services.html', - service_ctrl=service_ctrl, shng_service=shng_service, - knxd_service=knxd_service, knxd_socket=knxd_socket, knxdeamon=knxdeamon, - smarthome_service=smarthome_service, lang=get_translation_lang(), - sql_plugin=sql_plugin, database_plugin=database_plugin) - - - @cherrypy.expose - def services_shng_restart_html(self): - """ - Restart shNG service and reshow services page - """ - if os_service_status('smarthome'): - os_service_restart('smarthome') - result = "" + translate('Restart des Service sollte erfolgen - Bitte warten') + "" - else: - if os.name != 'nt': - pid = lib.daemon.read_pidfile(self.plugin.get_sh()._pidfile) - os_restart_shng(pid) - result = "" + translate('Restart des Prozesses sollte erfolgen - Bitte warten') + "" - else: - result = "" + translate('Unter Windows kann derzeit nicht neu gestartet werden') + "" - - result = result.replace('\n', '
') - return self.render_template('services_shng_restart.html', - msg=result) - # return '' - - - @cherrypy.expose - def reload_translation_html(self, lang=''): - if lang != '': - load_translation(lang) - self.plugin.get_sh().set_defaultlanguage(lang) - else: - load_translation(get_translation_lang()) - return self.main_html() - - @cherrypy.expose - def reboot(self): - passwd = request.form['password'] - rbt1 = subprocess.Popen(["echo", passwd], stdout=subprocess.PIPE) - rbt2 = subprocess.Popen(["sudo", "-S", "reboot"], stdin=rbt1. - stdout, stdout=subprocess.PIPE) - print(rbt2.communicate()[0]) - return redirect('/services.html') - - def validate_date(self, date_text): - try: - datetime.datetime.strptime(date_text, '%Y-%m-%d') - return True - except ValueError: - return False - - # ----------------------------------------------------------------------------------- - - def strip_empty_lines(self, txt): - """ - Remove \r from text and remove exessive empty lines from end - """ - txt = txt.replace('\r','').rstrip() - while txt.endswith('\n'): - txt = txt[:-1].rstrip() - txt += '\n\n' -# self.logger.warning("strip_empty_lines: txt = {}".format(txt)) - return txt - - - def append_empty_lines(self, txt, lines): - """ - Append empty lines until text is 'lines' long - """ - if len(txt.split('\n')) < lines: - txt += '\n' * (lines - len(txt.split('\n')) +1) - return txt - - - @cherrypy.expose - def conf_yaml_converter_html(self, convert=None, conf_code=None, yaml_code=None): - if convert is not None: - conf_code = self.strip_empty_lines(conf_code) - yaml_code = '' - ydata = lib.item_conversion.parse_for_convert(conf_code=conf_code) - if ydata != None: - yaml_code = lib.item_conversion.convert_yaml(ydata) - - conf_code = self.append_empty_lines(conf_code, 15) - yaml_code = self.append_empty_lines(yaml_code, 15) - else: - conf_code = self.append_empty_lines('', 15) - yaml_code = self.append_empty_lines('', 15) - return self.render_template('conf_yaml_converter.html', conf_code=conf_code, yaml_code=yaml_code) - - - # ----------------------------------------------------------------------------------- - - @cherrypy.expose - def yaml_syntax_checker_html(self, check=None, check2=None, yaml_code=None, check_result=None): - check_result = '' - output_format = 'yaml' - if check is not None: - yaml_code = self.strip_empty_lines(yaml_code) - - import lib.shyaml as shyaml - ydata, estr = shyaml.yaml_load_fromstring(yaml_code, True) - - if estr != '': - check_result = 'ERROR: \n\n'+ estr - if ydata != None: - check_result += lib.item_conversion.convert_yaml(ydata).replace('\n\n', '\n') - - yaml_code = self.append_empty_lines(yaml_code, 15) - check_result = self.append_empty_lines(check_result, 15) - elif check2 is not None: - yaml_code = self.strip_empty_lines(yaml_code) - - import lib.shyaml as shyaml - ydata, estr = shyaml.yaml_load_fromstring(yaml_code, False) - - if estr != '': - check_result = 'ERROR: \n\n'+ estr - if ydata != None: - import pprint - check_result += pprint.pformat(ydata) - - yaml_code = self.append_empty_lines(yaml_code, 15) - check_result = self.append_empty_lines(check_result, 15) - output_format = 'python' - else: - yaml_code = self.append_empty_lines('', 15) - check_result = self.append_empty_lines('', 15) - return self.render_template('yaml_syntax_checker.html', yaml_code=yaml_code, check_result=check_result, output_format=output_format) - - - # ----------------------------------------------------------------------------------- - - @cherrypy.expose - def eval_syntax_checker_html(self, check=None, eval_code=None, relative_to=''): - expanded_code = '' - if check is not None: - sh = self._sh - eval_code = eval_code.replace('\r', '').replace('\n', ' ').replace(' ', ' ').strip() - if relative_to == '': - expanded_code = eval_code - else: - rel_to_item = sh.return_item(relative_to) - if rel_to_item is not None: - expanded_code = rel_to_item.get_stringwithabsolutepathes(eval_code, 'sh.', '(') - else: - expanded_code = "Error: Item {} does not exist!".format(relative_to) - try: - value = eval(expanded_code) - except Exception as e: - check_result = "Problem evaluating {}:   {}".format(expanded_code, e) - else: - check_result = value - eval_code = self.append_empty_lines(eval_code, 5) - else: - eval_code = self.append_empty_lines('', 5) - check_result = '' - return self.render_template('eval_syntax_checker.html', eval_code=eval_code, expanded_code=expanded_code, relative_to=relative_to, check_result=check_result) - - - @cherrypy.expose - def create_hash_json_html(self, plaintext): - return json.dumps(create_hash(plaintext)) - diff --git a/backend/BackendSysteminfo.py b/backend/BackendSysteminfo.py deleted file mode 100755 index 80e10948f..000000000 --- a/backend/BackendSysteminfo.py +++ /dev/null @@ -1,811 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf8 -*- -######################################################################### -# Copyright 2016- René Frieß rene.friess@gmail.com -# Martin Sinn m.sinn@gmx.de -# Bernd Meiners -# Christian Strassburg c.strassburg@gmx.de -######################################################################### -# Backend plugin for SmartHomeNG -# -# This plugin is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This plugin is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this plugin. If not, see . -######################################################################### - -import cherrypy -import platform -#import collections -import datetime -import time -# identifying the user needs one of these: -import os -if os.name != 'nt': - import pwd # linux approach -else: - import getpass # windows approach -#import html -#import subprocess -import socket -import sys -#import threading -import os -import psutil - -import bin.shngversion as shngversion -import lib.config -from lib.shtime import Shtime -from lib.shpypi import Shpypi -#from lib.logic import Logics -#from lib.model.smartplugin import SmartPlugin -from lib.utils import Utils -from .utils import * - -#import lib.item_conversion - -class BackendSysteminfo: - - - def __init__(self): - - self.logger.info("BackendSysteminfo __init__ {}".format('')) - - self.shpypi = Shpypi.get_instance() - - - # ----------------------------------------------------------------------------------- - # SYSTEMINFO - # ----------------------------------------------------------------------------------- - - @cherrypy.expose - def system_html(self): -# now = datetime.datetime.now().strftime('%d.%m.%Y %H:%M') - now = self.plugin.shtime.now().strftime('%d.%m.%Y %H:%M') - system = platform.system() - vers = platform.version() - # node = platform.node() - node = socket.getfqdn() - arch = platform.machine() - if os.name != 'nt': - user = pwd.getpwuid(os.geteuid()).pw_name # os.getlogin() - else: - user = getpass.getuser() - - ip = Utils.get_local_ipv4_address() - ipv6 = Utils.get_local_ipv6_address() - - if os.name == 'posix': - space = os.statvfs(self._sh_dir) - freespace = space.f_frsize * space.f_bavail / 1024 / 1024 - else: - freespace = psutil.disk_usage(".").free - - # return host uptime - uptime = time.mktime(datetime.datetime.now().timetuple()) - psutil.boot_time() - days = uptime // (24 * 3600) - uptime = uptime % (24 * 3600) - hours = uptime // 3600 - uptime %= 3600 - minutes = uptime // 60 - uptime %= 60 - seconds = uptime - uptime = self.age_to_string(days, hours, minutes, seconds) - - # # return SmarthomeNG runtime - # rt = str(Shtime.get_instance().runtime()) - # daytest = rt.split(' ') - # if len(daytest) == 3: - # days = int(daytest[0]) - # hours, minutes, seconds = [float(val) for val in str(daytest[2]).split(':')] - # else: - # days = 0 - # hours, minutes, seconds = [float(val) for val in str(daytest[0]).split(':')] - # sh_uptime = self.age_to_string(days, hours, minutes, seconds) - - # return SmarthomeNG runtime - rt = Shtime.get_instance().runtime_as_dict() - sh_uptime = self.age_to_string(rt['days'], rt['hours'], rt['minutes'], rt['seconds']) - - - pyversion = "{0}.{1}.{2} {3}".format(sys.version_info[0], sys.version_info[1], sys.version_info[2], - sys.version_info[3]) - - #python_packages = self.getpackages() - #req_dict = self.get_requirements_info() - - return self.render_template('system.html', - now=now, system=system, sh_vers=shngversion.get_shng_version(), sh_desc=shngversion.get_shng_description(), plg_vers=shngversion.get_plugins_version(), plg_desc=shngversion.get_plugins_description(), sh_dir=self._sh_dir, - vers=vers, node=node, arch=arch, user=user, freespace=freespace, - uptime=uptime, sh_uptime=sh_uptime, pyversion=pyversion, - ip=ip, ipv6=ipv6) - - - @cherrypy.expose - def system_json(self): - """ - Return System inforation as json ( - for Angular tests only) - - :return: - """ -# now = datetime.datetime.now().strftime('%d.%m.%Y %H:%M') - now = self.plugin.shtime.now().strftime('%d.%m.%Y %H:%M') - system = platform.system() - vers = platform.version() - # node = platform.node() - node = socket.getfqdn() - arch = platform.machine() - user = pwd.getpwuid(os.geteuid()).pw_name # os.getlogin() - - ip = Utils.get_local_ipv4_address() - ipv6 = Utils.get_local_ipv6_address() - - space = os.statvfs(self._sh_dir) - freespace = space.f_frsize * space.f_bavail / 1024 / 1024 - - # return host uptime - uptime = time.mktime(datetime.datetime.now().timetuple()) - psutil.boot_time() - days = uptime // (24 * 3600) - uptime = uptime % (24 * 3600) - hours = uptime // 3600 - uptime %= 3600 - minutes = uptime // 60 - uptime %= 60 - seconds = uptime - uptime = self.age_to_string(days, hours, minutes, seconds) - - # return SmarthomeNG runtime - rt = str(Shtime.get_instance().runtime()) - daytest = rt.split(' ') - if len(daytest) == 3: - days = int(daytest[0]) - hours, minutes, seconds = [float(val) for val in str(daytest[2]).split(':')] - else: - days = 0 - hours, minutes, seconds = [float(val) for val in str(daytest[0]).split(':')] - sh_uptime = self.age_to_string(days, hours, minutes, seconds) - - pyversion = "{0}.{1}.{2} {3}".format(sys.version_info[0], sys.version_info[1], sys.version_info[2], - sys.version_info[3]) - - #python_packages = self.getpackages() - #req_dict = self.get_requirements_info() - - response = {} - response['now'] = now - response['system'] = system - response['sh_vers'] = shngversion.get_shng_version() - response['sh_desc'] = shngversion.get_shng_description() - response['plg_vers'] = shngversion.get_plugins_version() - response['plg_desc'] = shngversion.get_plugins_description() - response['sh_dir'] = self._sh_dir - response['vers'] = vers - response['node'] = node - response['arch'] = arch - response['user'] = user - response['freespace'] = freespace - response['uptime'] = uptime - response['sh_uptime'] = sh_uptime - response['pyversion'] = pyversion - response['ip'] = ip - response['ipv6'] = ipv6 - - return json.dumps(response) - - -# def get_process_info(self, command): -# """ -# returns output from executing a given command via the shell. -# """ -# ## get subprocess module -# import subprocess -# -# ## call date command ## -# p = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True) -# -# # Talk with date command i.e. read data from stdout and stderr. Store this info in tuple ## -# # Interact with process: Send data to stdin. Read data from stdout and stderr, until end-of-file is reached. -# # Wait for process to terminate. The optional input argument should be a string to be sent to the child process, or None, if no data should be sent to the child. -# (result, err) = p.communicate() -# -# ## Wait for date to terminate. Get return returncode ## -# p_status = p.wait() -# return str(result, encoding='utf-8', errors='strict') - - - # ----------------------------------------------------------------------------------- - # SYSTEMINFO: PyPI Check - # ----------------------------------------------------------------------------------- - - @cherrypy.expose - def pypi_json(self): - """ - returns a list of python package information dicts as json structure: - - The json response contains the following information: - - name str Name of package - vers_installed str Installed version of that package - is_required bool is package required by SmartHomeNG? - is_required_for_testsuite bool is package required for the testsuite? - is_required_for_docbuild bool is package required for building documentation with Sphinx? - vers_req_source str requirements as defined inrequirements.txt - vers_req_min str required minimum version - vers_req_max str required maximum version -- vers_req_msg str - vers_ok bool installed version meets requirements - vers_recent bool installed version is the req_max or the newest on PyPI - - pypi_version str newest package version on PyPI - pypi_version_ok bool is newest package version on PyPI ok for install on SmartHomeNG? - pypi_version_not_available_msg str error message or empty - pypi_doc_url str url of the package's documentation on PyPI - - sort str string for sorting (is_required + name) - - - :return: information about packahge requirements including PyPI information - :rtype: json structure - """ - self.logger.info("pypi_json") - - # check if pypi service is reachable - if self.pypi_timeout <= 0: - pypi_available = False - pypi_unavailable_message = translate('PyPI Prüfung deaktiviert') - else: - pypi_available = True - try: - import socket - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(self.pypi_timeout) -# sock.connect(('pypi.python.org', 443)) - sock.connect(('pypi.org', 443)) - sock.close() - except: - pypi_available = False - pypi_unavailable_message = translate('PyPI nicht erreichbar') - - import pip - import xmlrpc - import pkg_resources - installed_packages = pkg_resources.working_set - #installed_packages = pip.get_installed_distributions() - #pypi = xmlrpc.client.ServerProxy('https://pypi.python.org/pypi') - pypi = xmlrpc.client.ServerProxy('https://pypi.org/pypi') - - req_dict = self.get_requirements_info('base') - req_test_dict = self.get_requirements_info('test') - req_doc_dict = self.get_requirements_info('doc') - self.logger.info("pypi_json: req_doc_dict {}".format(req_doc_dict)) - - package_list = [] - - for dist in installed_packages: - package = dict() - package['name'] = dist.key - package['vers_installed'] = dist.version - package['is_required'] = False - package['is_required_for_testsuite'] = False - package['is_required_for_docbuild'] = False - - package['vers_req_min'] = '' - package['vers_req_max'] = '' - package['vers_req_msg'] = '' - package['vers_req_source'] = '' - - package['vers_ok'] = False - package['vers_recent'] = False - package['pypi_version'] = '' - package['pypi_version_ok'] = True - package['pypi_version_not_available_msg'] = '' - package['pypi_doc_url'] = '' - - if pypi_available: - try: - available = pypi.package_releases(dist.project_name) - self.logger.debug("pypi_json: pypi package: project_name {}, availabe = {}".format(dist.project_name, available)) - try: - package['pypi_version'] = available[0] - except: - package['pypi_version_not_available_msg'] = '?' - except: - package['pypi_version'] = '--' - package['pypi_version_not_available_msg'] = [translate('Keine Antwort von PyPI')] - else: - package['pypi_version_not_available_msg'] = pypi_unavailable_message -# package['pypi_doc_url'] = 'https://pypi.python.org/pypi/' + dist.project_name - package['pypi_doc_url'] = 'https://pypi.org/pypi/' + dist.project_name - - if package['name'].startswith('url'): - self.logger.info("pypi_json: urllib: package['name'] = >{}<, req_dict.get(package['name'] = >{}<".format(package['name'], req_dict.get(package['name']))) - - # test if package belongs to to SmartHomeNG requirements - if req_dict.get(package['name'], '') != '': - package['is_required'] = True - # tests for min, max versions - rmin, rmax, rtxt = self.check_requirement(package['name'], req_dict.get(package['name'], '')) - package['vers_req_source'] = req_dict.get(package['name'], '') - package['vers_req_min'] = rmin - package['vers_req_max'] = rmax - package['vers_req_msg'] = rtxt - - if req_doc_dict.get(package['name'], '') != '': - package['is_required_for_docbuild'] = True - # tests for min, max versions - rmin, rmax, rtxt = self.check_requirement(package['name'], req_doc_dict.get(package['name'], '')) - package['vers_req_source'] = req_doc_dict.get(package['name'], '') - package['vers_req_min'] = rmin - package['vers_req_max'] = rmax - package['vers_req_msg'] = rtxt - - if req_test_dict.get(package['name'], '') != '': - package['is_required_for_testsuite'] = True - # tests for min, max versions - rmin, rmax, rtxt = self.check_requirement(package['name'], req_test_dict.get(package['name'], '')) - package['vers_req_source'] = req_test_dict.get(package['name'], '') - package['vers_req_min'] = rmin - package['vers_req_max'] = rmax - package['vers_req_msg'] = rtxt - - if package['is_required']: - package['sort'] = '1' - elif package['is_required_for_testsuite']: - package['sort'] = '2' - elif package['is_required_for_docbuild']: - package['sort'] = '3' - else: - package['sort'] = '4' - self.logger.debug("pypi_json: sort=4, package['name'] = >{}<".format(package['name'])) - - package['sort'] += package['name'] - - # check if installed verison is recent (compared to PyPI) - if package['is_required']: - self.logger.info("compare PyPI package {}:".format(package['name'])) - if self.compare_versions(package['vers_installed'], package['pypi_version'], '>='): - package['vers_recent'] = True - else: - self.logger.info("compare PyPI package {} (for non required):".format(package['name'])) - if package['pypi_version'] != '': - if self.compare_versions(package['vers_installed'], package['pypi_version'], '>='): - package['vers_recent'] = True - - # check if installed verison is ok - if package['is_required'] or package['is_required_for_testsuite'] or package['is_required_for_docbuild']: - package['vers_ok'] = True - if self.compare_versions(package['vers_req_min'], package['vers_installed'], '>'): - package['vers_ok'] = False - max = package['vers_req_max'] - if max == '': - max = '99999' - if self.compare_versions(max, package['vers_installed'], '<'): - package['vers_ok'] = False - package['vers_recent'] = False - if self.compare_versions(max, package['vers_installed'], '=='): - package['vers_recent'] = True - if package['pypi_version'] != '': - if self.compare_versions(package['pypi_version'], package['vers_installed'], '<') or self.compare_versions(package['pypi_version'], max, '>'): - package['pypi_version_ok'] = False - - package_list.append(package) - - # self.logger.warning('installed_packages: {}'.format(installed_packages)) - self.logger.warning('req_dict: {}'.format(req_dict)) - inst_pkgname_list = [] - for pkg in package_list: - inst_pkgname_list.append(pkg['name']) - self.logger.warning('pkgname_list: {}'.format(inst_pkgname_list)) - for req in req_dict: - if not (req in inst_pkgname_list): - pkg = {} - pkg['name'] = req - pkg['vers_installed'] = '-' - pkg['is_required'] = True - pkg['is_required_for_testsuite'] = True - pkg['is_required_for_docbuild'] = True - # tests for min, max versions - rmin, rmax, rtxt = self.check_requirement(pkg['name'], req_dict.get(pkg['name'], '')) - pkg['vers_req_min'] = rmin - pkg['vers_req_max'] = rmax - pkg['vers_req_msg'] = rtxt - pkg['sort'] = '1' + pkg['name'] - package_list.append(pkg) -### - if pypi_available: - try: - available = pypi.package_releases(pkg['name']) #(dist.project_name) - self.logger.debug("pypi_json: pypi package: project_name {}, availabe = {}".format(pkg['name'], available)) - try: - pkg['pypi_version'] = available[0] - except: - pkg['pypi_version_not_available_msg'] = '?' - except: - pkg['pypi_version'] = '--' - pkg['pypi_version_not_available_msg'] = [translate('Keine Antwort von PyPI')] - else: - pkg['pypi_version_not_available_msg'] = pypi_unavailable_message -### - - self.logger.warning('package_list: {}'.format(package_list)) - - -# sorted_package_list = sorted([(i['name'], i['version_installed'], i['version_available']) for i in package_list]) - sorted_package_list = sorted(package_list, key=lambda k: k['sort'], reverse=False) - self.logger.info("pypi_json: sorted_package_list = {}".format(sorted_package_list)) - self.logger.info("pypi_json: json.dumps(sorted_package_list) = {}".format(json.dumps(sorted_package_list))) - - return json.dumps(sorted_package_list) - - - def get_requirements_info(self, req_group='base'): - """ - """ - req_dict = {} - if req_group == 'base': - req_dict_base = parse_requirements(os.path.join(self._sh_dir, 'requirements', 'base.txt')) -# req_dict_base = self.shpypi.parse_requirementsfile(os.path.join(self._sh_dir, 'requirements', 'base.txt')) - dummy = self.shpypi.parse_requirementsfile(os.path.join(self._sh_dir, 'requirements', 'conf-all.txt')) - dummy = self.shpypi.test_base_requirements() - dummy = self.shpypi.test_requirements(os.path.join(self._sh_dir, 'requirements', 'conf-all.txt')) - dummy = self.shpypi.get_packagelist() - self.logger.warning("get_requirements_info: get_packagelist = {}".format(dummy)) - - - elif req_group == 'test': - req_dict_base = parse_requirements(os.path.join(self._sh_dir, 'tests', 'requirements.txt')) - self.logger.info("get_requirements_info: filepath = {}".format(os.path.join(self._sh_dir, 'tests', 'requirements.txt'))) - pass - elif req_group == 'doc': - req_dict_base = parse_requirements(os.path.join(self._sh_dir, 'doc', 'requirements.txt')) - self.logger.info("get_requirements_info: filepath = {}".format(os.path.join(self._sh_dir, 'doc', 'requirements.txt'))) - pass - else: - self.logger.error("get_requirements_info: Unknown requirements group '{}' requested".format(req_group)) - - if req_group == 'base': - # parse loaded plugins and look for requirements - _conf = lib.config.parse(self._sh._plugin_conf) - plugin_names = [] - for plugin in _conf: - plugin_name = _conf[plugin].get('class_path', '').strip() - if plugin_name == '': - plugin_name = 'plugins.' + _conf[plugin].get('plugin_name', '').strip() - if not plugin_name in plugin_names: # only unique plugin names, e.g. if multiinstance is used - plugin_names.append(plugin_name) - self.logger.info("get_requirements_info: len(_conf) = {}, len(plugin_names) = {}, plugin_names = {}".format(len(_conf), len(plugin_names), plugin_names)) - - req_dict = req_dict_base.copy() - for plugin_name in plugin_names: - file_path = "%s/%s/requirements.txt" % (self._sh_dir, plugin_name.replace("plugins.", "plugins/")) - if os.path.isfile(file_path): - plugin_dict = parse_requirements(file_path) - for key in plugin_dict: - if key not in req_dict: - req_dict[key] = plugin_dict[key] + ' (' + plugin_name.replace('plugins.', '') + ')' - else: - req_dict[key] = req_dict[key] + '
' + plugin_dict[key] + ' (' + plugin_name.replace( - 'plugins.', '') + ')' - - if req_group in ['doc','test']: - try: - req_dict = req_dict_base.copy() - except: - pass - - self.logger.info("get_requirements_info: req_dict for group {} = {}".format(req_group, req_dict)) - return req_dict - - - def compare_versions(self, vers1, vers2, operator): - """ - Compare two version numbers and return if the condition is met - """ - v1s = vers1.split('.') - while len(v1s) < 4: - v1s.append('0') - v1 = [] - for v in v1s: - vi = 0 - try: - vi = int(v) - except: pass - v1.append(vi) - - v2s = vers2.split('.') - while len(v2s) < 4: - v2s.append('0') - v2 = [] - for v in v2s: - vi = 0 - try: - vi = int(v) - except: pass - v2.append(vi) - - result = False - if v1 == v2 and operator in ['>=','==','<=']: - result = True - if v1 < v2 and operator in ['<','<=']: - result = True - if v1 > v2 and operator in ['>','>=']: - result = True - - self.logger.debug("compare_versions: - - - v1 = {}, v2 = {}, operator = '{}', result = {}".format(v1, v2, operator, result)) - return result - - - def strip_operator(self, string, operator): - """ - Strip a leading operator from a string and remove quotes, if they exist - - :param string: string to remove the operator from - :param operator: operator to remove - :type string: str - :type operator: str - - :return: string without the operator - :rtype: str - """ - if string.startswith(operator): - return Utils.strip_quotes(string[len(operator):].strip()) - else: - return Utils.strip_quotes(string.strip()) - - - def split_operator(self, reqstring): - """ - split operator and version from string - - :param reqstring: string containing operator and version - :type reqstring: str - - :return: operator, version - :rtype: str, str - """ - if reqstring.startswith('=='): - operator = '==' - version = self.strip_operator(reqstring, operator) - elif reqstring.startswith('<='): - operator = '<=' - version = self.strip_operator(reqstring, operator) - elif reqstring.startswith('>='): - operator = '>=' - version = self.strip_operator(reqstring, operator) - elif reqstring.startswith('<'): - operator = '<' - version = self.strip_operator(reqstring, operator) - elif reqstring.startswith('>'): - operator = '>=' - version = self.strip_operator(reqstring, operator) - else: - operator = '' - version = reqstring - - return operator.strip(), version.strip() - - - def req_is_pyversion_req_relevant(self, pyreq, package=''): - """ - Test if requirement has a Python version restriction and if so, test if the restriction - is relevant. - """ - pyversion = "{0}.{1}".format(sys.version_info[0], sys.version_info[1]) - - pyreq = pyreq.strip().replace('python_version', '') - pyv_operator = '' - if pyreq != '': - self.logger.debug("req_is_pyversion_req_relevant: - - package {}, py_version {}".format(package, pyreq)) - if pyreq.startswith('=='): - pyv_operator = '==' - pyreq = self.strip_operator(pyreq, pyv_operator) - result = self.compare_versions(pyversion, pyreq, pyv_operator) - elif pyreq.startswith('<='): - pyv_operator = '<=' - pyreq = self.strip_operator(pyreq, pyv_operator) - result = self.compare_versions(pyversion, pyreq, pyv_operator) - elif pyreq.startswith('>='): - pyv_operator = '>=' - pyreq = self.strip_operator(pyreq, pyv_operator) - result = self.compare_versions(pyversion, pyreq, pyv_operator) - elif pyreq.startswith('<'): - pyv_operator = '<' - pyreq = self.strip_operator(pyreq, pyv_operator) - result = self.compare_versions(pyversion, pyreq, pyv_operator) - elif pyreq.startswith('>'): - pyv_operator = '>' - result = pyreq = self.strip_operator(pyreq, pyv_operator) - self.compare_versions(pyversion, pyreq, pyv_operator) - else: - pyv_operator = '' - self.logger.error("req_is_pyversion_req_relevant: no operator in front of Python version found - package {}, pyreq = {}".format(package, pyreq)) - result = False - - self.logger.debug("req_is_pyversion_req_relevant: - - - package {}, py_version_operator {}, py_version {}".format(package, pyv_operator, pyreq)) - return result - - -# operator: <, <=, ==, >=, >> -# source: or 'core' -# version_relation: -# pyversion_relation: -# version_relations: , -# py_vers_requirement: ; -# py_vers_requirements: | -# requirement_string: () - - def req_split_source(self, req, package=''): - """ - Splits the requirement source from the requirement string - """ - self.logger.debug("req_split_source: package {}, req = '{}'".format(package, req)) - req = req.lower().strip() - - # seperate requirement from source - source = 'core' - req1 = req - if '(' in req: - wrk = req.split('(') - source = wrk[1][0:wrk[1].find(")")].strip() - req1 = wrk[0].strip() - self.logger.debug("req_split_source: - source {}, req1 = '{}'".format(source, req1)) - - # seperate requirements for different Python versions - req2 = req1.split('|') - reql = [] - for r in req2: - reql.append(r.strip()) - self.logger.debug("req_split_source: - source {}, reql = {}".format(source, reql)) - - req_result = [] - for req in reql: - # isolate and handle Python version - wrk = req.split(';') - sreq = wrk[0].strip() - if len(wrk) > 1: - valid = self.req_is_pyversion_req_relevant(wrk[1], package) - else: - valid = True - -# self.logger.info("req_split_source: - - - source {}, py_version_operator {}, py_version {}, sreq = {}".format(source, pyv_operator, pyreq, sreq)) - - if valid: - # check and handle version requirements - wrkl = sreq.split(',') - if len(wrkl) > 2: - self.logger.error("req_split_source: More that two requirements for package {} req = {}".format(package, reql)) - rmin = '' - rmax = '' - for r in wrkl: - if r.find('<') != -1 or r.find('<=') != -1: - rmax = r - if r.find('>') != -1 or r.find('>=') != -1: - rmin = r - if r.find('==') != -1: - rmin = r - rmax = r - req_result.append([source, rmin, rmax]) - - self.logger.debug("req_split_source: - package {} req_result = {}".format(package, req_result)) - if len(req_result) > 1: - self.logger.warning("req_split_source: Cannot reconcile multiple version requirements for package {} for running Python version".format(package)) - else: - req_result = req_result[0] - return req_result - - - def check_requirement(self, package, req_str): - """ - """ - pyversion = "{0}.{1}".format(sys.version_info[0], sys.version_info[1]) - req_min = '' - req_max = '' - # split requirements - req_templist = req_str.split('
') # split up requirements from different plugins and the core - - req_result = [] - for req in req_templist: - req_result.append( self.req_split_source(req, package) ) - self.logger.debug("check_requirement: package {}, len(req_result)={}, req_result = '{}'".format(package, len(req_result), req_result)) - - # Check if requirements from all sources are the same - if len(req_result) > 1: - are_equal = True - for req in req_result: - if req[1] != req_result[0][1]: - are_equal = False - if req[2] != req_result[0][2]: - are_equal = False - if are_equal: - req_result = [req_result[0]] - - req_txt = req_result - # Now we have a list of [ requirement_source, min_version (with operator), max_version (with operator) ] - if len(req_result) == 1: - result = req_result[0] - self.logger.debug("check_requirement: package {}, req_result = >{}<, result = >{}<".format(package, req_result, result)) - #handle min - op, req_min = self.split_operator(result[1]) - if req_min == '*': - req_min = '' - req_txt = '' - else: - if op == '>': - req_min += '.0' - - #handle max - op, req_max = self.split_operator(result[2]) - if req_max == '*': - req_max = '' - req_txt = '' -# else: -# if op == '<': -# req_max = ? - - - self.logger.debug("check_requirement: package {} ({}), req_result = '{}'".format(package, len(req_result), req_result)) - if req_min != '' or req_max != '': - req_txt = '' - - return req_min, req_max, req_txt - - - def getpackages(self): - """ - returns a list with the installed python packages and its versions - """ - - # check if pypi service is reachable - if self.pypi_timeout <= 0: - pypi_available = False - pypi_unavailable_message = translate('PyPI Prüfung deaktiviert') - else: - pypi_available = True - try: - import socket - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(self.pypi_timeout) -# sock.connect(('pypi.python.org', 443)) - sock.connect(('pypi.org', 443)) - sock.close() - except: - pypi_available = False - pypi_unavailable_message = translate('PyPI nicht erreichbar') - - import pip - import xmlrpc - installed_packages = pip.get_installed_distributions() -# pypi = xmlrpc.client.ServerProxy('https://pypi.python.org/pypi') - pypi = xmlrpc.client.ServerProxy('https://pypi.org/pypi') - packages = [] - for dist in installed_packages: - package = {} - package['key'] = dist.key - package['version_installed'] = dist.version - if pypi_available: - try: - available = pypi.package_releases(dist.project_name) - try: - package['version_available'] = available[0] - except: - package['version_available'] = '-' - except: - package['version_available'] = [translate('Keine Antwort von PyPI')] - else: - package['version_available'] = pypi_unavailable_message - packages.append(package) - - - sorted_packages = sorted([(i['key'], i['version_installed'], i['version_available']) for i in packages]) - return sorted_packages - - diff --git a/backend/BackendThreads.py b/backend/BackendThreads.py deleted file mode 100755 index 0a3e25416..000000000 --- a/backend/BackendThreads.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf8 -*- -######################################################################### -# Copyright 2016- René Frieß rene.friess@gmail.com -# Martin Sinn m.sinn@gmx.de -# Bernd Meiners -# Christian Strassburg c.strassburg@gmx.de -######################################################################### -# Backend plugin for SmartHomeNG -# -# This plugin is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This plugin is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this plugin. If not, see . -######################################################################### - -import cherrypy - -import threading - -class BackendThreads: - - - def __init__(self): - - self.logger.info("BackendThreads __init__ {}".format('')) - - - # ----------------------------------------------------------------------------------- - # THREADS - # ----------------------------------------------------------------------------------- - - def thread_sum(self, name, count): - thread = dict() - if count > 0: - thread['name'] = name - thread['sort'] = str(thread['name']).lower() - thread['id'] = "(" + str(count) + " threads" + ")" - thread['alive'] = 'True' - return thread - - @cherrypy.expose - def threads_html(self): - """ - display a list of all threads - """ - threads_count = 0 - cp_threads = 0 - http_threads = 0 - idle_threads = 0 - for thread in threading.enumerate(): - if thread.name.find("CP Server") == 0: - cp_threads += 1 - if thread.name.find("HTTPServer") == 0: - http_threads +=1 - if thread.name.find("idle") == 0: - idle_threads +=1 - - threads = [] - for t in threading.enumerate(): - if t.name.find("CP Server") != 0 and t.name.find("HTTPServer") != 0 and t.name.find("idle") != 0: - thread = dict() - thread['name'] = t.name - thread['sort'] = str(t.name).lower() - thread['id'] = t.ident - try: - if t.is_alive(): - thread['alive'] = 'True' - else: - thread['alive'] = 'False' - except AssertionError: - thread['alive'] = 'AssertionError' - - threads.append(thread) - threads_count += 1 - - if cp_threads > 0: - threads.append(self.thread_sum("CP Server", cp_threads)) - threads_count += cp_threads - if http_threads > 0: - threads.append(self.thread_sum("HTTPServer", http_threads)) - threads_count += http_threads - if idle_threads > 0: - threads.append(self.thread_sum("idle", idle_threads)) - threads_count += idle_threads - - threads_sorted = sorted(threads, key=lambda k: k['sort']) - return self.render_template('threads.html', threads=threads_sorted, threads_count=threads_count) - - diff --git a/backend/README.md b/backend/README.md deleted file mode 100755 index 725e09bcc..000000000 --- a/backend/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# Backend GUI - -This plugin delivers information about the current SmartHomeNG installation. Right now it serves as a support tool for helping other users with an installation that does not run properly. Some highlights: - -* a list of installed python modules is shown versus the available versions from PyPI -* a list of items and their attributes is shown -* a list of logics and their next execution time -* a list of current schedulers and their next execution time -* direct download of sqlite database (if plugin is used) and smarthome.log -* some information about frequently used daemons like knxd/eibd is included -* supports basic authentication -* multi-language support - -There is however only basic protection against unauthorized access or use of the plugin so be careful when enabling it with your network. - -Call the backend-webserver: **```http://:8383```** - -Support is provided trough the support thread within the smarthomeNG forum: - -[knx-user-forum.de/forum/supportforen/smarthome-py/959964-support-thread-f%C3%BCr-das-backend-plugin](https://knx-user-forum.de/forum/supportforen/smarthome-py/959964-support-thread-für-das-backend-plugin) - -## Requirements - -This version of the plugin needs **SmartHomeNG v1.4 or newer**. - -This plugin is running under **Python >= 3.4** as well as the libs cherrypy and jinja2. You can install them with: -``` -(sudo apt-get install python-cherrypy) -sudo pip3 install cherrypy -(sudo apt-get install python-jinja2) -sudo pip3 install jinja2 -``` - -And please pay attention that the libs are installed for Python3 and not an older Python 2.7 that is probably installed on your system. - -The log level filter in the log file view will only work with "%(asctime)s %(levelname)-8s" in the beginning of the configured format! Dateformat needs to be datefmt: '%Y-%m-%d %H:%M:%S' - -> Note: This plugin needs the SmartHomeNG loadable module `http` to be installed/configured. - -To support visualization, the visu_websocket plugin has to be used. It has to be PLUGIN_VERSION >= "1.1.2". - - -## Configuration - -### plugin.yaml - -```yaml -# /etc/plugin.yaml -BackendServer: - plugin_name: backend - #updates_allowed: 'True' - #developer_mode: 'on' - #pypi_timeout: 5 -``` - - -#### updates_allowed - -By default, the backend server allows updates to the running smarthomeNG instance. For instance, it is possible to trigger or to reload a logic. Setting **`updates_allowed`** to **`False`**, you can disable these features. - -#### developer_mode (optional) - -You may specify develper_mode = on, if you are developiing within the backend plugin. At the moment, the only thing that changes is an additional button **``reload translation``** on the services page - -#### pypi_timeout (optional) - -Timeout for PyPI accessibility check (seconds). PyPI is queried on page "Systeminfo" to compare installed python module versions with current versions if accessible. If you receive the message "PyPI inaccessible" on systems with internet access you may increase the value. On systems where PyPI can not be reached (no/restricted internet access) you may set the timeout to 0 which disables the PyPI queries. diff --git a/backend/__init__.py b/backend/__init__.py deleted file mode 100755 index 1a7f8c180..000000000 --- a/backend/__init__.py +++ /dev/null @@ -1,296 +0,0 @@ -#!/usr/bin/env python3 -# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab -######################################################################### -# Copyright 2016- René Frieß rene.friess@gmail.com -# Martin Sinn m.sinn@gmx.de -# Bernd Meiners -# Christian Strassburg c.strassburg@gmx.de -######################################################################### -# Backend plugin for SmartHomeNG -# -# It runs with SmartHomeNG version 1.4 and upwards. -# -# This plugin is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# SmartHomeNG is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with SmartHomeNG. If not, see . -# -######################################################################### - -import logging - -from lib.module import Modules -from lib.model.smartplugin import SmartPlugin - -from .utils import * - -from .BackendSysteminfo import BackendSysteminfo -from .BackendServices import BackendServices -from .BackendItems import BackendItems -from .BackendLogics import BackendLogics -from .BackendSchedulers import BackendSchedulers -from .BackendPlugins import BackendPlugins -from .BackendScenes import BackendScenes -from .BackendThreads import BackendThreads -from .BackendLogging import BackendLogging - - -class BackendServer(SmartPlugin): - """ - Main class of the Plugin. Does all plugin specific stuff and provides - the update functions for the items - """ - - PLUGIN_VERSION = '1.5.15' - - - def __init__(self, sh, updates_allowed='True', developer_mode="no", pypi_timeout=5): - """ - Initalizes the plugin. The parameters describe for this method are pulled from the entry in plugin.conf. - - :param sh: **Deprecated**: The instance of the smarthome object. For SmartHomeNG versions **beyond** 1.3: **Don't use it**! - :param *args: **Deprecated**: Old way of passing parameter values. For SmartHomeNG versions **beyond** 1.3: **Don't use it**! - :param **kwargs:**Deprecated**: Old way of passing parameter values. For SmartHomeNG versions **beyond** 1.3: **Don't use it**! - - If you need the sh object at all, use the method self.get_sh() to get it. There should be almost no need for - a reference to the sh object any more. - - The parameters *args and **kwargs are the old way of passing parameters. They are deprecated. They are imlemented - to support oder plugins. Plugins for SmartHomeNG v1.4 and beyond should use the new way of getting parameter values: - use the SmartPlugin method get_parameter_value(parameter_name) instead. Anywhere within the Plugin you can get - the configured (and checked) value for a parameter by calling self.get_parameter_value(parameter_name). It - returns the value in the datatype that is defined in the metadata. - """ - self.logger = logging.getLogger(__name__) - - self.updates_allowed = self.get_parameter_value('updates_allowed') - self.developer_mode = self.get_parameter_value('developer_mode') - self.pypi_timeout = self.get_parameter_value('pypi_timeout') - - self.language = self.get_sh().get_defaultlanguage() - if self.language != '': - if not load_translation(self.language): - self.logger.warning("Language '{}' not found, using standard language instead".format(self.language)) - - if not self.init_webinterface(): - self._init_complete = False - - return - - - def run(self): - """ - Run method for the plugin - """ - self.logger.debug("Plugin '{}': run method called".format(self.get_fullname())) - self.alive = True - # if you want to create child threads, do not make them daemon = True! - # They will not shutdown properly. (It's a python bug) - - - def stop(self): - """ - Stop method for the plugin - """ - self.logger.debug("Plugin '{}': stop method called".format(self.get_fullname())) - self.alive = False - - - def parse_item(self, item): - """ - Default plugin parse_item method. Is called when the plugin is initialized. - The plugin can, corresponding to its attribute keywords, decide what to do with - the item in future, like adding it to an internal array for future reference - :param item: The item to process. - :return: If the plugin needs to be informed of an items change you should return a call back function - like the function update_item down below. An example when this is needed is the knx plugin - where parse_item returns the update_item function when the attribute knx_send is found. - This means that when the items value is about to be updated, the call back function is called - with the item, caller, source and dest as arguments and in case of the knx plugin the value - can be sent to the knx with a knx write function within the knx plugin. - """ - pass - - - def parse_logic(self, logic): - """ - Default plugin parse_logic method - """ - pass - - - def update_item(self, item, caller=None, source=None, dest=None): - """ - Write items values - :param item: item to be updated towards the plugin - :param caller: if given it represents the callers name - :param source: if given it represents the source - :param dest: if given it represents the dest - """ - pass - - - def init_webinterface(self): - """" - Initialize the web interface for this plugin - - This method is only needed if the plugin is implementing a web interface - """ - try: -# self.mod_http = self.get_module('http') # try/except to handle running in a core version that does not support modules - self.mod_http = Modules.get_instance().get_module('http') # try/except to handle running in a core version that does not support modules - except: - self.mod_http = None - - if self.mod_http == None: - self.logger.error("Plugin '{}': Not initializing the web interface".format(self.get_fullname())) - return False - - # set application configuration for cherrypy - webif_dir = self.path_join(self.get_plugin_dir(), 'webif') - config = { - '/': { - 'tools.staticdir.root': webif_dir, - }, - '/static': { - 'tools.staticdir.on': True, - 'tools.staticdir.dir': 'static' - } - } - - # Register the web interface as a cherrypy app - self.mod_http.register_webif(WebInterface(webif_dir, self), - self.get_shortname(), - config, - self.get_classname(), self.get_instance_name(), - description='Administrationsoberfläche für SmartHomeNG', - webifname='') - - return True - - -# ------------------------------------------ -# Webinterface of the plugin -# ------------------------------------------ - -import os - -import cherrypy -from jinja2 import Environment, FileSystemLoader - -from lib.logic import Logics -import lib.item_conversion - -class WebInterface(BackendSysteminfo, BackendServices, BackendItems, BackendLogics, - BackendSchedulers, BackendPlugins, BackendScenes, BackendThreads, - BackendLogging): - - blockly_plugin_loaded = None # None = load state is unknown - - def __init__(self, webif_dir, plugin): - """ - Initialization of instance of class WebInterface - - :param webif_dir: directory where the webinterface of the plugin resides - :param plugin: instance of the plugin - :type webif_dir: str - :type plugin: object - """ - self.logger = logging.getLogger(__name__) - - self.webif_dir = webif_dir - self.plugin = plugin - self.logger.info("{}: Running from '{}'".format(self.__class__.__name__, self.webif_dir)) - backendtemplates = self.plugin.path_join(self.webif_dir, 'templates') - globaltemplates = self.plugin.mod_http.gtemplates_dir - self.tplenv = Environment(loader=FileSystemLoader([globaltemplates, backendtemplates])) - - from os.path import basename as get_basename - self.tplenv.globals['get_basename'] = get_basename - self.tplenv.globals['is_userlogic'] = Logics.is_userlogic - self.tplenv.globals['_'] = translate - - self.env = self.tplenv # because the new naming isn't globally implemented - - self.logger = logging.getLogger(__name__) - self._bs = plugin - self._sh = plugin.get_sh() - self.language = plugin.language - self.updates_allowed = plugin.updates_allowed - self.developer_mode = plugin.developer_mode - self.pypi_timeout = plugin.pypi_timeout - - self._sh_dir = self._sh.base_dir - - BackendSysteminfo.__init__(self) - BackendServices.__init__(self) - BackendItems.__init__(self) - BackendLogics.__init__(self) - BackendSchedulers.__init__(self) - BackendPlugins.__init__(self) - BackendScenes.__init__(self) - BackendThreads.__init__(self) - BackendLogging.__init__(self) - - - def html_escape(self, str): - """ - escape characters in html - """ - return html_escape(str) - - - def render_template(self, tmpl_name, **kwargs): - """ - - Render a template and add vars needed gobally (for navigation, etc.) - - :param tmpl_name: Name of the template file to be rendered - :param **kwargs: keyworded arguments to use while rendering - - :return: contents of the template after beeing rendered - - """ - tmpl = self.tplenv.get_template(tmpl_name) - return tmpl.render(develop=self.developer_mode, - smarthome=self._sh, - yaml_converter=lib.item_conversion.is_ruamelyaml_installed(), - **kwargs) - - - # ----------------------------------------------------------------------------------- - # MAIN - # ----------------------------------------------------------------------------------- - - @cherrypy.expose - def index(self): - """ - display index page - """ - return self.render_template('index.html') - - @cherrypy.expose - def main_html(self): - - return self.render_template('main.html') - - - # ----------------------------------------------------------------------------------- - # DISCLOSURE - # ----------------------------------------------------------------------------------- - - @cherrypy.expose - def disclosure_html(self): - """ - display disclosure - """ - return self.render_template('disclosure.html') - diff --git a/backend/assets/backend_systeminfo.jpg b/backend/assets/backend_systeminfo.jpg deleted file mode 100755 index fd622a6df..000000000 Binary files a/backend/assets/backend_systeminfo.jpg and /dev/null differ diff --git a/backend/locale/de.json b/backend/locale/de.json deleted file mode 100755 index 448825500..000000000 --- a/backend/locale/de.json +++ /dev/null @@ -1,302 +0,0 @@ -{ - "alle": "alle", - "sonstige": "sonstige", - "Scheduler (plural)": "Scheduler", - "Scheduler": "Scheduler", - "Item Scheduler": "Item Scheduler", - "Logik Scheduler": "Logik Scheduler", - "Plugin Scheduler": "Plugin Scheduler", - "sonstige Scheduler": "sonstige Scheduler", - "Logik": "Logik", - "Systemlogiken": "Systemlogiken", - "Nutzerlogiken": "Nutzerlogiken", - "Neue Logiken (nicht geladen)": "Neue Logiken (nicht geladen)", - "letzte Ausführung": "letzte Ausführung", - "nächste Ausführung": "nächste Ausführung", - "Cycle": "Cycle", - "Crontab": "Crontab", - "Crontab(s)": "Crontab(s)", - "Dateiname": "Dateiname", - "Aktionen": "Aktionen", - "Items": "Items", - "Item": "Item", - "gesamt": "gesamt", - "Suchen": "Suchen", - "Suche zurücksetzen": "Zurücksetzen", - "Alle aufklappen": "Alle aufklappen", - "Alle zuklappen": "Alle zuklappen", - "Itempfad suchen...": "Itempfad suchen...", - "Item im Baum auswählen um Details einzusehen!": "Item im Baum auswählen um Details einzusehen!", - "Item-Informationen": "Item-Informationen", - "Attribut": "Attribut", - "Wert": "Wert", - "Pfad": "Pfad", - "Name": "Name", - "Typ": "Typ", - - "Änderungsinformationen": "Änderungsinformationen", - "Letztes Update": "Letztes Update", - "Letzte Änderung": "Letzte Änderung", - "Geändert durch": "Geändert durch", - "age": "Alter (Änderung)", - "update_age": "Alter (Update)", - "vorheriger Wert": "vorheriger Wert", - "previous update": "vorheriges Update", - "previous change": "vorherige Änderung", - "previous update age": "vorh. Alter (Update)", - "previous age": "vorh. Alter (Änderung)", - "Update durch": "Update durch", - - "Evaluation und Trigger": "Initialisierungs-, Trigger- und Evaluations-Konfiguration", - "Plugin spezifische Attribute": "Plugin spezifische Konfigurationen", - "Plugin Metadaten": "Plugin Metadaten", - "Verbundene Logiken": "Verbundene Logiken", - "Verbundene Trigger": "Verbundene Trigger", - "vom Plugin definierte Methoden": "vom Plugin definierte Methoden", - "Willkommen im Backend von": "Willkommen im Backend von", - "Eigenschaft": "Eigenschaft", - "IP": "IP Adresse", - "Betriebssystem": "Betriebssystem", - "Architektur": "Architektur", - "Benutzer": "Benutzer", - "Host": "Host", - "Freier Speicher": "Freier Speicher", - "Datum": "Datum", - "Zeit": "Zeit", - "Tag": "Tag", - "Tage": "Tage", - "Stunde": "Stunde", - "Stunden": "Stunden", - "Minute": "Minute", - "Minuten": "Minuten", - "Sekunden": "Sekunden", - "Betriebszeit": "Betriebszeit", - "Python Version": "Python Version", - "in": "in", - "installierte Version": "installierte Version", - "Neuste Version": "Neuste Version", - "Neuste Version!": "Neuste Version, bitte vor der Installation prüfen ob sie zu Problemen führt!", - "Keine Antwort von PyPI": "Keine Antwort von PyPI", - "PyPI nicht erreichbar": "PyPI nicht erreichbar", - "PyPI Prüfung deaktiviert": "PyPI Prüfung deaktiviert", - "Dienst": "Dienst", - "Status": "Status", - "Aktion": "Aktion", - "Root-Passwort": "Root-Passwort", - "Neu starten": "System neu starten", - "Dienst für die KNX Unterstützung": "Dienst für die KNX Unterstützung", - "Nicht aktiv": "Nicht aktiv", - "Sprache des Backends": "Sprache des Backends", - "Logfile speichern": "Logfile speichern", - "Logfile ansehen": "Logfile ansehen", - "Logger ansehen": "Logger ansehen", - "Datenbank-Dump": "Datenbank-Dump", - "Übersetzung neu laden": "Übersetzung neu laden", - "Auf Deutsch wechseln": "Auf Deutsch wechseln", - "Auf Englisch wechseln": "Auf Englisch wechseln", - "Auf Französisch wechseln": "Auf Französisch wechseln", - "Auf Polnisch wechseln": "Auf Polnisch wechseln", - "YAML Syntax Checker": "YAML Syntax Checker", - "Eval Syntax Checker": "Eval Syntax Checker", - "Ausdruck (Eingabe im Python Eval-Format)": "Ausdruck (Eingabe im Python Eval-Format)", - "Relativ zu (Eingabe des Item-Path)": "Relativ zu (Eingabe des Item-Path)", - "Expandierter Ausdruck": "Expandierter Ausdruck", - "Ergebnis": "Ergebnis", - "Hier den Ausdruck eingeben, der ausgewertet werden soll. Items können mit absolutem Pfad oder relativem Pfad angegeben werden.": "Hier den Ausdruck eingeben, der ausgewertet werden soll. Items können mit absolutem Pfad oder relativem Pfad angegeben werden.", - "Um relative Itempfade aufzulösen, hier das Item eingeben, zu dem die Pfade relativ sind.": "Um relative Itempfade aufzulösen, hier das Item eingeben, zu dem die Pfade relativ sind.", - "Hier wird der Ausdruck angezeigt, nachdem die relativen Itempfade aufgelöst wurden.": "Hier wird der Ausdruck angezeigt, nachdem die relativen Itempfade aufgelöst wurden.", - "Hier wird das Ergebnis Ausdruck angezeigt.": "Hier wird das Ergebnis Ausdruck angezeigt.", - "Hier den YAML Code eingeben, der geprüft werden soll.": "Hier den YAML Code eingeben, der geprüft werden soll.", - "Eingabe im .YAML Format": "Eingabe im .YAML Format", - "Ergebnis: Aufbereitetes .YAML Format": "Ergebnis: Aufbereitetes .YAML Format", - "Ergebnis: Aufbereitet als Python Source Code": "Ergebnis: Aufbereitet als Python Source Code", - "Python Code Ausgabe": "Python Code Ausgabe", - - "Nr.": "Nr.", - "Type": "Type", - "Configname": "Configname", - "Plugin": "Plugin", - "Classname": "Classname", - "Instanz": "Instanz", - "Version": "Version", - "Mehrere Instanzen möglich": "Multi-Instance", - "Web Interface": "Web Interface", - "ja": "ja", - "Ja": "Ja", - "nein": "nein", - "Nein": "Nein", - - "Szene": "Szene", - "Lernen": "Lernen", - - "Thread": "Thread", - "Thread-Id": "Thread-Id", - "Aktiv": "Aktiv", - "Visu Client": "Visu Client", - "Port": "Port", - "Client Software": "Client Software", - "Browser": "Browser", - "No active clients": "Keine aktiven Clients", - "Die eingegebenen Daten sind kein numerischer Wert": "Die eingegebenen Daten sind kein numerischer Wert", - "Filter": "Filter", - "Logfile": "Log-Datei", - "Dateien ohne zugehöriges Item im /var/cache Verzeichnis": "Dateien ohne zugehöriges Item im /var/cache Verzeichnis", - "Letzte Modifikation": "Letzte Modifikation", - "Erstellungsdatum": "Erstellungsdatum", - "no data available": "keine Daten verfügbar", - "logger name": "Logger Name", - "disabled": "disabled", - "level": "Level", - "filters": "Filter(s)", - "handlers": "Handler(s)", - "logfiles": "Logfile(s)", - "Logging": "Logging", - "Passwort anzeigen": "Passwort anzeigen", - "SmartHomeNG Version": "SmartHomeNG Version", - "SmartHomeNG Plugins Version": "SmartHomeNG Plugins Version", - "Watch_Items": "Watch_Items", - "Watch_Item(s)": "Watch_Item(s)", - "Anforderungen": "Anforderungen", - "Übersicht": "Übersicht", - "Eingabe im .CONF Format": "Eingabe im .CONF Format", - "Ergebnis im .YAML Format": "Ergebnis im .YAML Format", - "Wartung": "Wartung", - "Tester": "Tester", - "Beschreibung": "Beschreibung", - "Documentation": "Dokumentation", - "Support": "Support", - "Drücken Sie F11 für den Vollbildmodus, wenn der Cursor im Editorfenster ist. Mit F11 oder ESC verlassen Sie den Vollbildmodus wieder.": "Drücken Sie F11 für den Vollbildmodus, wenn der Cursor im Editorfenster ist. Mit F11 oder ESC verlassen Sie den Vollbildmodus wieder.", - "Zeilenumbruch" : "Zeilenumbruch", - "Strg + Space: Autovervollständigen von Python Befehlen. Strg + i: Autovervollständigen von Item-Pfaden.": "Strg + Space: Autovervollständigen von Python Befehlen. Strg + I: Autovervollständigen von Item-Pfaden.", - "Hilfe": "Hilfe", - "help_search_1": "Strg + F / Cmd + F: Suche starten", - "help_search_2": "Strg + G / Cmd + G: Nächsten Treffer finden", - "help_search_3": "Shift + Strg + G / Shift + Cmd + G: Vorherigen Treffer finden", - "help_search_4": "Shift + Strg + F / Cmd + Option + F: Ersetzen", - "help_search_5": "Shift + Strg + R / Shift + Cmd + Option + F: Alle ersetzen", - "help_search_6": "Alt + F: Persistenter Such-Dialog (Enter für nächsten Treffer, Shift + Enter für vorherigen Treffer)" , - "help_search_7": "Alt + G: In Zeile springen", - "Suche": "Suche", - "Hilfslinien": "Hilfslinien", - "PyPI Check": "PyPI Check", - "Systemeigenschaften": "Systemeigenschaften", - "Sie verlieren ggf. Ihre letzten, nicht gespeicherten Eingaben!": "Sie verlieren ggf. Ihre letzten, nicht gespeicherten Eingaben!", - "Wollen Sie die Logik" : "Wollen Sie die Logik", - "Wollen Sie die selektierten Dateien wirklich löschen?" : "Wollen Sie die selektierten Dateien wirklich löschen?", - "wirklich löschen?" : "wirklich löschen?", - "Neue Logik erstellen": "Neue Logik erstellen", - "Angaben für die Erzeugung einer neuen Python Logik": "Angaben für die Erzeugung einer neuen Python Logik", - "Dateiname des Python Codes der Logik (ohne Extension '.py')": "Dateiname des Python Codes der Logik (ohne Extension '.py')", - "Logik-Name/Abschnittsnamen in /etc/logic.yaml) - Wenn leer, wird der Dateiname verwendet": "Logik-Name/Abschnittsnamen in /etc/logic.yaml) - Wenn leer, wird der Dateiname verwendet", - "Logikname": "Logikname", - "Bitte Dateinamen angeben": "Bitte Dateinamen angeben", - "Logik-Datei": "Logik-Datei", - "existiert bereits": "existiert bereits", - "Der Logikname wird bereits verwendet": "Der Logikname wird bereits verwendet", - "sowie": "sowie", - "führen nicht zum": "führen nicht zum", - "der Logik": "der Logik", - "Bitte bei Bedarf anschließend": "Bitte bei Bedarf anschließend", - "drücken": "drücken", - "CONF-YAML Konverter": "CONF-YAML Konverter", - "Pakete ohne Requirements": "Pakete ohne Requirements", - "Version unterstützt!": "Version unterstützt!", - "Version nicht zulässig!": "Version nicht zulässig!", - "Schlüsselwörter": "Schlüsselwörter", - - "Entwicklungs-Daten": "Entwicklungs-Daten", - "Plugin spezifische Parameter": "Plugin spezifische Parameter", - "Plugin spezifische Item Attribute": "Plugin spezifische Item Attribute", - "Pakete für den Bau der Dokumentation": "Pakete für den Bau der Dokumentation", - "Pakete für die Testsuite": "Pakete für die Testsuite", - - "_items": { - "path": "Pfad", - "name": "Name", - "type": "Typ (type)", - "value": "Wert (value)", - "cache": "cache", - "enforce_updates": "enforce_updates", - "eval": "eval", - "eval_trigger": "eval_trigger", - "on_update": "on_update", - "on_change": "on_change", - "cycle": "cycle", - "crontab": "crontab", - "autotimer": "autotimer", - "threshold": "threshold", - "filename": "definiert in" - }, - - "_threads": { - "True": "Ja", - "False": "Nein" - }, - - "_disclosure": { - "Lizenz": "Lizenz", - "Link": "Link", - "Name": "Name", - "disclosure_title": "Offenlegung der genutzten Open Source Software Komponenten in SmartHomeNG", - "Verwendete Open Source Software Komponenten": "Verwendete Open Source Software Komponenten", - "Icons/Bilder": "Icons/Bilder", - "Alle Icons und Bilder kommen von": "Alle Icons und Bilder kommen von " - }, - - "_button": { - "smarthomeNG starten": "SmartHomeNG starten", - "knxd service starten": "knxd service starten", - "knxd socket starten": "knxd socket starten", - "smarthomeNG beenden": "SmartHomeNG beenden", - "knxd service beenden": "knxd service beenden", - "knxd socket beenden": "knxd socket beenden", - "Datenbank-Dump": "Datenbank-Dump", - "Filter anwenden": "Filter anwenden", - "Cacheprüfung": "Cacheprüfung", - "Passwort-Hash erzeugen": "Passwort-Hash erzeugen", - "Englisch": "English", - "Deutsch": "Deutsch", - "Französisch": "French", - "Polnisch": "Polish", - "Auslösen": "Auslösen", - "Neu Laden": "Neu Laden", - "Prüfen": "Prüfen", - "Deaktivieren": "Deaktivieren", - "Aktivieren": "Aktivieren", - "Hinzufügen": "Hinzufügen", - "Leeren" : "Leeren", - "Schließen": "Schließen", - "Änderungen verwerfen" : "Änderungen verwerfen", - "Blöcke speichern" : "Blöcke speichern", - "Beenden" : "Beenden", - "Speichern" : "Speichern", - "Speichern_und_Neu_Laden" : "Speichern und neu laden", - "Speichern_Neu_Laden_und_Triggern": "Speichern, neu laden und Auslösen", - "Löschen" : "Löschen", - "Alle ausgewählten löschen" : "Alle ausgewählten löschen", - "Konvertieren": "Konvertieren", - "Neue Blockly Logik": "Neue Blockly Logik", - "Neue Python Logik": "Neue Python Logik", - "Erstellen": "Erstellen", - "Entladen": "Entladen", - "Starten": "Starten", - "Stoppen": "Stoppen", - "Nach unten scrollen": "Nach unten scrollen" - }, - - "_menu": { - "Systeminfo": "Systeminfo", - "Dienste": "Dienste", - "CONF-YAML Konverter": "CONF-YAML Konverter", - "Items": "Items", - "Logiken": "Logiken", - "Blockly-Logiken-Editor": "Blockly-Logiken-Editor", - "Scheduler": "Scheduler", - "Plugins": "Plugins", - "Szenen": "Szenen", - "Threads": "Threads", - "Logging": "Logging", - "Visu": "Visu", - "Disclosure": "Urheberrechtshinweise" - } -} diff --git a/backend/locale/en.json b/backend/locale/en.json deleted file mode 100755 index 1d154e471..000000000 --- a/backend/locale/en.json +++ /dev/null @@ -1,302 +0,0 @@ -{ - "alle": "all", - "sonstige": "other", - "Scheduler": "Scheduler", - "Scheduler (plural)": "Schedulers", - "Item Scheduler": "Item Schedulers", - "Logik Scheduler": "Logic Schedulers", - "Plugin Scheduler": "Plugin Schedulers", - "sonstige Scheduler": "other Schedulers", - "Logik": "Logic", - "Systemlogiken": "System Logics", - "Nutzerlogiken": "User Logics", - "letzte Ausführung": "last execution", - "nächste Ausführung": "next execution", - "Cycle": "Cycle", - "Crontab": "Crontab", - "Crontab(s)": "Crontab(s)", - "Dateiname": "Filename", - "Aktionen": "Actions", - "Items": "Items", - "Item": "Item", - "gesamt": "total", - "Suchen": "Search", - "Suche zurücksetzen": "Reset search", - "Alle aufklappen": "Unfold All", - "Alle zuklappen": "Fold All", - "Itempfad suchen...": "Search Itempath...", - "Item im Baum auswählen um Details einzusehen!": "Select item in tree to see details!", - "Item-Informationen": "Item Information", - "Attribut": "attribute", - "Wert": "value", - "Pfad": "path", - "Name": "name", - "Typ": "type", - - "Änderungsinformationen": "Information about changes", - "Letztes Update": "last update", - "Letzte Änderung": "last change", - "Geändert durch": "changed by", - "age": "age (change)", - "update_age": "age (update)", - - "vorheriger Wert": "previous value", - "previous update": "previous update", - "previous change": "previous change", - "previous update age": "previous update age", - "previous age": "previous age", - "Update durch": "update by", - - "Evaluation und Trigger": "Initialization, Evaluation and Trigger Configuration", - "Plugin spezifische Attribute": "Plugin specific configurations", - "Plugin Metadaten": "Plugin Metadata", - "Verbundene Logiken": "Bound Logics", - "Verbundene Trigger": "Bound Triggers", - "vom Plugin definierte Methoden": "Methods defined by the plugin", - "Willkommen im Backend von": "Welcome to the backend of", - "Eigenschaft": "Property", - "IP": "IP Address", - "Betriebssystem": "Operating System", - "Architektur": "Architecture", - "Benutzer": "User", - "Host": "Host", - "Freier Speicher": "Free disk space", - "Datum": "Date", - "Zeit": "Time", - "Tag": "day", - "Tage": "days", - "Stunde": "hour", - "Stunden": "hours", - "Minute": "minute", - "Minuten": "minutes", - "Sekunden": "seconds", - "Betriebszeit": "Uptime", - "Python Version": "Python Version", - "in": "in", - "installierte Version": "installed Version", - "Neuste Version": "Newest version", - "Neuste Version!": "Newest version, please check for possible conflicts before upgrading!", - "Keine Antwort von PyPI": "No answer from PyPI", - "PyPI nicht erreichbar": "PyPI inaccessible", - "PyPI Prüfung deaktiviert": "PyPI check disabled", - "Dienst": "Service", - "Status": "Status", - "Aktion": "Action", - "Root-Passwort": "root password", - "Neu starten": "Restart system", - "Dienst für die KNX Unterstützung": "Service for KNX support", - "Nicht aktiv": "Not active", - "Sprache des Backends": "Language of Backend", - "Logfile speichern": "Save logfile", - "Logfile ansehen": "View logfile", - "Logger ansehen": "View loggers", - "Datenbank-Dump": "Dump database", - "Übersetzung neu laden": "Reload translation", - "Auf Deutsch wechseln": "Switch to German", - "Auf Englisch wechseln": "Switch to English", - "Auf Französisch wechseln": "Switch to French", - "Auf Polnisch wechseln": "Switch to Polish", - "YAML Syntax Checker": "YAML Syntax Checker", - "Eval Syntax Checker": "Eval Syntax Checker", - "Ausdruck (Eingabe im Python Eval-Format)": "Expression (Enter in Python eval-format)", - "Relativ zu (Eingabe des Item-Path)": "Relative to (Enter Item-Path)", - "Expandierter Ausdruck": "Expanded expression", - "Ergebnis": "Result", - "Hier den Ausdruck eingeben, der ausgewertet werden soll. Items können mit absolutem Pfad oder relativem Pfad angegeben werden.": "Enter the expression to be evaluated. Items can be entered with absolute or relative path.", - "Um relative Itempfade aufzulösen, hier das Item eingeben, zu dem die Pfade relativ sind.": "To resolve relative itempathes, enter the item here to which the items should be relative to.", - "Hier wird der Ausdruck angezeigt, nachdem die relativen Itempfade aufgelöst wurden.": "The expression with resolved relative pathes is displayed here.", - "Hier wird das Ergebnis Ausdruck angezeigt.": "The evaluated expression is displayed here.", - "Hier den YAML Code eingeben, der geprüft werden soll.": "Enter YAML code to be checked here.", - "Eingabe im .YAML Format": "Enter data in YAML format", - "Ergebnis: Aufbereitetes .YAML Format": "Result: Processed YAML format", - "Ergebnis: Aufbereitet als Python Source Code": "Result: Displayed as Python source code", - "Python Code Ausgabe": "Output as Python code", - - "Nr.": "No.", - "Type": "Type", - "Configname": "Configname", - "Plugin": "Plugin", - "Classname": "Classname", - "Instanz": "Instance", - "Version": "Version", - "Mehrere Instanzen möglich": "Multi-Instance", - "Web Interface": "Web Interface", - "ja": "yes", - "Ja": "Yes", - "nein": "no", - "Nein": "No", - - "Szene": "Scene", - "Lernen": "Learn", - - "Thread": "Thread", - "Thread-Id": "Thread-Id", - "Aktiv": "Active", - "Visu Client": "Visu Client", - "Port": "Port", - "Client Software": "Client Software", - "Browser": "Browser", - "No active clients": "No active clients", - "Die eingegebenen Daten sind kein numerischer Wert": "Die eingegebenen Daten sind kein numerischer Wert", - "Filter": "Filter", - "Logfile": "Logfile", - "Dateien ohne zugehöriges Item im /var/cache Verzeichnis": "Files without corresponding item in /var/cache folder", - "Letzte Modifikation": "Last Modification", - "Erstellungsdatum": "Created", - "no data available": "no data available", - "logger name": "Logger Name", - "disabled": "Disabled", - "level": "Level", - "filters": "Filter(s)", - "handlers": "Handler(s)", - "logfiles": "Logfile(s)", - "Logging": "Logging", - "Passwort anzeigen": "Show Password", - "SmartHomeNG Version": "SmartHomeNG Version", - "SmartHomeNG Plugins Version": "SmartHomeNG Plugins Version", - "Watch_Items": "Watch_Items", - "Watch_Item(s)": "Watch_Item(s)", - "Anforderungen": "Requirements", - "Übersicht": "Overview", - "Eingabe im .CONF Format": "Input in .CONF format", - "Ergebnis im .YAML Format": "Result in .YAML format", - "Wartung": "Maintainer", - "Tester": "Tester", - "Beschreibung": "Description", - "Documentation": "Documentation", - "Support": "Support", - "Drücken Sie F11 für den Vollbildmodus, wenn der Cursor im Editorfenster ist. Mit F11 oder ESC verlassen Sie den Vollbildmodus wieder.": "Press F11 when cursor is in the editor to toggle full screen editing. Esc can also be used to exit full screen editing.", - "Zeilenumbruch" : "Line Wrapping", - "Strg + Space: Autovervollständigen von Python Befehlen. Strg + i: Autovervollständigen von Item-Pfaden.": "Press Ctrl-Space to activate autocompletion of Python commands. Press Ctrl-I to activate autocompletion of item paths.", - "Hilfe": "Help", - "help_search_1": "Ctrl-F / Cmd-F: Start searching", - "help_search_2": "Ctrl-G / Cmd-G: Find next", - "help_search_3": "Shift-Ctrl-G / Shift-Cmd-G: Find previous", - "help_search_4": "Shift-Ctrl-F / Cmd-Option-F: Replace", - "help_search_5": "Shift-Ctrl-R / Shift-Cmd-Option-F: Replace all", - "help_search_6": "Alt-F: Persistent search (dialog doesn't autoclose, enter to find next, Shift-Enter to find previous)" , - "help_search_7": "Alt-G: Jump to line", - "Suche": "Search", - "Hilfslinien": "Rulers", - "PyPI Check": "PyPI Check", - "Systemeigenschaften": "System Properties", - "Sie verlieren ggf. Ihre letzten, nicht gespeicherten Eingaben!": "If you don't save, you will loose your last changes!", - "Wollen Sie die Logik" : "Do you really want to delete the logic", - "Wollen Sie die selektierten Dateien wirklich löschen?" : "Do you really want to delete the selected files?", - "wirklich löschen?" : "?", - "Neue Logik erstellen": "Create new logic", - "Angaben für die Erzeugung einer neuen Python Logik": "Data for creation of a new Python logic", - "Dateiname des Python Codes der Logik (ohne Extension '.py')": "Filename of the Python code (without '.py' extension)", - "Logik-Name/Abschnittsnamen in /etc/logic.yaml) - Wenn leer, wird der Dateiname verwendet": "Logic-/sectionname in /etc/logic.yaml - If empty, the filename is used", - "Logikname": "Logicname", - "Bitte Dateinamen angeben": "Please enter filename", - "Logik-Datei": "Logic-file", - "existiert bereits": "already exists", - "Der Logikname wird bereits verwendet": "The name of the logic is already used", - "sowie": "and", - "führen nicht zum": "do not", - "der Logik": "the logic", - "Bitte bei Bedarf anschließend": "If needed, please press", - "drücken": " ", - "CONF-YAML Konverter": "CONF-YAML Converter", - "Pakete ohne Requirements": "Packages without Requirements", - "Version unterstützt!": "Version usable!", - "Version nicht zulässig!": "Version not usable!", - "Schlüsselwörter": "Keywords", - - "Entwicklungs-Daten": "Development Data", - "Plugin spezifische Parameter": "Plugin specific parameters", - "Plugin spezifische Item Attribute": "Plugin specific item attributes", - "Pakete für den Bau der Dokumentation": "Packages for Building the Documentation", - "Pakete für die Testsuite": "Packages for the Test Suite", - - "_items": { - "path": "Pfad", - "name": "name", - "type": "type", - "value": "value", - "cache": "cache", - "enforce_updates": "enforce_updates", - "eval": "eval", - "eval_trigger": "eval_trigger", - "on_update": "on_update", - "on_change": "on_change", - "cycle": "cycle", - "crontab": "crontab", - "autotimer": "autotimer", - "threshold": "threshold", - "filename": "defined in" - }, - - "_threads": { - "True": "Yes", - "False": "No" - }, - - - "_disclosure": { - "Lizenz": "License", - "Link": "Link", - "Name": "Name", - "disclosure_title": "Disclosure of Open Source Software Components deployed in SmartHomeNG", - "Verwendete Open Source Software Komponenten": "Open Source Software Components used", - "Icons/Bilder": "Icons/Images", - "Alle Icons und Bilder kommen von": "All icons and images are taken from " - }, - - "_button": { - "Auslösen": "Trigger", - "Neu Laden": "Reload", - "smarthomeNG starten": "Start SmartHomeNG", - "knxd service starten": "Start knxd service", - "knxd socket starten": "Start knxd socket", - "smarthomeNG beenden": "Stop SmartHomeNG", - "knxd service beenden": "Stop knxd service", - "knxd socket beenden": "Stop knxd socket", - "Filter anwenden": "Apply Filter", - "Datenbank-Dump": "Dump database", - "Cacheprüfung": "Cache Check", - "Passwort-Hash erzeugen": "Create Password Hash", - "Englisch": "English", - "Deutsch": "Deutsch", - "Französisch": "French", - "Polnisch": "Polish", - "Prüfen": "Check", - "Deaktivieren": "Disable", - "Aktivieren": "Enable", - "Leeren" : "Clear", - "Schließen": "Close", - "Änderungen verwerfen" : "Undo Changes", - "Blöcke speichern" : "Save Blocks", - "Beenden" : "Stop", - "Speichern" : "Save", - "Speichern_und_Neu_Laden" : "Save and Reload", - "Speichern_Neu_Laden_und_Triggern": "Save, Reload and Trigger", - "Löschen" : "Delete", - "Alle ausgewählten löschen" : "Delete All Selected", - "Konvertieren": "Convert", - "Neue Blockly Logik": "New Blockly logic", - "Neue Python Logik": "New Python logic", - "Erstellen": "Create", - "Entladen": "Unload", - "Starten": "Start", - "Stoppen": "Stop", - "Nach unten scrollen": "Scroll down" - }, - - "_menu": { - "Systeminfo": "Systeminfo", - "Dienste": "Services", - "Items": "Items", - "Logiken": "Logics", - "Blockly-Logiken-Editor": "Blockly Logics Editor", - "Scheduler": "Scheduler", - "Plugins": "Plugins", - "Szenen": "Scenes", - "Threads": "Threads", - "Logging": "Logging", - "Visu": "Visu", - "Disclosure": "Disclosure", - "CONF-YAML Konverter": "CONF-YAML Converter" - } -} diff --git a/backend/locale/fr.json b/backend/locale/fr.json deleted file mode 100755 index 21c359286..000000000 --- a/backend/locale/fr.json +++ /dev/null @@ -1,303 +0,0 @@ -{ - "alle": "tous", - "sonstige": "autres", - "Scheduler": "Planificateur", - "Scheduler (plural)": "Planificateurs", - "Item Scheduler": "Planificateurs des objets", - "Logik Scheduler": "Planificateurs des logiques", - "Plugin Scheduler": "Planificateurs des extensions", - "sonstige Scheduler": "Autre planificateurs", - "Logik": "Logique", - "Systemlogiken": "Logiques système", - "Nutzerlogiken": "Logiques utilisateur", - "Neue Logiken (nicht geladen)": "Nouvelles logiques (non encore chargées)", - "letzte Ausführung": "Dernière exécution", - "nächste Ausführung": "Prochaine exécution", - "Cycle": "Cycle", - "Crontab": "Crontab", - "Crontab(s)": "Crontab(s)", - "Dateiname": "Nom du fichier", - "Aktionen": "Actions", - "Items": "Objets", - "Item": "Objet", - "gesamt": "total", - "Suchen": "Chercher", - "Suche zurücksetzen": "Réinitialiser", - "Alle aufklappen": "Montrer tout", - "Alle zuklappen": "Cacher tout", - "Itempfad suchen...": "Chercher chemin...", - "Item im Baum auswählen um Details einzusehen!": "Choisir un objet dans l'arborescence pour afficher les détails!", - "Item-Informationen": "Informations sur l'objet", - "Attribut": "Attribut", - "Wert": "Valeur", - "Pfad": "Chemin", - "Name": "Nom", - "Typ": "Type", - - "Änderungsinformationen": "Informations sur les modifications", - "Letztes Update": "Dernière mise à jour", - "Letzte Änderung": "Dernière modification", - "Geändert durch": "Modifié par", - "age": "Âge (modification)", - "update_age": "Âge (mise à jour)", - "aktueller": "actuel", - "vorheriger": "précédent", - "vorheriger Wert": "Valeur précédente", - "previous update": "Mise à jour précédente", - "previous change": "Modification précédente", - "previous update age": "Âge précédent (mise à jour)", - "previous age": "Âge précédent (modification)", - "Update durch": "Mise à jour par", - - "Evaluation und Trigger": "Configuration de l'initialisation, de l'évaluation et des déclencheurs", - "Plugin spezifische Attribute": "Attributs spécifiques à l'extension", - "Plugin Metadaten": "Métadonnées de l'extension", - "Verbundene Logiken": "Logiques connexes", - "Verbundene Trigger": "Déclencheurs connexes", - "vom Plugin definierte Methoden": "", - "Willkommen im Backend von": "Bienvenue au Backend de", - "Eigenschaft": "Propriété", - "IP": "Adresse IP", - "Betriebssystem": "Système d'exploitation", - "Architektur": "Architecture", - "Benutzer": "utilisateur", - "Host": "Hôte", - "Freier Speicher": "Espace disque libre", - "Datum": "Date", - "Zeit": "Heure", - "Tag": "jour", - "Tage": "jours", - "Stunde": "heure", - "Stunden": "heures", - "Minute": "minute", - "Minuten": "minutes", - "Sekunden": "secondes", - "Betriebszeit": "Temps en service", - "Python Version": "Version Python", - "in": "dans", - "installierte Version": "Version installée", - "Neuste Version": "Dernière version", - "Neuste Version!": "", - "Keine Antwort von PyPI": "Pas de réponse de PyPI", - "PyPI nicht erreichbar": "PyPI non accessible", - "PyPI Prüfung deaktiviert": "Vérification PyPI désactivée", - "Dienst": "Service", - "Status": "Etat", - "Aktion": "Action", - "Root-Passwort": "Mot de passe 'root'", - "Neu starten": "Redémarrer le système", - "Dienst für die KNX Unterstützung": "Service pour le support du KNX", - "Nicht aktiv": "Inactif", - "Sprache des Backends": "Langue du backend", - "Logfile ansehen": "Afficher journal", - "Logger ansehen": "Afficher journalisateurs", - "Datenbank-Dump": "Dump de la base de données", - "Übersetzung neu laden": "Recharger traduction", - "Auf Deutsch wechseln": "Basculer vers l'allemand", - "Auf Englisch wechseln": "Basculer vers l'anglais", - "Auf Polnisch wechseln": "Basculer vers le polonais", - "YAML Syntax Checker": "Contrôle de syntax YAML", - "Eval Syntax Checker": "Contrôle de syntax 'eval'", - "Ausdruck (Eingabe im Python Eval-Format)": "Expression (entrer en format Python 'eval')", - "Relativ zu (Eingabe des Item-Path)": "Relatif à (entrer chemin de l'objet)", - "Expandierter Ausdruck": "Expression étendue", - "Ergebnis": "Résultat", - "Hier den Ausdruck eingeben, der ausgewertet werden soll. Items können mit absolutem Pfad oder relativem Pfad angegeben werden.": "Entrer ici l'expression à valider. Les objets peuvent être utilisés par leur chemin absolut ou relatif.", - "Um relative Itempfade aufzulösen, hier das Item eingeben, zu dem die Pfade relativ sind.": "Pour résoudre des chemins d'objets relatifs veuillez entrer ici l'objet racine du chemin relatif.", - "Hier wird der Ausdruck angezeigt, nachdem die relativen Itempfade aufgelöst wurden.": "Le résultat sera affiché ici après résolution des chemins d'objets relatifs.", - "Hier wird das Ergebnis Ausdruck angezeigt.": "Le résultat sera affiché ici.", - "Hier den YAML Code eingeben, der geprüft werden soll.": "Entrez ici le code .YAML à contrôler.", - "Eingabe im .YAML Format": "Entrée en format .YAML", - "Ergebnis: Aufbereitetes .YAML Format": "Résultat: Format .YAML édité", - "Ergebnis: Aufbereitet als Python Source Code": "Résultat: Edité en format code source Python", - "Python Code Ausgabe": "Sortie code Python", - "Pakete für den Bau der Dokumentation": "Paquets pour le génération de la documentation:", - "Pakete für die Testsuite": "Paquets pour la suite de tests", - - "Nr.": "N°", - "Type": "Type", - "Configname": "Nom configuré", - "Plugin": "Extension", - "Classname": "Nom de la classe", - "Instanz": "Instance", - "Version": "Version", - "Mehrere Instanzen möglich": "Supporte plusieurs instances", - "Web Interface": "Interface web", - "ja": "oui", - "Ja": "Oui", - "nein": "non", - "Nein": "Non", - - "Szene": "Scène", - "Lernen": "Apprendre", - - "Thread": "Tâche", - "Thread-Id": "Id de tâche", - "Aktiv": "Active", - "Visu Client": "Client Visu", - "Port": "Port", - "Client Software": "Logiciel client", - "Browser": "Navigateur", - "No active clients": "Pas de clients actifs", - "Die eingegebenen Daten sind kein numerischer Wert": "Les valeurs entrées ne sont pas numériques", - "Filter": "Filtre", - "Logfile": "Fichier log", - "Dateien ohne zugehöriges Item im /var/cache Verzeichnis": "Fichiers dans le répertoire /var/cache sans objet associé", - "Letzte Modifikation": "Dernière modification", - "Erstellungsdatum": "Date de création", - "no data available": "pas de données disponibles", - "logger name": "Nom du journalisateur", - "disabled": "Désactivé", - "level": "Niveau", - "filters": "Filtres", - "handlers": "Gestionnaire", - "logfiles": "Fichier(s) journal", - "Logging": "Journalisation", - "Passwort anzeigen": "Afficher mot de passe", - "SmartHomeNG Version": "Version de SmartHomeNG", - "SmartHomeNG Plugins Version": "Version des extensions", - "Watch_Items": "Objets surveillés", - "Watch_Item(s)": "Objet(s) surveillé(s)", - "Anforderungen": "Version demandée", - "Übersicht": "Aperçu", - "Eingabe im .CONF Format": "Entrée en format .CONF", - "Ergebnis im .YAML Format": "Résultat en format .YAML", - "Wartung": "Maintenance", - "Tester": "Testeur", - "Beschreibung": "Description", - "Documentation": "Documentation", - "Support": "Support", - "Drücken Sie F11 für den Vollbildmodus, wenn der Cursor im Editorfenster ist. Mit F11 oder ESC verlassen Sie den Vollbildmodus wieder.": "Appuyez F11 si le curseur est dans la fenêtre de l'éditeur pour passer en mode plein-écran. Appuyez F11 ou ESC pour sortir du mode plein-écran.", - "Zeilenumbruch" : "Coupure de ligne", - "Strg + Space: Autovervollständigen von Python Befehlen. Strg + i: Autovervollständigen von Item-Pfaden.": "Appuyez ctrl-espace pour activer le complètement automatique des commandes Python. Appuyez ctrl-i pour activer le complètement automatique des chemins des objets.", - "Hilfe": "Aide", - "help_search_1": "Ctrl-F / Cmd-F: Démarrer la recherche", - "help_search_2": "Ctrl-G / Cmd-G: Prochain résultat", - "help_search_3": "Shift-Ctrl-G / Shift-Cmd-G: Résultat précédent", - "help_search_4": "Shift-Ctrl-F / Cmd-Option-F: Remplacer", - "help_search_5": "Shift-Ctrl-R / Shift-Cmd-Option-F: Remplacer tout", - "help_search_6": "Alt-F: Recherche persistente (dialogue ne se ferme pas, pour prochain résultat, Shift-Entrée pour résultat précédent)", - "help_search_7": "Alt-G: Sauter vers ligne", - "Suche": "Chercher", - "Hilfslinien": "Lignes de séparation", - "PyPI Check": "Vérification PyPI", - "Systemeigenschaften": "Propriétés du système", - "Sie verlieren ggf. Ihre letzten, nicht gespeicherten Eingaben!": "Si vous ne sauvegardez pas vous perdez toutes modifications!", - "Wollen Sie die Logik" : "Voulez-vous vraiment supprimer cette logique", - "Wollen Sie die selektierten Dateien wirklich löschen?" : "Voulez-vous vraiment supprimer tous les fichiers choisis ?", - "wirklich löschen?" : "?", - "Neue Logik erstellen": "Créer nouvelle logique", - "Angaben für die Erzeugung einer neuen Python Logik": "Donées de la nouvelle logique", - "Dateiname des Python Codes der Logik (ohne Extension '.py')": "Nom du fichier Python de la logique (sans l'extension '.py')", - "Logik-Name/Abschnittsnamen in /etc/logic.yaml) - Wenn leer, wird der Dateiname verwendet": "Nom de la logique dans /etc/logic.yaml - Si vide le nom du fichier sera utilisé", - "Logikname": "Nom de la logique", - "Bitte Dateinamen angeben": "Veuillez entrer le nom du fichier:", - "Logik-Datei": "Fichier logique", - "existiert bereits": "existe déjà", - "Der Logikname wird bereits verwendet": "Ce nom de la logique est déjà utilisé", - "sowie": "ainsi que", - "führen nicht zum": "n'aboutissent pas à", - "der Logik": "de la logique", - "Bitte bei Bedarf anschließend": "Connecter en cas de besoin s.v.p.", - "drücken": "pousser", - "CONF-YAML Konverter": "Convertisseur .conf vers .yaml", - "Pakete ohne Requirements": "Paquets sans prérequis", - "Version unterstützt!": "Version supportée", - "Version nicht zulässig!": "Version non autorisée", - "Schlüsselwörter": "Mots-clés", - - "Entwicklungs-Daten": "Données de développement", - "Plugin spezifische Parameter": "Paramètres spécifiques à l'extension", - "Plugin spezifische Item Attribute": "Attributs d'objets spécifiques à l'extension", - - "_items": { - "path": "Chemin", - "name": "Nom", - "type": "Type", - "value": "Valeur", - "cache": "cache", - "enforce_updates": "forcer mises à jour", - "eval": "eval", - "eval_trigger": "déclencheur d'éval", - "on_update": "si mise à jour", - "on_change": "si changement", - "cycle": "cycle", - "crontab": "crontab", - "autotimer": "autotimer", - "threshold": "seuil", - "filename": "défini dans" - }, - - "_threads": { - "True": "Oui", - "False": "Non" - }, - - "_disclosure": { - "Lizenz": "Licence", - "Link": "Lien", - "Name": "Nom", - "disclosure_title": "Déclaration des composantes Open Source utilisées dans SmartHomeNG", - "Verwendete Open Source Software Komponenten": "Composantes Open Source utilisées", - "Icons/Bilder": "Icônes/Images", - "Alle Icons und Bilder kommen von": "Toutes les images et icônes parviennent de" - }, - - "_button": { - "Auslösen": "Déclencher", - "Neu Laden": "Recharger", - "smarthomeNG starten": "démarrer SmartHomeNG", - "knxd service starten": "démarrer service knxd", - "knxd socket starten": "démarrer socket knxd", - "smarthomeNG beenden": "arrêter SmartHomeNG", - "knxd service beenden": "arrêter service knxd", - "knxd socket beenden": "arrêter socket knxd", - "Filter anwenden": "Appliquer le filtre", - "Datenbank-Dump": "Dump de la base de données", - "Cacheprüfung": "Test du cache", - "Passwort-Hash erzeugen": "Créer hachage du mot de passe", - "Englisch": "Anglais", - "Deutsch": "Allemand", - "Französisch": "Français", - "Polnisch": "Polonais", - "Prüfen": "Contrôler", - "Deaktivieren": "Désactiver", - "Aktivieren": "Activer", - "Hinzufügen": "Ajouter", - "Leeren" : "Vider", - "Schließen": "Fermer", - "Änderungen verwerfen" : "Annuler modifications", - "Blöcke speichern" : "Enregistrer les blocs", - "Beenden" : "Terminer", - "Logfile speichern": "Sauvegarder fichier journal", - "Speichern" : "Sauvegarder", - "Speichern_und_Neu_Laden" : "Sauvegarder et recharger", - "Speichern_Neu_Laden_und_Triggern": "Sauvegarder, recharger et déclencher", - "Löschen" : "Supprimer", - "Alle ausgewählten löschen" : "Supprimer tous les éléments choisis", - "Konvertieren": "Convertir", - "Neue Blockly Logik": "Nouvelle logique 'Blockly'", - "Neue Python Logik": "Nouvelle logique 'Python'", - "Erstellen": "Créer", - "Entladen": "Décharger", - "Starten": "Démarrer", - "Stoppen": "Arrêter", - "Nach unten scrollen": "Défiler vers le bas" - }, - - "_menu": { - "Systeminfo": "Info système", - "Dienste": "Services", - "Items": "Objets", - "Logiken": "Logiques", - "Blockly-Logiken-Editor": "Blockly - Editeur de Logiques", - "Scheduler": "Planificateur", - "Plugins": "Extensions", - "Szenen": "Scènes", - "Threads": "Tâches", - "Logging": "Journalisation", - "Visu": "Visu", - "Disclosure": "Déclaration", - "CONF-YAML Konverter": "Convertisseur .conf vers .yaml" - } -} diff --git a/backend/locale/pl.json b/backend/locale/pl.json deleted file mode 100755 index a004e83aa..000000000 --- a/backend/locale/pl.json +++ /dev/null @@ -1,300 +0,0 @@ -{ - "alle": "", - "sonstige": "", - "Scheduler": "Planista", - "Scheduler (plural)": "", - "Item Scheduler": "", - "Logik Scheduler": "", - "Plugin Scheduler": "", - "sonstige Scheduler": "", - "Logik": "Logika", - "Systemlogiken": "Logika systemowa", - "Nutzerlogiken": "Logika użytkownika", - "letzte Ausführung": "", - "nächste Ausführung": "Następne wykonanie", - "Cycle": "Cykl", - "Crontab": "Harmonogram", - "Crontab(s)": "Harmonogram", - "Dateiname": "Nazwa pliku", - "Aktionen": "Akcje", - "Items": "Itemy", - "Item": "Item", - "gesamt": "w sumie", - "Suchen": "Szukaj", - "Suche zurücksetzen": "Reset wyszukiwania", - "Alle aufklappen": "Rozwiń wszystko", - "Alle zuklappen": "Zwiń wszystko", - "Itempfad suchen...": "Szukaj Itemów...", - "Item im Baum auswählen um Details einzusehen!": "Wybierz Item aby zobaczyć szczegóły!", - "Item-Informationen": "Informacje o Itemie", - "Attribut": "atrybut", - "Wert": "wartość", - "Pfad": "ścieżka", - "Name": "nazwa", - "Typ": "typ", - - "Änderungsinformationen": "Inormacje o zmianach", - "Letztes Update": "ostatnia aktualizacja", - "Letzte Änderung": "ostatnia zmiana", - "Geändert durch": "zmieniono przez", - "age": "czas aktualnej wartości", - "update_age": "", - "vorheriger Wert": "poprzednia wartość", - "previous update": "", - "previous change": "poprzednia zmiana", - "previous update age": "", - "previous age": "czas poprzedniej wartości", - "Update durch": "", - - "Evaluation und Trigger": "Inicjalizacja, ewaluacja i wyzwalacze", - "Plugin spezifische Attribute": "Konfiguracja pluginów", - "Plugin Metadaten": "Metadane pluginu", - "Verbundene Logiken": "Powiązana Logika", - "Verbundene Trigger": "Powiązane wyzwalacze", - "vom Plugin definierte Methoden": "", - "Willkommen im Backend von": "Witaj w backendzie", - "Eigenschaft": "Atrybut", - "IP": "Adres IP", - "Betriebssystem": "System operacyjny", - "Architektur": "Architektura", - "Benutzer": "Użytkownik", - "Host": "", - "Freier Speicher": "Wolne miejsce", - "Datum": "Data", - "Zeit": "Czas", - "Tag": "dzień", - "Tage": "dni", - "Stunde": "godzina", - "Stunden": "godzin", - "Minute": "minuta", - "Minuten": "minut", - "Sekunden": "sekund", - "Betriebszeit": "Czas pracy", - "Python Version": "Wersja Pythona", - "installierte Version": "Zainstalowana wersja", - "Neuste Version": "Najnowsza wersja", - "Neuste Version!": "", - "Keine Antwort von PyPI": "Brak odpowiedzi z PyPI", - "PyPI nicht erreichbar": "PyPI niedostępne", - "PyPI Prüfung deaktiviert": "sprawdzanie PyPI wyłączone", - "Dienst": "Usługa", - "Status": "Status", - "Aktion": "Akcja", - "Root-Passwort": "Hasło roota", - "Neu starten": "Restart systemu", - "Dienst für die KNX Unterstützung": "Usługa KNX", - "Nicht aktiv": "Nieaktywne", - "Sprache des Backends": "Język", - "Logfile speichern": "Zapisz log", - "Logfile ansehen": "Przeglądaj log", - "Logger ansehen": "Przeglądaj loggery", - "Datenbank-Dump": "Pobierz bazę danych", - "Übersetzung neu laden": "Przeładuj tłumaczenie", - "Auf Deutsch wechseln": "Przełącz na nimiecki", - "Auf Englisch wechseln": "Przełącz na angielski", - "Auf Französisch wechseln": "Przełącz na francuski", - "Auf Polnisch wechseln": "Przełącz na polski", - "YAML Syntax Checker": "", - "Eval Syntax Checker": "", - "Ausdruck (Eingabe im Python Eval-Format)": "", - "Relativ zu (Eingabe des Item-Path)": "", - "Expandierter Ausdruck": "", - "Ergebnis": "", - "Hier den Ausdruck eingeben, der ausgewertet werden soll. Items können mit absolutem Pfad oder relativem Pfad angegeben werden.": "", - "Um relative Itempfade aufzulösen, hier das Item eingeben, zu dem die Pfade relativ sind.": "", - "Hier wird der Ausdruck angezeigt, nachdem die relativen Itempfade aufgelöst wurden.": "", - "Hier wird das Ergebnis Ausdruck angezeigt.": "", - "Hier den YAML Code eingeben, der geprüft werden soll.": "", - "Eingabe im .YAML Format": "", - "Ergebnis: Aufbereitetes .YAML Format": "", - "Ergebnis: Aufbereitet als Python Source Code": "", - "Python Code Ausgabe": "", - "Pakete für den Bau der Dokumentation": "Packages for Building the Documentation", - "Pakete für die Testsuite": "Packages for the Test Suite", - - "Nr.": "Nr", - "Type": "Typ", - "Configname": "", - "Plugin": "Plugin", - "Classname": "Nazwa klasy", - "Instanz": "Instancja", - "Version": "Wersja", - "Mehrere Instanzen möglich": "Możliwe wiele instancji", - "Web Interface": "", - "ja": "Tak", - "Ja": "Tak", - "nein": "Nie", - "Nein": "Nie", - - "Szene": "", - "Lernen": "", - - "Thread": "Wątki", - "Thread-Id": "Id Wątku", - "Aktiv": "Aktywny", - "Visu Client": "Klient Visu", - "Port": "Port", - "Client Software": "Oprogramowanie klienta", - "Browser": "Przeglądarka", - "No active clients": "Brak aktywnych klientów", - "Die eingegebenen Daten sind kein numerischer Wert": "Wprowadzona wartość nie jest liczbą", - "Filter": "Filtr", - "Logfile": "Log", - "Dateien ohne zugehöriges Item im /var/cache Verzeichnis": "Pliki w var/cache bez odpowiadającego im Itema", - "Letzte Modifikation": "Ostatnia modyfikacja", - "Erstellungsdatum": "Utworzono", - "no data available": "brak danych", - "logger lame": "Nazwa Loggera", - "disabled": "Wyłączony", - "level": "Poziom", - "filters": "Filtry", - "handlers": "Handlery", - "logfiles": "Log", - "Logging": "Logowanie", - "Passwort anzeigen": "pokaż", - "SmartHomeNG Version": "Wersja SmartHomeNG", - "SmartHomeNG Plugins Version": "Wersja Pluginy SmartHomeNG", - "Watch_Items": "Obserwowane Itemy", - "Watch_Item(s)": "Obserwowane Itemy", - "Anforderungen": "Wymagania", - "Übersicht": "Podsumowanie", - "Eingabe im .CONF Format": "Wejście w formacie .CONF", - "Ergebnis im .YAML Format": "Wyjście w formacie .YAML", - "Wartung": "Opiekun", - "Tester": "Tester", - "Beschreibung": "Opis", - "Documentation": "Dokumentacja", - "Support": "Wsparcie", - "Drücken Sie F11 für den Vollbildmodus, wenn der Cursor im Editorfenster ist. Mit F11 oder ESC verlassen Sie den Vollbildmodus wieder.": "Press F11 when cursor is in the editor to toggle full screen editing. Esc can also be used to exit full screen editing.", - "Zeilenumbruch" : "Line Wrapping", - "Strg + Space: Autovervollständigen von Python Befehlen. Strg + i: Autovervollständigen von Item-Pfaden.": "Press ctrl-space to activate autocompletion of Python commands. Press ctrl-i to activate autocompletion of item paths.", - "Hilfe": "Help", - "help_search_1": "Ctrl-F / Cmd-F: Start searching", - "help_search_2": "Ctrl-G / Cmd-G: Find next", - "help_search_3": "Shift-Ctrl-G / Shift-Cmd-G: Find previous", - "help_search_4": "Shift-Ctrl-F / Cmd-Option-F: Replace", - "help_search_5": "Shift-Ctrl-R / Shift-Cmd-Option-F: Replace all", - "help_search_6": "Alt-F: Persistent search (dialog doesn't autoclose, enter to find next, Shift-Enter to find previous)" , - "help_search_7": "Alt-G: Jump to line", - "Suche": "Search", - "Hilfslinien": "Rulers", - "PyPI Check": "PyPI Check", - "Systemeigenschaften": "System Properties", - "Sie verlieren ggf. Ihre letzten, nicht gespeicherten Eingaben!": "If you don't save, you will loose your last changes!", - "Wollen Sie die Logik" : "", - "Wollen Sie die selektierten Dateien wirklich löschen?" : "", - "wirklich löschen?" : "", - "Neue Logik erstellen": "", - "Angaben für die Erzeugung einer neuen Python Logik": "", - "Dateiname des Python Codes der Logik (ohne Extension '.py')": "", - "Logik-Name/Abschnittsnamen in /etc/logic.yaml) - Wenn leer, wird der Dateiname verwendet": "", - "Logikname": "", - "Bitte Dateinamen angeben": "", - "Logik-Datei": "", - "existiert bereits": "", - "Der Logikname wird bereits verwendet": "", - "sowie": "", - "führen nicht zum": "", - "der Logik": "", - "Bitte bei Bedarf anschließend": "", - "drücken": "", - "CONF-YAML Konverter": "Konwerter CONF-YAML", - "Pakete ohne Requirements": "", - "Version unterstützt!": "", - "Version nicht zulässig!": "", - "Schlüsselwörter": "", - - "Entwicklungs-Daten": "", - "Plugin spezifische Parameter": "", - "Plugin spezifische Item Attribute": "", - - "_items": { - "path": "ścieżka", - "name": "nazwa", - "type": "typ", - "value": "wartość", - "cache": "pamięć podręczna", - "enforce_updates": "wymuszone aktualizacje", - "eval": "ewaluacja", - "eval_trigger": "wyzwalacze ewaluacji", - "on_update": "on_update", - "on_change": "on_change", - "cycle": "cykl", - "crontab": "harmonogram", - "autotimer": "autotimer", - "threshold": "próg", - "filename": "" - }, - - "_threads": { - "True": "Tak", - "False": "Nie" - }, - - - "_disclosure": { - "Lizenz": "Licencja", - "Link": "Link", - "Name": "Nazwa", - "disclosure_title": "Deklaracja oprogramowania Open Source wykorzystanego w SmartHomeNG", - "Verwendete Open Source Software Komponenten": "Wykaz użytego oprogramowania Open Source", - "Icons/Bilder": "Ikony/Obrazy", - "Alle Icons und Bilder kommen von": "Wszystkie ikony i obrazy pochodzą z" - }, - - "_button": { - "Auslösen": "Uruchom", - "Neu Laden": "Przeładuj", - "smarthomeNG starten": "Start SmartHomeNG", - "knxd service starten": "Start usługi knxd", - "knxd socket starten": "Start socketa knxd", - "smarthomeNG beenden": "Stop SmartHomeNG", - "knxd service beenden": "Stop usługi knxd", - "knxd socket beenden": "Stop socketa knxd", - "Filter anwenden": "Zastosuj filtr", - "Datenbank-Dump": "Pobierz bazę danych", - "Cacheprüfung": "Sprawdź pamięć podręczną", - "Passwort-Hash erzeugen": "Utwórz zahashowane hasło", - "Englisch": "English", - "Deutsch": "Deutsch", - "Französisch": "French", - "Polnisch": "Polski", - "Prüfen": "", - "Deaktivieren": "Wyłącz", - "Aktivieren": "Włącz", - "Leeren" : "Wyczyść", - "Schließen": "Zamknij", - "Änderungen verwerfen" : "Cofnij Zmiany", - "Blöcke speichern" : "Zapisz Bloki", - "Beenden" : "Stop", - "Speichern" : "Zapisz", - "Speichern_und_Neu_Laden" : "Zapisz i przeładuj", - "Speichern_Neu_Laden_und_Triggern": "", - "Löschen" : "Skasuj", - "Alle ausgewählten löschen" : "", - "Konvertieren": "Konwertuj", - "Neue Blockly Logik": "", - "Neue Python Logik": "", - "Erstellen": "", - "Entladen": "", - "Starten": "", - "Stoppen": "", - "Nach unten scrollen": "" - }, - - "_menu": { - "Systeminfo": "System", - "Dienste": "Usługi", - "Items": "Itemy", - "Logiken": "Logika", - "Blockly-Logiken-Editor": "Edytor logiki Blockly", - "Scheduler": "Planista", - "Plugins": "Pluginy", - "Szenen": "", - "Threads": "Wątki", - "Logging": "Logowanie", - "Visu": "Visu", - "Disclosure": "Deklaracja", - "CONF-YAML Konverter": "Konwerter CONF-YAML" - } -} diff --git a/backend/plugin.yaml b/backend/plugin.yaml deleted file mode 100755 index c114088ef..000000000 --- a/backend/plugin.yaml +++ /dev/null @@ -1,97 +0,0 @@ -# Metadata for the Smart-Plugin -plugin: - # Global plugin attributes - type: system # plugin type (gateway, interface, protocol, system, web) - subtype: core # plugin subtype (if applicable) - description: # Alternative: description in multiple languages - de: 'Web Interface zur Anzeige von Informationen zum System und SmartHomeNG Backend-Daten' - en: 'webinterface for displaying system information and SmartHomeNG backend data' - - description_long: # Alternative: long description in multiple languages - de: 'Dieses Plugin liefert Informationen über die aktuell laufende SmartHomeNG Installation. Bisher dient es vorwiegend als Support Tool um Anwendern zu helfen, deren Installation nicht richtig läuft.\n - \n - Einige Highlights:\n - \n - - eine Liste der installierten Python Module wird angezeigt und die Versionen werden gegenüber den Requirements und den verfügbaren Versions von PyPI abgeglichen\n - - eine Liste der Items und ihrer Attribute wird angezeigt. Für diverse Item Typen ist der Wert änderbar\n - - eine Liste der Logiken mit nächster Ausführungszeit wird angezeit\n - - Logiken können aktiviert/deaktiviert oder getriggert werden\n - - Logiken können erstellt und editiert werden\n - - eine Liste der aktuellen Scheduler und ihr nächster Ausführungszeitpunkt wird angezeigt\n - - Ein direktes Download der Sqlite Datenbank (nutzt das sqlite Plugins) und der SmartHomeNG Log-Dateien ist möglich\n - - Einige Informationen über häufig genutzte Daemons wie knxd bzw. eibd werden angezeigt\n - - Unterstützt Basic Authentication bei Web-Browser Zugriff\n - - Unterstützt mehrere Sprachen\n - \n - Es gibt bisher nur eine Basic Absicherung gegen nicht-authorisierten Zugriff oder Nutzung des Plugins. Deshalb ist Vorsicht geboten, wenn das Plugin im Netzwerk (evtl. sogar über WAN) zugreifbar ist.\n - \n - Der Aufruf des Backend-Webservers erfolgt standardmäßig durch: http://:8383 - ' - - en: 'This plugin delivers information about the current SmartHomeNG installation. Right now it serves as a support tool for helping other users with an installation that does not run properly.\n - \n - Some highlights:\n - \n - - a list of installed python modules is shown versus the available versions from PyPI\n - - a list of items and their attributes is shown\n - - a list of logics and their next execution time\n - - a list of current schedulers and their next execution time\n - - direct download of sqlite database (if plugin is used) and smarthome.log\n - - some information about frequently used daemons like knxd/eibd is included\n - - supports basic authentication\n - - multi-language support\n - \n - There is however only basic protection against unauthorized access or use of the plugin so be careful when enabling it with your network.\n - \n - Call the backend-webserver: http://:8383 - ' - maintainer: psilo909, msinn, bmxp - tester: Sandman60 - # state: qa-passed - state: deprecated -# keywords: iot xyz - documentation: http://smarthomeng.de/user/plugins/backend/user_doc.html - support: https://knx-user-forum.de/forum/supportforen/smarthome-py/959964-support-thread-für-das-backend-plugin - - version: 1.5.15 # Plugin version - sh_minversion: 1.6 # minimum shNG version to use this plugin -# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) - multi_instance: False # plugin supports multi instance - restartable: False - classname: BackendServer # class containing the plugin - -parameters: - # Definition of parameters to be configured in etc/plugin.yaml - updates_allowed: - type: bool - default: True - description: - de: 'Update von Werten durch das Backend Plugin ist erlaubt' - en: 'Update of values through the backend plugin is allowed' - - developer_mode: - type: bool - default: False - description: - de: 'Entwickler Modus aktivieren' - en: 'Activate developer mode' - - pypi_timeout: - type: int - default: 5 - description: - de: 'Timeout bei der Abfrage der pypi Website' - en: 'Timeout for getting data from the pypi website' - -item_attributes: NONE - # Definition of item attributes defined by this plugin - -item_structs: NONE - # Item structures defined by this plugin - -plugin_functions: NONE -# Definition of plugin functions defined by this plugin - -logic_parameters: NONE -# Definition of logic parameters defined by this plugin - diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100755 index 372a9b84d..000000000 --- a/backend/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -python-dateutil>=2.5.3 diff --git a/backend/tests/cptestcase.py b/backend/tests/cptestcase.py deleted file mode 100755 index bdeb384eb..000000000 --- a/backend/tests/cptestcase.py +++ /dev/null @@ -1,102 +0,0 @@ -# -*- coding: utf-8 -*- -from io import BytesIO -import unittest -import urllib.request, urllib.parse, urllib.error - -import cherrypy - -# Not strictly speaking mandatory but just makes sense -cherrypy.config.update({'environment': "test_suite"}) - -# This is mandatory so that the HTTP server isn't started -# if you need to actually start (why would you?), simply -# subscribe it back. -cherrypy.server.unsubscribe() - -# simulate fake socket address... they are irrelevant in our context -local = cherrypy.lib.httputil.Host('127.0.0.1', 50000, "") -remote = cherrypy.lib.httputil.Host('127.0.0.1', 50001, "") - -class BaseCherryPyTestCase(unittest.TestCase): - def request(self, path='/', method='GET', app_path='', scheme='http', - proto='HTTP/1.1', data=None, headers=None, **kwargs): - """ - CherryPy does not have a facility for serverless unit testing. - However this recipe demonstrates a way of doing it by - calling its internal API to simulate an incoming request. - This will exercise the whole stack from there. - - Remember a couple of things: - - * CherryPy is multithreaded. The response you will get - from this method is a thread-data object attached to - the current thread. Unless you use many threads from - within a unit test, you can mostly forget - about the thread data aspect of the response. - - * Responses are dispatched to a mounted application's - page handler, if found. This is the reason why you - must indicate which app you are targetting with - this request by specifying its mount point. - - You can simulate various request settings by setting - the `headers` parameter to a dictionary of headers, - the request's `scheme` or `protocol`. - - .. seealso: http://docs.cherrypy.org/stable/refman/_cprequest.html#cherrypy._cprequest.Response - """ - # This is a required header when running HTTP/1.1 - h = {'Host': '127.0.0.1'} - - if headers is not None: - h.update(headers) - - # If we have a POST/PUT request but no data - # we urlencode the named arguments in **kwargs - # and set the content-type header - if method in ('POST', 'PUT') and not data: - data = urllib.parse.urlencode(kwargs) - kwargs = None - h['content-type'] = 'application/x-www-form-urlencoded' - - # If we did have named arguments, let's - # urlencode them and use them as a querystring - qs = None - if kwargs: - qs = urllib.parse.urlencode(kwargs) - - # if we had some data passed as the request entity - # let's make sure we have the content-length set - fd = None - if data is not None: - h['content-length'] = '%d' % len(data) - #fd = StringIO(data) - fd = BytesIO(data.encode()) - - # Get our application and run the request against it - app = cherrypy.tree.apps.get(app_path) - if not app: - # XXX: perhaps not the best exception to raise? - raise AssertionError("No application mounted at '%s'" % app_path) - - # Cleanup any previous returned response - # between calls to this method - app.release_serving() - - # Let's fake the local and remote addresses - request, response = app.get_serving(local, remote, scheme, proto) - try: - h = [(k, v) for k, v in h.items()] - response = request.run(method, path, qs, proto, h, fd) - finally: - if fd: - fd.close() - fd = None - - if response.output_status.startswith(b'500'): - print(response.body) - raise AssertionError("Unexpected error") - - # collapse the response into a bytestring - response.collapse_body() - return response diff --git a/backend/user_doc.rst b/backend/user_doc.rst deleted file mode 100755 index 96ca666ad..000000000 --- a/backend/user_doc.rst +++ /dev/null @@ -1,93 +0,0 @@ - -.. index:: Plugins; backend (Backend Administrationsoberfläche) -.. index:: backend -.. index:: Webinterfaces; Administrations GUI (Backend) - -backend -####### - -Seit SmartHomeNG v1.2 steht eine graphische Oberfläche zur Verfügung, die bei der Administration -von SmartHomeNG hilft. - -Diese Oberfläche wird durch das **Backend Plugin** zur Verfügung gestellt. Dazu implementiert -SmartHomeNG einen eigenen Webserver, der in der Standardkonfiguration auf **Port 8383** hört. - - -Das Backen Plugin liefert Informationen über die aktuelle SmartHomeNG Installation. Im Moment -dient es als Support-Tool, um anderen Benutzern bei einer Installation zu helfen, die nicht -ordnungsgemäß ausgeführt wird. - -Einige Höhepunkte: - -eine Liste der installierten Python-Module wird gegenüber den verfügbaren Versionen von PyPI angezeigt -eine Liste von Elementen und ihren Attributen wird angezeigt -eine Liste von Logiken und ihrer nächsten Ausführungszeit -eine Liste der aktuellen Scheduler und ihrer nächsten Ausführungszeit -Direkter Download der sqlite Datenbank (falls Plugin verwendet wird) und smarthome.log -Einige Informationen über häufig verwendete Daemons wie knxd / eibd sind enthalten -unterstützt die Standardauthentifizierung -mehrsprachige Unterstützung -Es gibt jedoch nur einen grundlegenden Schutz vor unbefugtem Zugriff oder Verwendung des Plugins. Seien Sie also vorsichtig, wenn Sie es mit Ihrem Netzwerk aktivieren. - - -Die GUI ermöglicht bereits einige Administrations Tasks direkt durchzuführen. Dazu gehören: - -- Setzen von Item Werten -- Erstellen, Änden und Löschen von Python Logiken -- Konfiguration von Lokigen -- Das Enablen und Disablen von Logiken - - -Die Administrations GUI wird durch folgenden Aufruf gestartet: - -.. code:: - - http://:8383 - -Ab SmartHomeNG v1.4 erreichen sie die Administrations GUI (je nach Konfiguration von SmartHomeNG) -nur über den Aufruf: - -.. code:: - - http://:8383/backend - - -Ein Beispiel Schirm mit allgemeinen Informationen über SmartHomeNG und das System auf dem -SmartHomeNG läuft: - -.. image:: user_doc/assets/backend_systeminfo.jpg - :class: screenshot - - - -Konfiguration -============= - -Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/backend` beschrieben. - - - -Beispiele ---------- - -Das Backend Plugin liefert zahlreiche Infroamtionen, die zum Beispiel bei der Fehlersuche -hilfreich sind. Inzwischen kann man damit auch eine Reihe von Administrationsaufgaben erledigen. - -.. image:: user_doc/assets/backend_systeminfo.jpg - :class: screenshot - - -Neues Adiministrations-Interface --------------------------------- - -Das neu ab SmartHomeNG v1.6 hinzukommende graphische Administrations-Interface ist unter :doc:`/admin/admin` beschrieben. - - - -.. toctree:: - :maxdepth: 4 - :hidden: - :titlesonly: - - user_doc/items - user_doc/logiken diff --git a/backend/user_doc/assets/backend_itemtree.jpg b/backend/user_doc/assets/backend_itemtree.jpg deleted file mode 100755 index a7f3e09bc..000000000 Binary files a/backend/user_doc/assets/backend_itemtree.jpg and /dev/null differ diff --git a/backend/user_doc/assets/backend_logik_editor.jpg b/backend/user_doc/assets/backend_logik_editor.jpg deleted file mode 100755 index 32c25dba1..000000000 Binary files a/backend/user_doc/assets/backend_logik_editor.jpg and /dev/null differ diff --git a/backend/user_doc/assets/backend_logikliste.jpg b/backend/user_doc/assets/backend_logikliste.jpg deleted file mode 100755 index 9eb2af830..000000000 Binary files a/backend/user_doc/assets/backend_logikliste.jpg and /dev/null differ diff --git a/backend/user_doc/assets/backend_systeminfo.jpg b/backend/user_doc/assets/backend_systeminfo.jpg deleted file mode 100755 index fd622a6df..000000000 Binary files a/backend/user_doc/assets/backend_systeminfo.jpg and /dev/null differ diff --git a/backend/user_doc/items.rst b/backend/user_doc/items.rst deleted file mode 100755 index 1bef9f65b..000000000 --- a/backend/user_doc/items.rst +++ /dev/null @@ -1,21 +0,0 @@ -.. index:: backend Plugin; Items - -##### -Items -##### - -Iin der graphischen Oberfläche steht eine Übersicht über alle definierten Items zur Verfügung. - - -.. index:: Item Tree - -Item Tree -========= - -Der Item Tree ermöglicht die Ansicht der Attribute aller Items. Außerdem ist es möglich den -Wert des jeweiligen Items zu setzen. - -.. image:: assets/backend_itemtree.jpg - :class: screenshot - - diff --git a/backend/user_doc/logiken.rst b/backend/user_doc/logiken.rst deleted file mode 100755 index f95230fd1..000000000 --- a/backend/user_doc/logiken.rst +++ /dev/null @@ -1,36 +0,0 @@ -.. index:: backend Plugin; Logiken - -####### -Logiken -####### - -Ab SmartHomeNG v1.4 stehen in der graphischen Oberfläche Funktionen zur Verfügung, mit der -Logiken vollständig verwaltet (erstellt, geändert und konfiguriert) werden können. - - -.. index:: Logik Liste - -Logik Liste -=========== - -Die Logik Liste zeigt die Übersicht aller Logiken mit Informationen zu ihrer Konfiguration an. - -.. image:: assets/backend_logikliste.jpg - :class: screenshot - -Aus dieser Liste heraus kann man die Logiken triggern, neu laden, aktivieren bzw. deaktivieren, -entladen und löschen. Ein Klick auf den Dateinamen führt in den Logik Editor. - - - -.. index:: Logik Editor - -Logik Editor -============ - -Im Logik Editor können die Konfigurationsparameter einer Logik angepasst werden und der Code -der Logik kann editiert werden. - -.. image:: assets/backend_logik_editor.jpg - :class: screenshot - diff --git a/backend/utils.py b/backend/utils.py deleted file mode 100755 index 6ccbcea3d..000000000 --- a/backend/utils.py +++ /dev/null @@ -1,334 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf8 -*- -######################################################################### -# Copyright 2016 Bernd Meiners, -# Christian Strassburg c.strassburg@gmx.de -# René Frieß rene.friess@gmail.com -# Martin Sinn m.sinn@gmx.de -######################################################################### -# Backend plugin for SmartHomeNG -# -# This plugin is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This plugin is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this plugin. If not, see . -######################################################################### - -import logging -import json -import os -import html -import collections -from collections import OrderedDict - -from lib.logic import Logics - - -translation_dict = {} -translation_dict_en = {} -translation_dict_de = {} -translation_lang = '' - - -logger = logging.getLogger(__name__) - - -def get_translation_lang(): - global translation_lang - return translation_lang - - -def load_translation_backuplanguages(): - global translation_dict_en # Needed to modify global copy of translation_dict - global translation_dict_de # Needed to modify global copy of translation_dict - - logger = logging.getLogger(__name__) - - lang_filename = os.path.dirname(os.path.abspath(__file__)) + '/locale/' + 'en' + '.json' - try: - f = open(lang_filename, 'r') - translation_dict_en = json.load(f) - except Exception as e: - translation_dict_en = {} - logger.error("load_translation language='{0}' failed: Error '{1}'".format('en', e)) - logger.debug("translation_dict_en='{0}'".format(translation_dict_en)) - - lang_filename = os.path.dirname(os.path.abspath(__file__)) + '/locale/' + 'de' + '.json' - try: - f = open(lang_filename, 'r') - translation_dict_de = json.load(f) - except Exception as e: - translation_dict_de = {} - logger.error("load_translation language='{0}' failed: Error '{1}'".format('de', e)) - logger.debug("translation_dict_de='{0}'".format(translation_dict_de)) - - return - - -def load_translation(language): - global translation_dict # Needed to modify global copy of translation_dict - global translation_lang # Needed to modify global copy of translation_lang - - logger = logging.getLogger(__name__) - - if translation_dict_en == {}: - load_translation_backuplanguages() - - translation_lang = language.lower() - if translation_lang == '': - translation_dict = {} - else: - lang_filename = os.path.dirname(os.path.abspath(__file__)) + '/locale/' + translation_lang + '.json' - try: - f = open(lang_filename, 'r') - except Exception as e: - translation_lang = '' - logger.error("load_translation language='{0}' failed: Error '{1}'".format(translation_lang, e)) - return False - try: - translation_dict = json.load(f) - except Exception as e: - logger.error("load_translation language='{0}': Error '{1}'".format(translation_lang, e)) - return False - logger.debug("translation_dict='{0}'".format(translation_dict)) - return True - - -def _get_translation_for_block(lang, txt, block): - """ - """ - if lang == 'en': - blockdict = translation_dict_en.get('_' + block, {}) - elif lang == 'de': - blockdict = translation_dict_de.get('_' + block, {}) - else: - blockdict = translation_dict.get('_' + block, {}) - - return blockdict.get(txt, '') - - -def _get_translation(txt, block): - """ - Get translation with fallback to english and further fallback to german - """ - logger = logging.getLogger(__name__) - - if block != '': - tr = _get_translation_for_block('', txt, block) - if tr == '': - logger.info("Language '{0}': Translation for '{1}' is missing!".format(translation_lang, txt)) - tr = _get_translation_for_block('en', txt, block) - if tr == '': - tr = _get_translation_for_block('de', txt, block) - else: - tr = translation_dict.get(txt, '') - if tr == '': - logger.info("Language '{0}': Translation for '{1}' is missing".format(translation_lang, txt)) - tr = translation_dict_en.get(txt, '') - if tr == '': - logger.info("Language '{0}': Translation for '{1}' is missing".format('en', txt)) - tr = translation_dict_de.get(txt, '') - return tr - - -def translate(txt, block=''): - """ - returns translated text - - This function extends the jinja2 template engine - """ - logger = logging.getLogger(__name__) - - txt = str(txt) - if translation_lang == '': - tr = txt - else: - tr = _get_translation(txt, block) - - if tr == '': - logger.info("translate: -> Language '{0}': Translation for '{1}' is missing".format(translation_lang, txt)) - tr = txt - return html.escape(tr) - - -def create_hash(plaintext): - import hashlib - hashfunc = hashlib.sha512() - hashfunc.update(plaintext.encode()) - return hashfunc.hexdigest() - - -def parse_requirements(file_path): - req_dict = {} - try: - fobj = open(file_path) - except: - return req_dict - - for rline in fobj: - line = '' - if len(rline) > 0: - if rline.find('#') == -1: - line = rline.lower().strip() - else: - line = line[0:line.find("#")].lower().strip() - - if len(line) > 0: - if ">" in line: - if line[0:line.find(">")].lower().strip() in req_dict: - req_dict[line[0:line.find(">")].lower().strip()] += " | " + line[line.find(">"):len( - line)].lower().strip() - else: - req_dict[line[0:line.find(">")].lower().strip()] = line[line.find(">"):len(line)].lower().strip() - elif "<" in line: - if line[0:line.find("<")].lower().strip() in req_dict: - req_dict[line[0:line.find("<")].lower().strip()] += " | " + line[line.find("<"):len( - line)].lower().strip() - else: - req_dict[line[0:line.find("<")].lower().strip()] = line[line.find("<"):len(line)].lower().strip() - elif "=" in line: - if line[0:line.find("=")].lower().strip() in req_dict: - req_dict[line[0:line.find("=")].lower().strip()] += " | " + line[line.find("="):len( - line)].lower().strip() - else: - req_dict[line[0:line.find("=")].lower().strip()] = line[line.find("="):len(line)].lower().strip() - else: - req_dict[line.lower().strip()] = '==*' - - fobj.close() - return req_dict - - -def get_process_info(command, wait=True): - """ - returns output from executing a given command via the shell. - """ - ## get subprocess module - import subprocess - - ## call date command ## - p = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True) - - # Talk with date command i.e. read data from stdout and stderr. Store this info in tuple ## - # Interact with process: Send data to stdin. Read data from stdout and stderr, until end-of-file is reached. - # Wait for process to terminate. The optional input argument should be a string to be sent to the child process, or None, if no data should be sent to the child. - (result, err) = p.communicate() -# logger.warning("get_process_info: command='{}', result='{}', err='{}'".format(command, result, err)) - - if wait: - ## Wait for date to terminate. Get return returncode ## - p_status = p.wait() - return str(result, encoding='utf-8', errors='strict') - - -def os_with_systemd(): - """ - Returns True, if running systemd on the computer - - :return: - """ - result = get_process_info("systemctl --version") - return (result != '') - - -def os_with_sysvinit(): - """ - Returns True, if running SysVinit on the computer - - :return: - """ - return os.path.isfile('/usr/sbin/service') - - -def os_service_controllable(): - """ - Test if services are contollable by backend - - :return: True, if service is controllable - """ - return (os_with_systemd() or os_with_sysvinit()) - - -def os_service_status(servicename): - """ - Returns if the specified service is active (running) - - :param servicename: str - :return: bool - """ - result_b = False - if os_with_systemd(): - result = get_process_info("systemctl status {}".format(servicename)) - if result.find('Active: inactive') != -1: - result_b = False - elif result.find('Active: active') != -1: - result_b = True - else: - logger.warning("os_service_status (systemd): Cannot determine status of service (result='{}')".format(result)) - elif os_with_sysvinit(): - result = get_process_info("/usr/sbin/service {} status".format(servicename)) - if result.find('FAIL') != -1: - result_b = False - elif result.find(' ok ') != -1: - result_b = True - else: - logger.warning("os_service_status (SysVInit): Cannot determine status of service (result='{}')".format(result)) - else: - result = "os_service_status: Cannot determine status of service" - result_b = False - logger.warning("os_service_status: Cannot determine status of service") -# logger.warning("os_service_status: result = '{}' -> {}".format(result, result_b)) - return result_b - -def os_service_restart(servicename): - """ - Restart a service - - :param servicename: - :return: - """ - logger.warning("os_service_restart: Restarting SmartHomeNG") - if os_with_systemd(): - result = get_process_info("sudo systemctl restart {}.service".format(servicename), wait=False) - elif os_with_sysvinit(): - result = get_process_info("sudo service {} restart".format(servicename), wait=False) - else: - logger.warning("os_service_restart: Cannot restart service") - - -def os_restart_shng(pid): - """ - Restart a service - - :param pid: - """ - result = get_process_info("ps -f -p {}".format(pid)) - cmdpos = result.find('CMD') - result = result[result.find('\n')+1:] - cmd = result[cmdpos:] - cmd = cmd[:cmd.find('.py')+3] - logger.warning("os_service_shng: ps -f -p {} -> '{}'".format(pid, cmd)) - - logger.warning("os_service_shng: 1. Restart -> '{} -r'".format(cmd)) - result = get_process_info("{} -r".format(cmd), wait=False) - logger.warning("os_service_shng: 2. Restart -> '{} -r'".format(cmd)) - - -# PIDFILE in smarthome.py als global definiert -# self.get_sh()._pidfile -# lib.daemon.read_pidfile(PIDFILE) -# -# lib.daemon.read_pidfile(self.get_sh()._pidfile) -# -# > ps -f -p 14266 -# UID PID PPID C STIME TTY TIME CMD -# smartho+ 14266 1 3 Mai17 ? 00:50:30 python bin/smarthome.py -r -# diff --git a/backend/webif/static/img/cache.svg b/backend/webif/static/img/cache.svg deleted file mode 100755 index 6c82d0dca..000000000 --- a/backend/webif/static/img/cache.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - image/svg+xml - - - - - Openclipart - - - - - - - - - - - \ No newline at end of file diff --git a/backend/webif/static/img/clock.svg b/backend/webif/static/img/clock.svg deleted file mode 100755 index b9a1cc8b3..000000000 --- a/backend/webif/static/img/clock.svg +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - Openclipart - - - - 2009-07-05T21:07:07 - - https://openclipart.org/detail/27002/analog-clock-by-sivvus-27002 - - - sivvus - - - - - analog - clock - photorealistic - time - - - - - - - - - - - \ No newline at end of file diff --git a/backend/webif/static/img/db_backup.svg b/backend/webif/static/img/db_backup.svg deleted file mode 100755 index 94353b1fc..000000000 --- a/backend/webif/static/img/db_backup.svg +++ /dev/null @@ -1,424 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/backend/webif/static/img/eval.svg b/backend/webif/static/img/eval.svg deleted file mode 100755 index 440fe5b98..000000000 --- a/backend/webif/static/img/eval.svg +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - - - - - - - - image/svg+xml - - - - - Openclipart - - - - - - - - - - - diff --git a/backend/webif/static/img/hd.svg b/backend/webif/static/img/hd.svg deleted file mode 100755 index 13568a687..000000000 --- a/backend/webif/static/img/hd.svg +++ /dev/null @@ -1,8781 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/backend/webif/static/img/html.svg b/backend/webif/static/img/html.svg deleted file mode 100755 index bd7a5c665..000000000 --- a/backend/webif/static/img/html.svg +++ /dev/null @@ -1,525 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - Openclipart - - - - - - - - - - - - diff --git a/backend/webif/static/img/knxd.svg b/backend/webif/static/img/knxd.svg deleted file mode 100755 index 21c93193c..000000000 --- a/backend/webif/static/img/knxd.svg +++ /dev/null @@ -1,1246 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - KNXD - - diff --git a/backend/webif/static/img/knxd_service.png b/backend/webif/static/img/knxd_service.png deleted file mode 100755 index 3a4d78dd3..000000000 Binary files a/backend/webif/static/img/knxd_service.png and /dev/null differ diff --git a/backend/webif/static/img/knxd_socket.png b/backend/webif/static/img/knxd_socket.png deleted file mode 100755 index 4668bd17c..000000000 Binary files a/backend/webif/static/img/knxd_socket.png and /dev/null differ diff --git a/backend/webif/static/img/languages.svg b/backend/webif/static/img/languages.svg deleted file mode 100755 index 8bd14a1ce..000000000 --- a/backend/webif/static/img/languages.svg +++ /dev/null @@ -1,199 +0,0 @@ - - - - - - - - - - - - - - - - - Ciao Modo - - - - مرحبا العالم! - - - Hallo Welt! - - - Hej Värld! - - - Hello World! - - - 世界您好! - - - Salut le Monde! - - - ハローワールド! - - - ¡HOlá mundo! - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - Openclipart - - - Hello, World In Several Languages - 2012-08-21T16:08:02 - "Hello, World!" in several languages. The text is converted to paths in the main portion, but is available in text format off-screen. - https://openclipart.org/detail/171842/hello-world-in-several-languages-by-jobrad-171842 - - - JoBrad - - - - - Hello - global - greeting - international - language - welcome - world - - - - - - - - - - - \ No newline at end of file diff --git a/backend/webif/static/img/logfile.svg b/backend/webif/static/img/logfile.svg deleted file mode 100755 index 6466aa2f1..000000000 --- a/backend/webif/static/img/logfile.svg +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/backend/webif/static/img/password.svg b/backend/webif/static/img/password.svg deleted file mode 100755 index 1bbe62e84..000000000 --- a/backend/webif/static/img/password.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/backend/webif/static/img/preferences.svg b/backend/webif/static/img/preferences.svg deleted file mode 100755 index abaa35ae2..000000000 --- a/backend/webif/static/img/preferences.svg +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/backend/webif/static/img/python.png b/backend/webif/static/img/python.png deleted file mode 100755 index 461284bc0..000000000 Binary files a/backend/webif/static/img/python.png and /dev/null differ diff --git a/backend/webif/static/img/reboot.svg b/backend/webif/static/img/reboot.svg deleted file mode 100755 index 89510c7ea..000000000 --- a/backend/webif/static/img/reboot.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/backend/webif/static/img/terminal-server.svg b/backend/webif/static/img/terminal-server.svg deleted file mode 100755 index 852bc9e7a..000000000 --- a/backend/webif/static/img/terminal-server.svg +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -image/svg+xmlOpenclipartTerminal Server2013-08-30T09:28:44This clipart ist based on lyte's server clipart representing a terminal server.https://openclipart.org/detail/182737/terminal-server-by-ujmoser-182737ujmoserltsprdpserverterminalservervnc \ No newline at end of file diff --git a/backend/webif/static/img/tree.png b/backend/webif/static/img/tree.png deleted file mode 100755 index c35729217..000000000 Binary files a/backend/webif/static/img/tree.png and /dev/null differ diff --git a/backend/webif/static/img/tux_hdd.svg b/backend/webif/static/img/tux_hdd.svg deleted file mode 100755 index 7803418e2..000000000 --- a/backend/webif/static/img/tux_hdd.svg +++ /dev/null @@ -1,7661 +0,0 @@ - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/backend/webif/static/img/user.svg b/backend/webif/static/img/user.svg deleted file mode 100755 index cdae10f80..000000000 --- a/backend/webif/static/img/user.svg +++ /dev/null @@ -1,1300 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - Hombre - - - - diff --git a/backend/webif/static/js/backend.js b/backend/webif/static/js/backend.js deleted file mode 100755 index a100c074e..000000000 --- a/backend/webif/static/js/backend.js +++ /dev/null @@ -1,38 +0,0 @@ -function switchLineWrapping(codeMirrorInstance) { - codeMirrorInstance.setOption('lineWrapping', !codeMirrorInstance.getOption('lineWrapping')); - if (codeMirrorInstance.getOption('lineWrapping')) { - $('#linewrapping').addClass('active'); - } else { - $('#linewrapping').removeClass('active'); - } -}; - -function triggerRestart() { - /* get Request to start restart of shng */ - // ... - var waitInMS = 2000; - setTimeout(function() { - checkBackendAvailability(); - }, waitInMS); -} - -function checkBackendAvailability() { - var reload = false; - - while (!reload) { - $.ajax({ - 'url': "/backend/system.html", - 'async': false, - 'type': "GET", - 'global': false, - 'dataType': 'html', - 'success': function (data) { - if (data.includes("SmartHomeNG")) { - reload = true; - } - } - }); - } - window.location.href = "/backend/system.html"; -} - diff --git a/backend/webif/static/js/item_tree_functions.js b/backend/webif/static/js/item_tree_functions.js deleted file mode 100755 index 145808b64..000000000 --- a/backend/webif/static/js/item_tree_functions.js +++ /dev/null @@ -1,130 +0,0 @@ -window.addEventListener("resize", resizeItemTree, false); - -function resizeItemTree() { - var browserHeight = $( window ).height(); - offsetTop = $('#tree').offset().top; - offsetTopDetail = $('#tree_detail').offset().top; - $('#tree').css("maxHeight", ((-1)*(offsetTop) - 35 + browserHeight)+ 'px'); - $('#tree_detail').css("maxHeight", ((-1)*(offsetTopDetail) - 35 + browserHeight)+ 'px'); -} -resizeItemTree(); - -function build_item_subtree_recursive(data) { - var result = []; - var tree_element = {}; - tree_element['text'] = data.path; - var node_length = data.nodes.length; - if (data.nodes.length > 0) { - var child_nodes = []; - for (var i = 0; i < node_length; i++) { - var new_child_node = build_item_subtree_recursive(data.nodes[i]); - child_nodes.push(new_child_node); - } - tree_element['tags'] = data.tags; - tree_element['nodes'] = child_nodes; - } - - return tree_element; -} - -function reload(item_path) { - if (item_path) { - $('#refresh-element').addClass('fa-spin'); - $('#reload-element').addClass('fa-spin'); - $('#cardOverlay').show(); - $.getJSON('item_detail_json.html?item_path='+item_path, function(result) { - getDetailInfo(result); - window.setTimeout(function(){ - $('#refresh-element').removeClass('fa-spin'); - $('#reload-element').removeClass('fa-spin'); - $('#cardOverlay').hide(); - }, 300); - - }); - } -} - -var selectedNode; - -function getTree() { - var item_tree = []; - - $.getJSON('items.json?mode=tree', function(result) { - $.each(result, function(index, element) { - item_tree.push(build_item_subtree_recursive(element)); - }); - - $('#tree').treeview({ - data: item_tree, - levels: 1, - expandIcon: 'plusIcon', - collapseIcon: 'minusIcon', - selectedBackColor: '#709cc2', - showTags: true, - onNodeSelected: function(event, node) { - selectedNode = node; - reload(node.text); - } - }); - - function clearSearch() { - $('#btn-clear-search').on('click', function (e) { - $('#tree').treeview('clearSearch'); - $('#tree').treeview('collapseAll', { silent: false }); - $('#input-search').val(''); - $('#search-output').html(''); - results = []; - $('#search-results').html(''); - }); - }; - - var search = function(e) { - results = []; - var pattern = $('#input-search').val(); - var options = { - ignoreCase: true, - exactMatch: false, - revealResults: true - }; - var results = $('#tree').treeview('search', [ pattern, options ]); - if ($('#input-search').val() != "") { - $('#search-results').html(' - Treffer: '+results.length); - } - clearSearch(); - } - - var searchExact = function(e) { - results = []; - var pattern = $('#input-search').val(); - var options = { - ignoreCase: true, - exactMatch: true, - revealResults: true - }; - var results = $('#tree').treeview('search', [ pattern, options ]); - if ($('#input-search').val() != "") { - $('#search-results').html(' - Treffer: '+results.length); - } - clearSearch(); - } - - $('#btn-search').on('click', search); - $("#input-search").keypress(function(event){ - if(event.keyCode == 13){ - $("#btn-search").click(); - } - }); - - // Expand/collapse all - $('#btn-expand-all').on('click', function (e) { - $('#tree').treeview('expandAll', { silent: false }); - }); - $('#btn-collapse-all').on('click', function (e) { - $('#tree').treeview('collapseAll', { silent: false }); - }); - - if ($("#input-search").val() != "") { - searchExact(); - } - }); -} \ No newline at end of file diff --git a/backend/webif/static/js/logics_view_functions.js b/backend/webif/static/js/logics_view_functions.js deleted file mode 100755 index d7ed58267..000000000 --- a/backend/webif/static/js/logics_view_functions.js +++ /dev/null @@ -1,120 +0,0 @@ -$('#help').click(function(e) { - $('#help_text').toggle(); - resizeCodeMirror() -}); -$('#linewrapping').click(function(e) { - switchLineWrapping(logicsCodeMirror) -}); -$('#rulers').click(function(e) { - switchRulers(); -}); - -window.addEventListener("resize", function(){resizeCodeMirror(logicsCodeMirror, 95)}, false); -resizeCodeMirror(logicsCodeMirror, 95); - -var dict = []; -var watch_items_dict = []; -function getItemDictionary() { - $.getJSON('items.json?mode=list', function(result) { - for (i = 0; i < result.length; i++) { - dict.push({ text: "sh."+result[i]+"()", displayText: "sh."+result[i]+"() | Item" }); - watch_items_dict.push({ text: result[i], displayText: result[i] }); - watch_items_dict.push({ text: result[i], displayText: "sh."+result[i] }); - } - }); -} -function getPluginDictionary() { - $.getJSON('plugins.json', function(result) { - for (i = 0; i < result.length; i++) { - dict.push({ text: "sh."+result[i], displayText: "sh."+result[i]+" | Plugin"}); - } - }); -} -getItemDictionary(); -getPluginDictionary(); - -function registerAutocompleteHelper(name, curDict) { - CodeMirror.registerHelper('hint', name, function(editor) { - var cur = editor.getCursor(), - curLine = editor.getLine(cur.line); - var start = cur.ch, - end = start; - - var charexp = /[\w\.$]+/; - while (end < curLine.length && charexp.test(curLine.charAt(end))) ++end; - while (start && charexp.test(curLine.charAt(start - 1))) --start; - var curWord = start != end && curLine.slice(start, end); - if (curWord.length > 1) { - curWord = curWord.trim(); - } - var regex = new RegExp('^' + curWord, 'i'); - - if (curWord.length >= 3) { - var oCompletions = { - list: (!curWord ? [] : curDict.filter(function (item) { - return item['displayText'].match(regex); - })).sort(function(a, b){ - var nameA=a.text.toLowerCase(), nameB=b.text.toLowerCase() - if (nameA < nameB) //sort string ascending - return -1 - if (nameA > nameB) - return 1 - return 0 //default return value (no sorting) - }), - from: CodeMirror.Pos(cur.line, start), - to: CodeMirror.Pos(cur.line, end) - }; - - return oCompletions; - } - }); -} - -registerAutocompleteHelper('autocompleteHint', dict); -registerAutocompleteHelper('autocompleteWatchItemsHint', watch_items_dict); - - -CodeMirror.commands.autocomplete_shng = function(cm) { - CodeMirror.showHint(cm, CodeMirror.hint.autocompleteHint); -}; - -CodeMirror.commands.autocomplete_shng_watch_items = function(cm) { - CodeMirror.showHint(cm, CodeMirror.hint.autocompleteWatchItemsHint); -}; - -function switchRulers() { - - if (logicsCodeMirror.getOption('rulers').length == 0) { - $('#rulers').addClass('active'); - logicsCodeMirror.setOption('rulers', rulers); - } else { - $('#rulers').removeClass('active'); - logicsCodeMirror.setOption('rulers', []); - } -}; - -function checkChangedContent() { - if ($('#original_content').val() != logicsCodeMirror.getValue() || ($('#original_cycle').val() != $('#cycle').val()) || ($('#original_crontab').val() != $('#crontab').val()) || ($('#original_watch').val() != $('#watch').val())) { - return true; - } else { - return false; - } -}; - -function markChangedContent() { - $('#savereloadtrigger').removeClass('btn-shng-success'); - $('#savereload').removeClass('btn-shng-success'); - $('#save').removeClass('btn-shng-success'); - $('#savereloadtrigger').addClass('btn-shng-danger'); - $('#savereload').addClass('btn-shng-danger'); - $('#save').addClass('btn-shng-danger'); -} - -function markIdenticalContent() { - $('#savereloadtrigger').removeClass('btn-shng-danger'); - $('#savereload').removeClass('btn-shng-danger'); - $('#save').removeClass('btn-shng-danger'); - $('#savereloadtrigger').addClass('btn-shng-success'); - $('#savereload').addClass('btn-shng-success'); - $('#save').addClass('btn-shng-success'); -} diff --git a/backend/webif/templates/conf_yaml_converter.html b/backend/webif/templates/conf_yaml_converter.html deleted file mode 100755 index cf087b5ba..000000000 --- a/backend/webif/templates/conf_yaml_converter.html +++ /dev/null @@ -1,59 +0,0 @@ - -{% extends "base.html" %} -{% import "navbar.html" as nav with context %} -{% block navbar %} - {{ nav }} -{% endblock navbar %} - -{% block title %} -{{ _('Dienste', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} - -
-
-
-
{{ _('Eingabe im .CONF Format') }}
- -
-
-
{{ _('Ergebnis im .YAML Format') }}
- -
-
-
- -
-
- -{% endblock %} diff --git a/backend/webif/templates/disclosure.html b/backend/webif/templates/disclosure.html deleted file mode 100755 index e094efc13..000000000 --- a/backend/webif/templates/disclosure.html +++ /dev/null @@ -1,210 +0,0 @@ - -{% extends "base.html" %} -{% block navbar %} - {% with active_page="disclosure" %} - {% include "navbar.html" %} - {% endwith %} -{% endblock navbar %} - -{% block title %} -{{ _('Disclosure', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} - -
-
-
-
{{ _('Verwendete Open Source Software Komponenten', 'disclosure') }}
- - - - - - - - - - - - - - - - - -
{{ _('Name', 'disclosure') }}{{ _('Lizenz', 'disclosure') }}{{ _('Link', 'disclosure') }}
Bootstrap v4.1.2MIT Licensehttp://getbootstrap.com/
Bootstrap Tree ViewApache License, Version 2.0https://github.com/jonmiles/bootstrap-treeview
Bootstrap Datepicker v1.8.0Apache License, Version 2.0https://github.com/uxsolutions/bootstrap-datepicker
CodeMirror 5.39.0MIT Licensehttps://codemirror.net/
jQuery 3.3.1MIT Licensehttps://jquery.org
Font Awesome 5.1.0 (Free)Font Awesome licensed under SIL OFL 1.1
Font Awesome CSS, LESS, and Sass files are licensed under the MIT License
Font Awesome icons (all icons packaged as .svg and .js files) are licensed under the CC BY 4.0 License
http://fontawesome.com
{{ _('Icons/Bilder', 'disclosure') }}{{ _('Alle Icons und Bilder kommen von', 'disclosure') }} https://openclipart.orghttps://openclipart.org
-
-
-
-
-
-
-
-
Bootstrap v4.1.2:
-

- MIT License
-
- Copyright (c) 2011-2016 Twitter, Inc.
-
- 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.
-

-
-
-
-
-
-
Bootstrap Tree View:
-

- Copyright 2013 Jonathan Miles
-
- Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
-

-
-
-
-
-
-
Bootstrap Datepicker:
-

Licensed under the Apache License v2.0 (http://www.apache.org/licenses/LICENSE-2.0) -

-
-
-
-
-
-
CodeMirror 5.39.0:
-

- Copyright (C) 2017 by Marijn Haverbeke and others
-
- 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.
-

-
-
-
-
-
-
Font Awesome 5.1.0 (Free)
-

- Font Awesome Free is free, open source, and GPL friendly. You can use it for - commercial projects, open source projects, or really almost whatever you want. - Full Font Awesome Free license: https://fontawesome.com/license. -

- # Icons:
- CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/) - In the Font Awesome Free download, the CC BY 4.0 license applies to all icons - packaged as SVG and JS file types. -

- # Fonts:
- SIL OFL 1.1 License (https://scripts.sil.org/OFL) - In the Font Awesome Free download, the SIL OLF license applies to all icons - packaged as web and desktop font files. -

- # Code:
- MIT License (https://opensource.org/licenses/MIT) - In the Font Awesome Free download, the MIT license applies to all non-font and - non-icon files. -

- # Attribution
- Attribution is required by MIT, SIL OLF, and CC BY licenses. Downloaded Font - Awesome Free files already contain embedded comments with sufficient - attribution, so you shouldn't need to do anything additional when using these - files normally. -

- We've kept attribution comments terse, so we ask that you do not actively work - to remove them from files, especially code. They're a great way for folks to - learn about Font Awesome. -

- # Brand Icons
- All brand icons are trademarks of their respective owners. The use of these - trademarks does not indicate endorsement of the trademark holder by Font - Awesome, nor vice versa. **Please do not use brand logos for any purpose except - to represent the company, product, or service to which they refer.** -

-
-
-
-
-
-
JQuery 3.3.1:
-

- MIT License
-
- Copyright jQuery Foundation and other contributors, https://jquery.org/
-
- This software consists of voluntary contributions made by many
- individuals. For exact contribution history, see the revision history
- available at https://github.com/jquery/jquery
-
- The following license applies to all parts of this software except as
- documented below:
-
-
-
- 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.
-
-
-
- All files located in the node_modules and external directories are
- externally maintained libraries used by this software which have their
- own licenses; we recommend you read them, as their terms may differ from
- the terms above.

-

-
-
-
-
-{% endblock %} diff --git a/backend/webif/templates/eval_syntax_checker.html b/backend/webif/templates/eval_syntax_checker.html deleted file mode 100755 index 42a385530..000000000 --- a/backend/webif/templates/eval_syntax_checker.html +++ /dev/null @@ -1,62 +0,0 @@ - -{% extends "base.html" %} -{% import "navbar.html" as nav with context %} -{% block navbar %} - {{ nav }} -{% endblock navbar %} - -{% block title %} -{{ _('Dienste', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} - -
-
-
-
-
{{ _('Ausdruck (Eingabe im Python Eval-Format)') }}
- -
-
-
{{ _('Relativ zu (Eingabe des Item-Path)') }}
- -
- -
-
 
-
{{ _('Expandierter Ausdruck') }}
-
{{ expanded_code }} 
-
-
-
 
-
{{ _('Ergebnis') }}
-
{{ check_result }} 
-
-
-
-
-
- -
-
- -{% endblock %} diff --git a/backend/webif/templates/index.html b/backend/webif/templates/index.html deleted file mode 100755 index 18a38f09f..000000000 --- a/backend/webif/templates/index.html +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/backend/webif/templates/items.html b/backend/webif/templates/items.html deleted file mode 100755 index 4801b3f61..000000000 --- a/backend/webif/templates/items.html +++ /dev/null @@ -1,200 +0,0 @@ - -{% extends "base.html" %} -{% import "navbar.html" as nav with context %} -{% block navbar %} - {% with active_page="items" %} - {% include "navbar.html" %} - {% endwith %} -{% endblock navbar %} - -{% block title %} -{{ _('Items', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} - - - - -{% endblock %} diff --git a/backend/webif/templates/log_view.html b/backend/webif/templates/log_view.html deleted file mode 100755 index c35eb0d81..000000000 --- a/backend/webif/templates/log_view.html +++ /dev/null @@ -1,89 +0,0 @@ - -{% extends "base.html" %} -{% import "navbar.html" as nav with context %} -{% block navbar %} - {{ nav }} -{% endblock navbar %} - -{% block title %} -{{ _('Logging', 'menu') }} - SmartHomeNG -{% endblock title %} - - - -{% block content %} - -
-
-
{{ _('Filter') }}:
- - -
- -   - - -
-
-
-
-{% if log_lines %}{% else %}{{ _('no data available') }}{% endif %} -
-
-
- - - - - - - - - -
- - - - - ({{ current_page }} / {{ pages }}) - - - - - - -
-
- -{% endblock %} diff --git a/backend/webif/templates/logging.html b/backend/webif/templates/logging.html deleted file mode 100755 index afd23bcb5..000000000 --- a/backend/webif/templates/logging.html +++ /dev/null @@ -1,62 +0,0 @@ - -{% extends "base.html" %} -{% block navbar %} - {% with active_page="logging" %} - {% include "navbar.html" %} - {% endwith %} -{% endblock navbar %} - -{% block title %} -{{ _('Logging', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} -
-
- - - - - - - - - - - - - - {% for l in loggers %} - - {% if l.name == 'root' %} - - - - {% else %} - - - - {% endif %} - - - - - {% endfor %} - - -
{{ _('logger name') }}{{ _('disabled') }}{{ _('level') }}{{ _('filters') }}{{ _('handlers') }}{{ _('logfiles') }}
{{ l.name }}{{ l.disabled }}{{ l.level }}{{ l.name }}{{ l.disabled }}{{ l.level }}{% for f in l.filters %} - {% if (f != '') and (not loop.first) %}, {% endif %} - {{ f }} - {% endfor %} - {% for h in l.handlers %} - {% if (h != '') and (not loop.first) %}, {% endif %} - {{ h }} - {% endfor %} - {% for fn in l.filenames %} - {% if (fn != '') and (not loop.first) %}, {% endif %} - {{ fn }} - {% endfor %} -
-
-
-{% endblock %} \ No newline at end of file diff --git a/backend/webif/templates/logics.html b/backend/webif/templates/logics.html deleted file mode 100755 index a8a091c3d..000000000 --- a/backend/webif/templates/logics.html +++ /dev/null @@ -1,228 +0,0 @@ - -{% extends "base.html" %} -{% block navbar %} - {% with active_page="logics" %} - {% include "navbar.html" %} - {% endwith %} -{% endblock navbar %} - -{% block title %} -{{ _('Logiken', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} - - - -
-
-
- - - - - - - - - - - {% if updates %} - - {% endif %} - - - - {% if smarthome %} - {% for logic in logics %} - {% if logic.userlogic %} - - - - - - - - - {% if logic.logictype == 'Blockly' and blockly_loaded %} - - {% else %} - - {% endif %} - {% if updates and logic.userlogic %} - - {% else %} - - {% endif %} - - - - - - {% endif %} - {% endfor %} - {% if newlogics|length > 0 %} - - {% for logic in newlogics %} - - - - - - - - - - {% if logic.logictype == 'Blockly' and blockly_loaded %} - - {% else %} - - {% endif %} - {% if updates %} - - {% else %} - - {% endif %} - - - {% endfor %} - {% endif %} - {% else %} - - - - {% endif %} - -
{{ _('Logik') }}{{ _('nächste Ausführung') }}{{ _('Cycle') }}{{ _('Crontab') }}{{ _('Watch_Items') }}{{ _('Dateiname') }}{{ _('Aktionen') }}       -
- - {% if not logic.enabled %}{% endif %}{{ logic.next_exec }}{% if not logic.enabled %}{% endif %}{% if not logic.enabled %}{% endif %} - {% if logic.cycle == None %}-{% else %}{{ logic.cycle}} {% endif %}{% if not logic.enabled %}{% endif %} - {% if not logic.enabled %}{% endif %} - {% if logic.crontab == None %}-{% else %}{{ logic.crontab}} {% endif %}{% if not logic.enabled %}{% endif %} - 0 %}onClick="$('#{{ logic.name }}_additional3').toggle();" style="cursor: pointer;"{% endif %}> - {% if logic.watch_item_list|length == 0 %}-{% else %}{% if not logic.enabled %}{% endif %}{{ logic.watch_item_list|length}}{% if not logic.enabled %}{% endif %} {% endif %} - {{ get_basename(logic.pathname) }}{{ get_basename(logic.pathname) }} - - - {% if logic.enabled %} - - {% else %} - - {% endif %} - {% if 1 == 1 %} - - - {% endif %} -
{{ _('Neue Logiken (nicht geladen)') }}
- - {{ get_basename(logic.pathname) }}{{ get_basename(logic.filename) }} - {% if logic.filename != '' %} - - {% endif %} - -
{{ _('no data available') }}
-
-
- -
-
- - - - - - - - - - - {% if updates %} - - {% endif %} - - - - {% if smarthome %} - {% for logic in logics %} - {% if not logic.userlogic %} - - - - - {% if smarthome.scheduler.return_next(logic.name) %} - - {% else %} - - {% endif %} - - - - - - - - - - - {% endif %} - {% endfor %} - {% else %} - - - - {% endif %} - -
{{ _('Logik') }}{{ _('nächste Ausführung') }}{{ _('Cycle') }}{{ _('Crontab') }}{{ _('Watch_Items') }}{{ _('Dateiname') }}{{ _('Aktionen') }}
- {% if not logic.enabled %}{% endif %}{{ smarthome.scheduler.return_next(logic.name).strftime('%Y-%m-%d %H:%M:%S%z') }}{% if not logic.enabled %}{% endif %} - {% if logic.cycle == None %}-{% else %}{{ logic.cycle}} {% endif %} - - {% if logic.crontab == None %}-{% else %}{{ logic.crontab}} {% endif %} - 0 %}onClick="$('#{{ logic.name }}_additional3').toggle();" style="cursor: pointer;"{% endif %}> - {% if logic.watch_item_list|length == 0 %}-{% else %}{% if not logic.enabled %}{% endif %}{{ logic.watch_item_list|length}}{% if not logic.enabled %}{% endif %} {% endif %} - {{ get_basename(logic.pathname) }}
{{ _('no data available') }}
-
-
-
-{% endblock %} diff --git a/backend/webif/templates/logics_new.html b/backend/webif/templates/logics_new.html deleted file mode 100755 index 6042ef242..000000000 --- a/backend/webif/templates/logics_new.html +++ /dev/null @@ -1,49 +0,0 @@ - -{% extends "base.html" %} -{% import "navbar.html" as nav with context %} -{% block navbar %} - {{ nav }} -{% endblock navbar %} - -{% block title %} -{{ _('Logiken', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} - - -
- {% if updates and yaml_updates %} -
- {% endif %} -
- {% if updates and yaml_updates %} - - {% endif %} -
- - - - - - - - - - - - - -
{{ _('Angaben für die Erzeugung einer neuen Python Logik') }}{{ message }}
    - {{ _('Dateiname') }} - {{ _("Dateiname des Python Codes der Logik (ohne Extension '.py')") }}
    - {{ _('Logikname') }} - {{ _('Logik-Name/Abschnittsnamen in /etc/logic.yaml) - Wenn leer, wird der Dateiname verwendet') }}
- {% if updates and yaml_updates %} -
- {% endif %} -
-{% endblock %} - diff --git a/backend/webif/templates/logics_view.html b/backend/webif/templates/logics_view.html deleted file mode 100755 index 84850022d..000000000 --- a/backend/webif/templates/logics_view.html +++ /dev/null @@ -1,317 +0,0 @@ - -{% extends "base.html" %} -{% import "navbar.html" as nav with context %} -{% block navbar %} - {{ nav }} -{% endblock navbar %} - -{% block title %} -{{ _('Logiken', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} - - -
-
- {% if updates %} -
- - -   - {% if thislogic.enabled %} - - {% else %} - - {% endif %} -  | |  -   - - - | |  -   -   - - {% endif %} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ _('Status') }}: - {% if not thislogic.enabled %}{{ _('Nicht aktiv') }}{% else %}{{ _('Aktiv') }}{% endif %} -
{{ _('letzte Ausführung') }}{% if not thislogic.enabled %}{% endif %} - {{ thislogic.last_run }} - {% if not thislogic.enabled %}{% endif %} - {{ _('nächste Ausführung') }}{% if not thislogic.enabled %}{% endif %} - {{ thislogic.next_exec }} - {% if not thislogic.enabled %}{% endif %} -
{{ _('Cycle') }} - - -
{{ _('Crontab(s)') }} - - -
{{ _('Watch_Item(s)') }} - {% if yaml_updates and updates %} - - {% else %} - - {% endif %} - -
{{ 'visu_acl' }} - - -
- -
- {% if logic_lines %} - - {% if updates %}{% endif %} - {% else %}{{ _('no data available') }}{% endif %} - {% if not yaml_updates or not updates %} - - {% endif %} - -
-
- -{% endblock %} - diff --git a/backend/webif/templates/main.html b/backend/webif/templates/main.html deleted file mode 100755 index b68457d48..000000000 --- a/backend/webif/templates/main.html +++ /dev/null @@ -1,35 +0,0 @@ - -{% extends "base.html" %} -{% import "navbar.html" as nav with context %} -{% block navbar %} - {{ nav }} -{% endblock navbar %} - -{% block title %} -SmartHomeNG -{% endblock title %} - -{% block content %} - -{# - -#} -{% endblock %} diff --git a/backend/webif/templates/navbar.html b/backend/webif/templates/navbar.html deleted file mode 100755 index cfe10755f..000000000 --- a/backend/webif/templates/navbar.html +++ /dev/null @@ -1,24 +0,0 @@ - diff --git a/backend/webif/templates/plugins.html b/backend/webif/templates/plugins.html deleted file mode 100755 index dd105be4c..000000000 --- a/backend/webif/templates/plugins.html +++ /dev/null @@ -1,275 +0,0 @@ - -{% extends "base.html" %} -{% block navbar %} - {% with active_page="plugins" %} - {% include "navbar.html" %} - {% endwith %} -{% endblock navbar %} - -{% block title %} -{{ _('Plugins', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} -
-
- - - - - - - - - - - - {% if develop %} - - {% else %} - - {% endif %} - - - - - {% for p in plugins %} - {% set outer_loop = loop %} - - - - - - - - {% if p.smartplugin %} - {% set webifs = mod_http.get_webifs_for_plugin(p.shortname) %} - - - - - - {% if develop %} - - {% else %} - - {% endif %} - - - {% else %} - - - - {% if develop %} - - {% else %} - - {%endif%} - - {%endif%} - - - - - - - {% endfor %} - -
{{ _('Type') }}{{ _('Configname') }}{{ _('Plugin') }}{{ _('Instanz') }}{{ _('Version') }}{{ _('Mehrere Instanzen möglich') }}{{ _('Aktionen') }}{{ _('Web Interface') }}
- {% if p.metadata.get_string('type') in ['web', 'protocol', 'interface', 'gateway', 'system'] %} - - {% else %} - - - {% endif %} - {% if p.stopped %}{% endif %}{% if p.smartplugin %}SmartPlugin{%else%}Classic{%endif%} - - - - - - {% if p.stopped %}{% endif %}{{ p.version }}{% if p.stopped %}{% endif %}{% if p.multiinstance %}{{ _('Ja') }}{%else%}{{ _('Nein') }}{%endif%} - {% if p.stoppable %} - {% if not p.stopped %} - - {% else %} - - {% endif %} - - {% if develop %} - - {% endif %} - {% endif %} - {% if not p.stopped and p.shortname != 'backend' %} - {% for webif in webifs %} - {% if webif['Instance'] == p.instancename %} - - - {%endif%} - {% endfor %} - {%endif%} - ----
-
-
-{% endblock %} diff --git a/backend/webif/templates/scenes.html b/backend/webif/templates/scenes.html deleted file mode 100755 index 0b9fd3040..000000000 --- a/backend/webif/templates/scenes.html +++ /dev/null @@ -1,81 +0,0 @@ - -{% extends "base.html" %} -{% block navbar %} - {% with active_page="scenes" %} - {% include "navbar.html" %} - {% endwith %} -{% endblock navbar %} - -{% block title %} -{{ _('Szenen', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} -
-
- - - - - - - - - - - - - - - - {% for scene in scene_list %} - - {% if scene['name'] == '' %} - - {% else %} - - {% endif %} - - - {% for value in scene['values'] %} - {% for action in value['action_list'] %} - {% if (action == value['action_list'][0]) and (value['action_name'] != '') %} - - - - - - - {% endif %} - - - {% if (action == value['action_list'][0]) and (value['action_name'] == '') %} - - {% else %} - - {% endif %} - {% if action[2] %} - - {% else %} - - {% endif %} - - {% if (action[3] == None) or (action[3] == action[1]) %} - - {% else %} - - {% endif %} - - - {% endfor %} - {% endfor %} - - {% endfor %} - {% if not supported %} -

  You need a newer SmartHomeNG core!

- {% endif %} - -
{{ _('Szene') }}{{ _('Lernen') }}{{ _('Item') }}{{ _('Wert') }}
{{ scene['path'] }}{{ scene['path'] }}   ({{ scene['name'] }})
{{ value['action'] }}: {{ value['action_name'] }}
{{ value['action'] }}:{{ _('ja') }}{{ _('nein') }}{{ action[0] }}{{ action[1] }}{{ action[3] }} (default: {{ action[1] }})
-
-
-{% endblock %} diff --git a/backend/webif/templates/schedules.html b/backend/webif/templates/schedules.html deleted file mode 100755 index 4b2015f50..000000000 --- a/backend/webif/templates/schedules.html +++ /dev/null @@ -1,161 +0,0 @@ - -{% extends "base.html" %} -{% block navbar %} - {% with active_page="schedules" %} - {% include "navbar.html" %} - {% endwith %} -{% endblock navbar %} - -{% block title %} -{{ _('Scheduler', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} - -
-
-
- - - - - - - - - - - {% for entry in schedule_list %} - - - - - - - - {% endfor %} - -
{{ _('Scheduler') }}{{ _('nächste Ausführung') }}{{ _('Cycle') }}{{ _('Crontab') }}
{{ entry['fullname'] }}{{ entry['next'] }}{{ entry['cycle'] }}{{ entry['cron'] }}
-
-
- -
-
- - - - - - - - - - - {% for entry in schedule_list %} - {% if entry['group'] == 'items' %} - - - - - - - - {% endif %} - {% endfor %} - -
{{ _('Scheduler') }}{{ _('nächste Ausführung') }}{{ _('Cycle') }}{{ _('Crontab') }}
{{ entry['name'] }}{{ entry['next'] }}{{ entry['cycle'] }}{{ entry['cron'] }}
-
-
- -
-
- - - - - - - - - - - {% for entry in schedule_list %} - {% if entry['group'] == 'logics' %} - - - - - - - - {% endif %} - {% endfor %} - -
{{ _('Scheduler') }}{{ _('nächste Ausführung') }}{{ _('Cycle') }}{{ _('Crontab') }}
{{ entry['name'] }}{{ entry['next'] }}{{ entry['cycle'] }}{{ entry['cron'] }}
-
-
- -
-
- - - - - - - - - - - {% for entry in schedule_list %} - {% if entry['group'] == 'plugins' %} - - - - - - - - {% endif %} - {% endfor %} - -
{{ _('Scheduler') }}{{ _('nächste Ausführung') }}{{ _('Cycle') }}{{ _('Crontab') }}
{{ entry['name'] }}{{ entry['next'] }}{{ entry['cycle'] }}{{ entry['cron'] }}
-
-
- -
-
- - - - - - - - - - - {% for entry in schedule_list %} - {% if entry['group'] != 'items' and entry['group'] != 'logics' and entry['group'] != 'plugins' %} - - - - - - - - {% endif %} - {% endfor %} - -
{{ _('Scheduler') }}{{ _('nächste Ausführung') }}{{ _('Cycle') }}{{ _('Crontab') }}
{{ entry['fullname'] }}{{ entry['next'] }}{{ entry['cycle'] }}{{ entry['cron'] }}
-
-
-
-{% endblock %} diff --git a/backend/webif/templates/services.html b/backend/webif/templates/services.html deleted file mode 100755 index a2e33f7c9..000000000 --- a/backend/webif/templates/services.html +++ /dev/null @@ -1,288 +0,0 @@ - -{% extends "base.html" %} -{% block navbar %} - {% with active_page="services" %} - {% include "navbar.html" %} - {% endwith %} -{% endblock navbar %} - -{% block title %} -{{ _('Dienste', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} -
-
- - - - - - - - - - - - - - - {% if service_ctrl %} - - - {% if shng_service %} - - - {% else %} - - - {% endif %} - - {% endif %} - - - - - - - {% if 1 == 2 %} - - - - - - - - - - - - - - - - - - - - - {% endif %} - -
{{ _('Dienst') }}{{ _('Status') }}{{ _('Aktion') }}
Icon languages -
{{ _('Sprache des Backends') }}:
- -
- {% if develop %} - - {% endif %} -
SmartHomeNG logo small
Dienst für SmartHomeNG: smarthome.service
- - SmartHomeNG {{ '(läuft nicht als Dienst)' }}
Icon knxd service{{ _('Dienst für die KNX Unterstützung') }}:  - {% if knxdeamon %} - {{ knxdeamon }} - {% else %} - {{ _('Nicht aktiv') }} - {% endif %} -
SmartHomeNG logo small
{{ smarthome_service }}
-
- {% if knxd_active %} -
- -
- {% else %} -
- -
- {% endif %} -
Icon KNXD service
{{ knxd_service }}
-
- {% if knxd_active %} -
- -
- {% else %} -
- -
- {% endif %} -
Icon KNXD socket
{{ knxd_socket }}
-
- {% if knxd_active %} -
- -
- {% else %} -
- -
- {% endif %} -
Icon Reboot - - {% if rbt1 %} - {{ rbt1 }} - {% else %} - - {% endif %} -
- - - - - - - - -{# - {% if sql_plugin %} - - - - - {% endif %} - {% if database_plugin %} - {% for p in database_plugin %} - - - - - {% endfor %} - {% endif %} -#} - - - - - - - - - - - - - - - - - -
{{ _('Dienst') }}{{ _('Aktion') }}
Icon database backup
Icon database backup
Icon cache - {{ _('Cacheprüfung','button') }} -
-
Icon password -
- - - {{ _('Passwort-Hash erzeugen','button') }} -
- {{ _('Passwort anzeigen') }} -
-
Icon cache - {{ _('YAML Syntax Checker') }} -   - {{ _('CONF-YAML Konverter') }} -
-
Icon cache - {{ _('Eval Syntax Checker') }} -
-
-
-
- -{% endblock %} diff --git a/backend/webif/templates/services_shng_restart.html b/backend/webif/templates/services_shng_restart.html deleted file mode 100755 index 0fe915025..000000000 --- a/backend/webif/templates/services_shng_restart.html +++ /dev/null @@ -1,29 +0,0 @@ - -{% extends "base.html" %} -{% block navbar %} - {% with active_page="services" %} - {% include "navbar.html" %} - {% endwith %} -{% endblock navbar %} - -{% block title %} -{{ _('Dienste', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} -
- - {{ msg }} - -
- -{% endblock content %} - diff --git a/backend/webif/templates/system.html b/backend/webif/templates/system.html deleted file mode 100755 index 145ab51c6..000000000 --- a/backend/webif/templates/system.html +++ /dev/null @@ -1,292 +0,0 @@ - -{% extends "base.html" %} -{% block navbar %} - {% with active_page="system" %} - {% include "navbar.html" %} - {% endwith %} -{% endblock navbar %} - -{% block title %} -{{ _('Systeminfo', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - {% endif %} - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{{ _('Eigenschaft') }}{{ _('Status') }}
{{ _('SmartHomeNG Version') }}:{{ sh_vers }} {{ _('in') }} {{ sh_dir }}  -   {{ sh_desc }}   -  {{ _('Benutzer') }}: {{ user }}
{{ _('SmartHomeNG Plugins Version') }}:{{ plg_vers }} {{ _('in') }} {{ sh_dir }}/plugins - {% if plg_desc != '' %} -   -   {{ plg_desc }} - {% endif %} -
{{ _('Host') }}:{{ node }}  -  IPv4: {{ ip }}{% if ipv6 != '::1' %}  -  IPv6: {{ ipv6 }}
{{ _('Betriebssystem') }}:{{ system }} {{ vers }}  -  {{ _('Architektur') }}: {{ arch }}
{{ _('Python Version') }}:{{ pyversion }}
{{ _('Freier Speicher') }}:{{ (freespace)|round|int }} MByte
{{ _('Datum') }} / {{ _('Zeit') }}:{{ now }}
{{ _('Betriebszeit') }}:{{ _('Host') }}: {{ uptime }}  -  SmartHomeNG: {{ sh_uptime }}
-
-
-
-
- - - - - - - - - - - -
Python Package{{ _('installierte Version') }}{{ _('Anforderungen') }}{{ _('Neuste Version') }} (PyPI)
-
-
-
- -{% endblock %} diff --git a/backend/webif/templates/threads.html b/backend/webif/templates/threads.html deleted file mode 100755 index 8406003c5..000000000 --- a/backend/webif/templates/threads.html +++ /dev/null @@ -1,38 +0,0 @@ - -{% extends "base.html" %} -{% block navbar %} - {% with active_page="threads" %} - {% include "navbar.html" %} - {% endwith %} -{% endblock navbar %} - -{% block title %} -{{ _('Threads', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} -
-
- - - - - - - - - - - {% for t in threads %} - - - - - - - {% endfor %} - -
{{ _('Thread') }} ({{ _('gesamt') }}: {{ threads_count }}){{ _('Thread-Id') }}{{ _('Aktiv') }}
{{ t.name }}{{ t.id }}{{ _(t.alive, 'threads') }}
-
-
-{% endblock %} diff --git a/backend/webif/templates/yaml_syntax_checker.html b/backend/webif/templates/yaml_syntax_checker.html deleted file mode 100755 index bc33e4268..000000000 --- a/backend/webif/templates/yaml_syntax_checker.html +++ /dev/null @@ -1,71 +0,0 @@ - -{% extends "base.html" %} -{% import "navbar.html" as nav with context %} -{% block navbar %} - {{ nav }} -{% endblock navbar %} - -{% block title %} -{{ _('Dienste', 'menu') }} - SmartHomeNG -{% endblock title %} - -{% block content %} - -
-
-
-
-
{{ _('Eingabe im .YAML Format') }}
- -
-
-
-
- {% if output_format == 'python' %} -
{{ _('Ergebnis: Aufbereitet als Python Source Code') }}
- {% else %} -
{{ _('Ergebnis: Aufbereitetes .YAML Format') }}
- {% endif %} - -
-
-
-
- - {% if develop %} - - {% endif %} -
-
- -{% endblock %} diff --git a/beolink/__init__.py b/beolink/__init__.py index 81842f0b9..defbb9590 100755 --- a/beolink/__init__.py +++ b/beolink/__init__.py @@ -40,7 +40,7 @@ class BeoNetlink(SmartPlugin): the update functions for the items """ - PLUGIN_VERSION = '0.6.0' + PLUGIN_VERSION = '0.6.1' def __init__(self, sh): """ diff --git a/beolink/plugin.yaml b/beolink/plugin.yaml index 34491972e..09656f68f 100755 --- a/beolink/plugin.yaml +++ b/beolink/plugin.yaml @@ -12,7 +12,7 @@ plugin: # documentation: https://github.com/smarthomeNG/smarthome/wiki/CLI-Plugin # url of documentation (wiki) page # support: https://knx-user-forum.de/forum/supportforen/smarthome-py - version: 0.6.0 # Plugin version + version: 0.6.1 # Plugin version sh_minversion: 1.9 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance diff --git a/beolink/webif/templates/index.html b/beolink/webif/templates/index.html index d9f0eed52..e7a9cd104 100755 --- a/beolink/webif/templates/index.html +++ b/beolink/webif/templates/index.html @@ -115,7 +115,7 @@ {{ item._path }} {{ item._type }} {{ item() }} - {{ p.beodevices.beodeviceinfo[beo_id]['FriendlyName'] }} + {{ p.beodevices.beodeviceinfo.get(beo_id,'-')['FriendlyName'] }} {{ beo_id }} {{ item.last_update().strftime('%d.%m.%Y %H:%M:%S') }} diff --git a/blockly/tests/test_backend_blocklylogics.py b/blockly/tests/test_backend_blocklylogics.py index e95b687ae..fe2bcc4e4 100755 --- a/blockly/tests/test_backend_blocklylogics.py +++ b/blockly/tests/test_backend_blocklylogics.py @@ -8,8 +8,9 @@ import lib.item -from plugins.backend import WebInterface as Root -from plugins.backend.tests.cptestcase import BaseCherryPyTestCase +#from plugins.backend import WebInterface as Root +#from plugins.backend.tests.cptestcase import BaseCherryPyTestCase +from .cptestcase import BaseCherryPyTestCase from tests.mock.core import MockSmartHome @@ -31,9 +32,9 @@ def tearDownModule(): class TestCherryPyApp(BaseCherryPyTestCase): def test_blockly(self): pass - # dummy, because tests are from the tightly coupled 1. try do integrate blockly + # dummy, because tests are from the tightly coupled 1. try do integrate blockly # (before it became a seperate plugin) - + # def test_backendIntegration(self): # response = self.request('index') # self.assertEqual(response.output_status, b'200 OK') diff --git a/casambi/__init__.py b/casambi/__init__.py index a944ff9d2..2e08cf23c 100755 --- a/casambi/__init__.py +++ b/casambi/__init__.py @@ -7,9 +7,6 @@ # https://www.smarthomeNG.de # https://knx-user-forum.de/forum/supportforen/smarthome-py # -# Sample plugin for new plugins to run with SmartHomeNG version 1.4 and -# upwards. -# # SmartHomeNG is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or @@ -28,6 +25,7 @@ from lib.module import Modules from lib.model.smartplugin import * from lib.item import Items +from .webif import WebInterface import threading import requests @@ -42,7 +40,7 @@ class Casambi(SmartPlugin): """ # Use VERSION = '1.0.0' for your initial plugin Release - PLUGIN_VERSION = '1.7.5' # (must match the version specified in plugin.yaml) + PLUGIN_VERSION = '1.7.6' # (must match the version specified in plugin.yaml) def __init__(self, sh): """ @@ -90,8 +88,8 @@ def __init__(self, sh): self._init_complete = False return - self.init_webinterface() - + self.init_webinterface(WebInterface) + return def sessionIDValid(self): @@ -328,8 +326,7 @@ def decodeEventData(self, receivedData): elif method == 'unitChanged': unitID = None - on = None # on status represents the status of the BLE module in the device, not the light status. - status = None + status = None # Health status of sensor dimValue = None verticalValue = None cctValue = None @@ -337,10 +334,11 @@ def decodeEventData(self, receivedData): self.logger.debug("Debug Json unitChanged: {0}".format(dataJson)) if 'id' in dataJson: unitID = int(dataJson['id']) - if 'on' in dataJson: - on = bool(dataJson['on']) if 'status' in dataJson: status = str(dataJson['status']) + if status != 'ok': + self.logger.warning(f"Sensor with ID {unitID} reported status {status}") + if 'controls' in dataJson: controls = dataJson['controls'] self.logger.debug(f"Debug controls Json: {controls}") @@ -360,15 +358,15 @@ def decodeEventData(self, receivedData): elif type == 'CCT': cctValue = value - - self.logger.debug(f"Received {method} status from unit {unitID}, on: {on}, value: {dimValue}, vertical: {verticalValue}, cct: {cctValue}.") + self.logger.debug(f"Received {method} status from unit {unitID}, value: {dimValue}, vertical: {verticalValue}, cct: {cctValue}.") #Copy data into casambi item: if unitID and (unitID in self._rx_items): #self.logger.debug("Casambi ID found in rx item list") # iterate over all items having this id for item in self._rx_items[unitID]: - if on and (item.conf['casambi_rx_key'].upper() == 'ON'): + #if on and (item.conf['casambi_rx_key'].upper() == 'ON'): + if (item.conf['casambi_rx_key'].upper() == 'ON'): item(not (dimValue == 0), self.get_shortname()) elif item.conf['casambi_rx_key'].upper() == 'DIMMER': item(dimValue * 100, self.get_shortname()) @@ -379,6 +377,15 @@ def decodeEventData(self, receivedData): elif unitID and not (unitID in self._rx_items): self.logger.warning(f"Received status information for ID {unitID} which has no equivalent item.") + + elif method == 'networkUpdated': + self.logger.warning(f"Casambi network has been updated with persistent changes") + self.logger.warning(f"Debug: decodeData(), receivedData: {receivedData}") + + elif method == 'networkLog': + self.logger.debug(f"Casambi network sent network logging information") + self.logger.debug(f"Debug: decodeData(), receivedData: {receivedData}") + else: self.logger.warning(f"Received unknown method {method} which is not supported.") self.logger.warning(f"Debug: decodeData(), receivedData: {receivedData}") @@ -589,110 +596,3 @@ def update_item(self, item, caller=None, source=None, dest=None): def get_rxItemLength(self): return len(self._rx_items) - def init_webinterface(self): - """" - Initialize the web interface for this plugin - - This method is only needed if the plugin is implementing a web interface - """ - try: - self.mod_http = Modules.get_instance().get_module( - 'http') # try/except to handle running in a core version that does not support modules - except: - self.mod_http = None - if self.mod_http == None: - self.logger.error("Not initializing the web interface") - return False - - import sys - if not "SmartPluginWebIf" in list(sys.modules['lib.model.smartplugin'].__dict__): - self.logger.warning("Web interface needs SmartHomeNG v1.5 and up. Not initializing the web interface") - return False - - # set application configuration for cherrypy - webif_dir = self.path_join(self.get_plugin_dir(), 'webif') - config = { - '/': { - 'tools.staticdir.root': webif_dir, - }, - '/static': { - 'tools.staticdir.on': True, - 'tools.staticdir.dir': 'static' - } - } - - # Register the web interface as a cherrypy app - self.mod_http.register_webif(WebInterface(webif_dir, self), - self.get_shortname(), - config, - self.get_classname(), self.get_instance_name(), - description='') - - return True - - -# ------------------------------------------ -# Webinterface of the plugin -# ------------------------------------------ - -import cherrypy -from jinja2 import Environment, FileSystemLoader - - -class WebInterface(SmartPluginWebIf): - - def __init__(self, webif_dir, plugin): - """ - Initialization of instance of class WebInterface - - :param webif_dir: directory where the webinterface of the plugin resides - :param plugin: instance of the plugin - :type webif_dir: str - :type plugin: object - """ - self.logger = logging.getLogger(__name__) - self.webif_dir = webif_dir - self.plugin = plugin - self.tplenv = self.init_template_environment() - - self.items = Items.get_instance() - - @cherrypy.expose - def index(self, reload=None): - """ - Build index.html for cherrypy - - Render the template and return the html file to be delivered to the browser - - :return: contents of the template after beeing rendered - """ - tmpl = self.tplenv.get_template('index.html') - # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) - return tmpl.render(p=self.plugin, items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path']))) - - - @cherrypy.expose - def get_data_html(self, dataSet=None): - """ - Return data to update the webpage - - For the standard update mechanism of the web interface, the dataSet to return the data for is None - - :param dataSet: Dataset for which the data should be returned (standard: None) - :return: dict with the data needed to update the web page. - """ - if dataSet is None: - # get the new data - data = {} - - # data['item'] = {} - # for i in self.plugin.items: - # data['item'][i]['value'] = self.plugin.getitemvalue(i) - # - # return it as json the the web page - # try: - # return json.dumps(data) - # except Exception as e: - # self.logger.error(f"get_data_html exception: {e}") - return {} - diff --git a/casambi/plugin.yaml b/casambi/plugin.yaml index 343b726c0..6b5833bd2 100755 --- a/casambi/plugin.yaml +++ b/casambi/plugin.yaml @@ -12,7 +12,7 @@ plugin: # documentation: https://github.com/smarthomeNG/smarthome/wiki/CLI-Plugin # url of documentation (wiki) page support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1496182-supportthread-f%C3%BCr-casambi-plugin - version: 1.7.5 # Plugin version (must match the version specified in __init__.py) + version: 1.7.6 # Plugin version (must match the version specified in __init__.py) sh_minversion: 1.7 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance diff --git a/casambi/user_doc.rst b/casambi/user_doc.rst index 92fcdb050..973f7a6fc 100755 --- a/casambi/user_doc.rst +++ b/casambi/user_doc.rst @@ -1,10 +1,17 @@ -.. index:: Plugins; casambi (Casambi REST API Unterstützung) +.. index:: Plugins; casambi .. index:: casambi ======= casambi ======= +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + Dieses Plugin unterstützt Casambi und Occhio Lichter durch die Verwendung des Casambi Backend API. Die Kommunikation erfolgt über Bluetooth Low Energy (BLE) und die Casambi Produkte sind in vielen Geräten verbaut, beispielsweise von Occhio. @@ -20,14 +27,13 @@ Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/co Gateway Hardware ================ -According to the Casambi concept, a mobile device (cell phone or tablet) is used as hardware gateway between local -BLE network and Casambi backend. +Das Casambi Konzept sieht vor, ein Mobilgerät (z.B. Handy oder Tablett) als Hardwaregateway zwischen dem lokalen BLE Netzwerk und dem Casambi Backend +enzusetzen. -Requirements +Anforderungen ============ - -The plugin needs a valid Casambi API key which can be obtained from Casambi under: -support@casambi.com +Das Casambi Plugin benötigt einen validen Casambi API key, der hier beantragt werden kann: +``support@casambi.com`` Beispiele @@ -134,3 +140,22 @@ Im ersten Tab werden die Items angezeigt, die das Casambi Plugin nutzen: .. image:: assets/webif1.jpg :class: screenshot + + +Changelog +========= + +V1.7.5 + improved webinterface + improved command sending, if connection is no longer available (broken pipe). Resent once immediately. + +V1.7.4 + added backend online status parsing to item + removed unjustified warning messages + +V1.7.3 + added support for tunable white devices + +V1.7.1 + Initial release + diff --git a/casambi/webif/__init__.py b/casambi/webif/__init__.py new file mode 100755 index 000000000..fd604010b --- /dev/null +++ b/casambi/webif/__init__.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2020- +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.5 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import datetime +import time +import os +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.tplenv = self.init_template_environment() + + self.items = Items.get_instance() + + @cherrypy.expose + def index(self, reload=None, action=None, email=None, hashInput=None, code=None, tokenInput=None, mapIDInput=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + calculatedHash = '' + codeRequestSuccessfull = None + token = '' + configWriteSuccessfull = None + resetAlarmsSuccessfull = None + boundaryListSuccessfull = None + + + + if action is not None: + if action == "generateHash": + ret = self.plugin.generateRandomHash() + calculatedHash = str(ret) + self.logger.info("Generate hash triggered via webinterface: {0}".format(calculatedHash)) + elif action == "requestCode" and (email is not None) and (hashInput is not None): + self.logger.warning("Request Vorwerk code triggered via webinterface (Email:{0} hashInput:{1})".format(email, hashInput)) + codeRequestSuccessfull = self.plugin.request_oauth2_code(str(hashInput)) + elif action == "requestCode": + if email is None: + self.logger.error("Cannot request Vorwerk code as email is empty: {0}.".format(str(email))) + elif hash is None: + self.logger.error("Cannot request Vorwerk code as hash is empty: {0}.".format(str(email))) + elif action == "requestToken": + self.logger.info("Request Vorwerk token triggered via webinterface") + if (email is not None) and (hashInput is not None) and (code is not None) and (not code == '') : + token = self.plugin.request_oauth2_token(str(code), str(hashInput)) + elif (code is None) or (code == ''): + self.logger.error("Request Vorwerk token: Email validation code missing.") + else: + self.logger.error("Request Vorwerk token: Missing argument.") + elif action =="writeToPluginConfig": + if (tokenInput is not None) and (not tokenInput == ''): + self.logger.warning("Writing token to plugin.yaml") + param_dict = {"token": str(tokenInput)} + self.plugin.update_config_section(param_dict) + configWriteSuccessfull = True + else: + self.logger.error("writeToPluginConfig: Missing argument.") + configWriteSuccessfull = False + elif action =="clearAlarms": + self.logger.warning("Resetting alarms via webinterface") + self.plugin.dismiss_current_alert() + resetAlarmsSuccessfull = True + elif action =="listAvailableMaps": + self.logger.warning("List all available maps via webinterface") + boundaryListSuccessfull = self.plugin.get_map_boundaries(map_id=mapIDInput) + else: + self.logger.error("Unknown command received via webinterface") + + tmpl = self.tplenv.get_template('index.html') + # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin, calculatedHash=calculatedHash, + token=token, + codeRequestSuccessfull=codeRequestSuccessfull, + configWriteSuccessfull=configWriteSuccessfull, + resetAlarmsSuccessfull=resetAlarmsSuccessfull, + boundaryListSuccessfull=boundaryListSuccessfull, + items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path']))) + + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + if dataSet is None: + # get the new data + data = {} + + # data['item'] = {} + # for i in self.plugin.items: + # data['item'][i]['value'] = self.plugin.getitemvalue(i) + # + # return it as json the the web page + # try: + # return json.dumps(data) + # except Exception as e: + # self.logger.error("get_data_html exception: {}".format(e)) + return {} diff --git a/casambi/webif/static/img/plugin_logo.png b/casambi/webif/static/img/plugin_logo.png new file mode 100755 index 000000000..c3ad05a45 Binary files /dev/null and b/casambi/webif/static/img/plugin_logo.png differ diff --git a/cli/plugin.yaml b/cli/plugin.yaml index 1976a6177..d8287f409 100755 --- a/cli/plugin.yaml +++ b/cli/plugin.yaml @@ -10,7 +10,7 @@ plugin: tester: onkelandy, Sandman60, ohinckel state: qa-passed keywords: telnet cli command line interface remote control - documentation: http://smarthomeng.de/user/plugins/cli/user_doc.html + documentation: '' version: 1.8.2 # Plugin version sh_minversion: 1.9.0 # minimum shNG version to use this plugin diff --git a/cli/user_doc.rst b/cli/user_doc.rst index e71f10669..80af9ef05 100755 --- a/cli/user_doc.rst +++ b/cli/user_doc.rst @@ -1,28 +1,33 @@ .. index:: Plugins; cli (CommandLine Interface) .. index:: cli +=== cli -### +=== -Konfiguration -============= +.. image:: webif/static/img/plugin_logo.svg + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left -Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/cli` beschrieben. +Dieses Plugin bietet einen Zugriff über Telnet auf SmartHomeNG. +Über das Plugin können diverse Befehle an SmartHomeNG zur Auflistung, Debugging und Manipulation +von Items, Logiken, Plugins und internen Objekten geschickt werden. -Weiterführende Informationen -============================ -Das CLI Plugin bietet einen Zugriff über Telnet auf SmartHomeNG. +Konfiguration +============= -Über das Plugin können diverse Befehle an SmartHomeNG zur Auflistung, Debugging und Manipulation -von Items, Logiken, Plugins und internen Objekten geschickt werden. +Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/cli` beschrieben. Plugin Konfigurationsparameter -============================== +------------------------------ -Das Plugin kann über die folgende Konfiguration in der Datei etc/plugins.conf akitviert werden: +Das Plugin kann über die folgende Konfiguration in der Datei etc/plugins.yaml akitviert werden: .. code-block: yaml cli: @@ -129,3 +134,7 @@ CLI Befehle | quit, q | Beendet die CLI Session | +--------------------------+----------------------------------------------------------------------------------------------+ +Web Interface +============= + +Das Webinterface zeigt nur die ingestellten Parameter des Plugins an und bietet darüber hinaus keine Funktionalität. diff --git a/cli/webif/templates/index.html b/cli/webif/templates/index.html index ebe5bcd27..1b31cdd8b 100755 --- a/cli/webif/templates/index.html +++ b/cli/webif/templates/index.html @@ -4,7 +4,7 @@ {% set update_interval = 0 %} - +{% set buttons = false %} @@ -32,35 +32,20 @@ {{ _('Hörende IP') }} {{ p.ip }} - {{ _('Updates erlaubt') }} {{ p.updates_allowed }} - Port {{ p.port }} - {{ _('Hashed Passwort') }} {{ p.hashed_password }} - {% endblock headtable %} - -{% block buttons %} -{% if 1==2 %} -
- -
-{% endif %} -{% endblock %} - diff --git a/darksky/plugin.yaml b/darksky/plugin.yaml index 41aa4b9ac..5f08cb276 100755 --- a/darksky/plugin.yaml +++ b/darksky/plugin.yaml @@ -261,6 +261,10 @@ item_structs: eval_trigger: ..time_epoch eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + weekday: + type: str + ds_matchstring@instance: currently/weekday + summary: type: str ds_matchstring@instance: currently/summary diff --git a/database/__init__.py b/database/__init__.py index ce5a78bbb..c2981012c 100755 --- a/database/__init__.py +++ b/database/__init__.py @@ -50,7 +50,7 @@ class Database(SmartPlugin): """ ALLOW_MULTIINSTANCE = True - PLUGIN_VERSION = '1.6.3' + PLUGIN_VERSION = '1.6.9' # SQL queries: {item} = item table name, {log} = log table name # time, item_id, val_str, val_num, val_bool, changed @@ -105,7 +105,9 @@ def __init__(self, sh, *args, **kwargs): self.max_delete_logentries = self.get_parameter_value('max_delete_logentries') self._default_maxage = float(self.get_parameter_value('default_maxage')) - self.webif_pagelength = self.get_parameter_value('webif_pagelength') + self._copy_database = self.get_parameter_value('copy_database') + self._copy_database_name = self.get_parameter_value('copy_database_name') + self._webdata = {} self._replace = {table: table if self._prefix == "" else self._prefix + table for table in ["log", "item"]} @@ -116,17 +118,28 @@ def __init__(self, sh, *args, **kwargs): self._dump_lock = threading.Lock() self.skipping_dump = False - - self._handled_items = [] # items that have a 'database' attribute set - self._items_with_maxage = [] # items that have a 'database_maxage' attribute set - self._maxage_worklist = [] # work copy of self._items_with_maxage - self._item_logcount = {} # dict to store the number of log records for an item - self._items_total_entries = 0 # total number of log entries - self._items_still_counting = False # total number of log entries + self._remove_older_skipped = False + self.lock_remove_older = False + + self.orphanlist = [] # list with item names of orphant database entries + self._orphan_logcount = {} # dict to store the number of log records for an orphan + self.remove_orphan = False # set to True to remove orphans during remove_older + self.delete_orphan_chunk_size = 20000 # Delete x log entries for orphan items at a time + self._handled_items = [] # items that have a 'database' attribute set + self._items_with_maxage = [] # items that have a 'database_maxage' attribute set + self._maxage_worklist = [] # work copy of self._items_with_maxage + self._item_logcount = {} # dict to store the number of log records for an item + self._items_total_entries = 0 # total number of log entries + self._items_still_counting = False # total number of log entries self.cleanup_active = False - self.last_connect_time = 0 # mechanism for limiting db connection requests + self.last_connect_time = 0 # mechanism for limiting db connection requests + self.last_maint_connect_time = 0 # mechanism for limiting db maintenance connection requests + + # Copy SQLite3 database file (if configured) + if self._copy_database: + self.copy_databasefile() # Setup db and test if connection is possible self._db = lib.db.Database(("" if self._prefix == "" else self._prefix.capitalize()) + "Database", self.driver, self._connect) @@ -136,15 +149,24 @@ def __init__(self, sh, *args, **kwargs): self._init_complete = False return + # Setup db maintenance connection and test if connection is possible + self._db_maint = lib.db.Database(("" if self._prefix == "" else self._prefix.capitalize()) + "Database", self.driver, self._connect) + if self._db_maint.api_initialized == False: + # Error initializeng the database driver (e.g.: Python module for database driver not found) + self.logger.error("Initialization of database API failed for maintenance connection") + self._init_complete = False + return + self._db_initialized = False + self._db_maint_initialized = False if not self._initialize_db(): #self._init_complete = False #return self.logger.debug("Init: DB could not be initialized") pass - self.init_webinterface(WebInterface) + self.init_webinterface(WebInterface) return @@ -154,6 +176,7 @@ def run(self): """ self.logger.debug("Run method called") self._initialize_db() + self.build_orphanlist(True) self._start_schedulers() self.alive = True @@ -167,6 +190,7 @@ def stop(self): self._stop_schedulers() self._dump(True) self._db.close() + self._db_maint.close() def parse_item(self, item): @@ -331,7 +355,7 @@ def _start_schedulers(self): self.scheduler_add('Buffer dump', self._dump, cycle=self._dump_cycle, prio=5) if len(self._items_with_maxage) > 0: # self.scheduler_add('Remove old', self.remove_older_than_maxage, cycle=91, prio=6) - self.scheduler_add('Remove old', self.remove_older_than_maxage, cycle=self._removeold_cycle, prio=6) + self.scheduler_add('Remove old', self.remove_older_than_maxage, cycle=self._removeold_cycle, prio=7) return @@ -351,6 +375,41 @@ def _stop_schedulers(self): # Database specific public functions of the plugin # ------------------------------------------------------ + def copy_databasefile(self): + """ + For SQLite3 databases only: Copy the databasefile before it is opened + + This can be used to make a backup or to use the copy for a VACUUM + + :return: + """ + if not self.driver.lower() == 'sqlite3': + self.logger.warning("Copying of database fie is only possible for SQLite3 databases") + param_dict = {"copy_database": False} + self.update_config_section(param_dict) + return + + # get source and destination names + try: + database_name = self._connect[0] + database_name = database_name[9:].strip() + except: + database_name = '' + + # copy the database file + self.logger.warning( f"Starting to copy SQLite3 database file from {database_name} to {self._copy_database_name}") + import shutil + try: + shutil.copy2(database_name, self._copy_database_name) + self.logger.warning("Finished copying SQLite3 database file") + except Exception as e: + self.logger.Error( f"Error copying SQLite3 database file: {e}") + + param_dict = {"copy_database": False} + self.update_config_section(param_dict) + return + + def id(self, item, create=True, cur=None): """ Returns the ID of the given item @@ -366,9 +425,13 @@ def id(self, item, create=True, cur=None): """ try: - id = self.readItem(str(item.id()), cur=cur) + item_path = str(item.id()) + except: + item_path = item + try: + id = self.readItem(item_path, cur=cur) except Exception as e: - self.logger.warning(f"id(): No id found for item {item.id()} - Exception {e}") + self.logger.warning(f"id(): No id found for item {item_path} - Exception {e}") id = None if id is None and create == True: @@ -379,6 +442,78 @@ def id(self, item, create=True, cur=None): return int(id[COL_ITEM_ID]) + def db_itemtype(self, item): + """ + Returns the itemtype of the given item, determined from the item-table of the database + + This is a public function of the plugin + + :param item: Item to get the ID for + + :return: id of the item within the database + :rtype: int | None + """ + + try: + item_path = str(item.id()) + except: + item_path = item + try: + row = self.readItem(item_path, cur=None) + except Exception as e: + self.logger.warning(f"db_itemtype: No id found for item {item_path} - Exception {e}") + row = None + + if (row is None) or (COL_ITEM_ID >= len(row)) : + return None + + id = int(row[COL_ITEM_ID]) + strval = row[COL_ITEM_VAL_STR] + numval = row[COL_ITEM_VAL_NUM] + boolval = row[COL_ITEM_VAL_BOOL] + + if (strval is not None) and (numval is None): + return 'str' + + if (strval is None) and (numval is not None): + if float(numval) != int(boolval): + return 'num' + return 'num, bool' + + return 'unbekannt' + + + def db_lastchange(self, item): + """ + Returns the itemtype of the given item, determined from the item-table of the database + + This is a public function of the plugin + + :param item: Item to get the ID for + :param cur: A database cursor object if available (optional) + + :return: id of the item within the database + :rtype: int | None + """ + + try: + item_path = str(item.id()) + except: + item_path = item + try: + row = self.readItem(item_path, cur=None) + except Exception as e: + self.logger.warning(f"db_lastchange: No id found for item {item_path} - Exception {e}") + row = None + + if (row is None) or (COL_ITEM_ID >= len(row)): + return None + + id = int(row[COL_ITEM_ID]) + last_change = row[COL_ITEM_TIME] + return self._datetime(last_change) + + def db(self): """ Returns the low-level database object @@ -394,7 +529,7 @@ def db(self): def dump(self, dumpfile, id=None, time=None, time_start=None, time_end=None, changed=None, changed_start=None, changed_end=None, cur=None): """ - Creates a database dump for given criterias + Creates a database dump for given criterias in csv format This is a public function of the plugin @@ -440,6 +575,22 @@ def dump(self, dumpfile, id=None, time=None, time_start=None, time_end=None, cha return + def sqlite_dump(self, dumpfile): + + if self.driver.lower() != 'sqlite3': + self.logger.warning("SQL dump is only possible for sqlite3 databases") + return False + + self.logger.info(f"Starting SQL file dump of the sqlite3 database to {dumpfile} ...") + + with open(dumpfile, 'w') as f: + for line in self._db._conn.iterdump(): + f.write(f"{line}\n") + + self.logger.info("SQL file dump of sqlite3 database completed") + return True + + def insertItem(self, name, cur=None): """ Create database item record for given database ID @@ -636,19 +787,6 @@ def readOldestLog(self, id, cur=None): params = {'id': id} return self._fetchall("SELECT min(time) FROM {log} WHERE item_id = :id;", params, cur=cur)[0][0] - #def readOldestLogs(self, id, cur=None): - # """ - # Read the time of oldest log record for given database ID - # - # This is a public function of the plugin - # - # :param id: Database ID of item to read the record for - # :param cur: A database cursor object if available (optional) - # - # :return: Log record for the database ID - # """ - # params = {'id': id} - # return self._fetchall("SELECT min(time) FROM {log} GROUP BY item_id", params, cur=cur)[0] def readLatestLog(self, id, time=None, cur=None): """ @@ -754,33 +892,116 @@ def deleteLog(self, id, time=None, time_start=None, time_end=None, changed=None, return - def cleanup(self): + def build_orphanlist(self, log_activity=False): """ - Cleanup database - deletes item/log records in the database if the corresponding item does not exist any more + Create a list of database entries which have no corresponding item in the item tree - This is a public function of the plugin + called by run() once on start :return: """ - self.cleanup_active = True - self.logger.info("Database cleanup started (removal of entries without defined item)") + if log_activity: + self.logger.info("build_orphan_list: Started") + self.orphanitemlist = [] + self.orphanlist = [] + items = [item.id() for item in self._buffer] - if not self._db.lock(60): - self.logger.error("Can not acquire lock for database cleanup") - self.cleanup_active = False - return - cur = self._db.cursor() + cur = self._db_maint.cursor() try: for item in self.readItems(cur=cur): if item[COL_ITEM_NAME] not in items: - self.deleteItem(item[COL_ITEM_ID], cur=cur) + if log_activity: + self.logger.info(f"- Found data for item w/o database attribute: {item[COL_ITEM_NAME]}") + self.orphanitemlist.append(item) + self.orphanlist.append(item[COL_ITEM_NAME]) except Exception as e: - self.logger.error("Database cleanup failed: {}".format(e)) + self.logger.error("Database build_orphan_list failed: {}".format(e)) cur.close() - self._db.release() - self.logger.info("Database cleanup finished") - self.cleanup_active = False + self._count_orphanlogentries() + if log_activity: + self.logger.info("build_orphan_list: Finished") + + return + + + def _count_orphanlogentries(self): + """ + count number of log entries for all items in database + + to be called by eval syntax checker + """ + self.logger.info("_count_orphanlogentries: # orphan items = {}".format(len(self.orphanlist))) + self._items_total_entries = 0 + for item in self.orphanlist: + item_id = self.id(item, create=False) + logcount = self.readLogCount(item_id) + logcount_str = f"{logcount:,}".replace(',','.') + self.logger.info(f"Orphan {item} (id={item_id}): {logcount_str} entries") + self._orphan_logcount[item_id] = logcount + + return + + + def _delete_orphan(self, item_path): + """ + Delete orphan item or logentries it + + :param item_path: path_name of the (orphan) item to work on + :param limit: Maximum log entries to delete + + :return: True, if item was deleted; False if only logentries were deleted + """ + item_id = self.id(item_path, create=False) + logcount = self.readLogCount(item_id) + if logcount == 0: + self.logger.info(f"_delete_orphan: Item {item_path} has no log entries") + cur = self._db_maint.cursor() + self._execute(self._prepare("DELETE FROM {item} WHERE id = :id;"), {'id': item_id}, cur=cur) + self.logger.info(f"_delete_orphan: Deleted item entry for {item_path}") + cur.close() + self._db_maint.commit() + return True + + cur = self._db_maint.cursor() + self._execute(self._prepare("DELETE FROM {log} WHERE item_id = :id ORDER BY time ASC LIMIT :maxrecords;"), {'id': item_id, 'maxrecords': self.delete_orphan_chunk_size}, cur=cur) + delete_orphan_chunk_size_str = f"{self.delete_orphan_chunk_size:,}".replace(',', '.') + self.logger.info(f"_delete_orphan: Deleted (up to) {delete_orphan_chunk_size_str} log entries for Item {item_path}") + cur.close() + self._db_maint.commit() + + return False + + + def remove_orphan_items(self): + """ + Delete item and logdata of items that have no correspondance in itemtree + """ + if len(self.orphanlist) == 0: + self.build_orphanlist() + + item = self.orphanlist.pop(0) + if not self._delete_orphan(item): + self.orphanlist.append(item) + + if len(self.orphanlist) == 0: + self.remove_orphan = False + self.logger.info("remove_orphan_items: Database cleanup finished") + + return + + + def cleanup(self): + """ + Cleanup database + deletes item/log records in the database if the corresponding item does not exist any more + + This is a public function of the plugin + + :return: + """ + self.remove_orphan = True + self.cleanup_active = True + self.logger.info("Database cleanup started (removal of entries without defined item)") return @@ -1090,42 +1311,6 @@ def _parse_single(self, frame): return ts - # def _parse_ts(self, frame): - # """ - # Parse one frame of a duration-timestamp to a duration (in seconds) - # - # :param frame: - # :return: - # """ - # minute = 60 * 1000 - # hour = 60 * minute - # day = 24 * hour - # week = 7 * day - # month = 30 * day - # year = 365 * day - # - # _frames = {'i': minute, 'h': hour, 'd': day, 'w': week, 'm': month, 'y': year} - # try: - # return int(frame) - # except: - # pass - # ts = self._timestamp(self.shtime.now()) - # if frame == 'now': - # fac = 0 - # frame = 0 - # elif frame[-1] in _frames: - # fac = _frames[frame[-1]] - # frame = frame[:-1] - # else: - # # return parameter unchaned - # return frame - # try: - # ts = ts - int(float(frame) * fac) - # except: - # self.logger.warning("Database: Unknown time frame '{0}'".format(frame)) - # return ts - - # -------------------------------------------------------- # Database buffer routines (dump, insert and remove) # -------------------------------------------------------- @@ -1141,14 +1326,14 @@ def _dump(self, finalize=False, items=None): :return: """ if self._dump_lock.acquire(timeout=60) == False: - self.logger.warning('Skipping dump, since an other database operation running! Data is buffered and dumped later.') + self.logger.notice('Skipping dump, since an other database operation running! Data is buffered and dumped later.') self.skipping_dump = True return self.logger.debug('Starting dump') if self.skipping_dump: - self.logger.warning('Dumping buffered data from skipped dump(s).') + self.logger.notice('Dumping buffered data from skipped dump(s).') self.skipping_dump = False if not self._initialize_db(): @@ -1292,12 +1477,18 @@ def remove_older_than_maxage(self): """ Remove log entries older than maxage of an item - Calls by scheduler + Called by scheduler """ - if not self._db.connected(): - self.logger.warning("remove_older_than_maxage skipped as db is not connected") + if self.lock_remove_older: + if not self._remove_older_skipped: + self.logger.info("remove_older_than_maxage task is manually locked") + self._remove_older_skipped = True return + if not self._db.connected(): + self.logger.warning("remove_older_than_maxage skipped because db is not connected") + return False + # prevent creation of more than one thread current_thread = threading.current_thread() current_thread_name = current_thread.name @@ -1305,16 +1496,24 @@ def remove_older_than_maxage(self): if t is current_thread: continue if t.name == current_thread_name: - self.logger.info("remove_older_than_maxage skipped as thread with this task is already running") + if not self._remove_older_skipped: + self.logger.info("remove_older_than_maxage skipped because a thread with this task is already running") + self._remove_older_skipped = True return + self._remove_older_skipped = False + + if self.remove_orphan: + self.remove_orphan_items() + + # go to work if self._maxage_worklist == []: # Fill work list, if it is empty if self._default_maxage == 0: self._maxage_worklist = [i for i in self._items_with_maxage] else: self._maxage_worklist = [i for i in self._handled_items] - self.logger.info(f"remove_older_than_maxage: Worklist filled with {len(self._items_with_maxage)} items") + self.logger.info(f"remove_older_: Worklist filled with {len(self._maxage_worklist)} items") item = self._maxage_worklist.pop(0) itempath = item.property.path @@ -1323,9 +1522,9 @@ def remove_older_than_maxage(self): item_id = self.id(item, create=False) except: if item_id is None: - self.logger.info(f"remove_older_than_maxage: no id for item {itempath}") + self.logger.info(f"remove_older_: no id for item {itempath}") else: - self.logger.critical(f"remove_older_than_maxage: no id for item {itempath}") + self.logger.critical(f"remove_older_: no id for item {itempath}") return # it might well be that introducing database_maxage to a very old SmartHomeNG installation will try to start @@ -1335,16 +1534,15 @@ def remove_older_than_maxage(self): # b) to just delete a limited number of log entries time_end = self.get_maxage_ts(item) timestamp_end = self._timestamp(time_end) - self.logger.info(f"remove_older_than_maxage: item = {itempath} remove older than {time_end}") # if delete would also remove the last logged value for the item then there might be no chance for # ``database: init`` to retrieve the latest value. remaining = 1 if self.get_iattr_value(item.conf, 'database').lower() == 'init': # find out if there are still log entries after deletion of the logs - remaining = self.readLogCount(item_id,self._timestamp( time_end + datetime.timedelta(microseconds=1))) + remaining = self.readLogCount(item_id, time_start=self._timestamp( time_end + datetime.timedelta(microseconds=1))) # remaining can be larger than self._item_logcount[item_id], it depends on the rate of database updates - #self.logger.info(f"remove_older_than_maxage: item = {itempath} has attribute init with {self._item_logcount[item_id]} log entries and will have {remaining} log entries after deletion") + #self.logger.info(f"remove_older_: {itempath} has attribute init with {self._item_logcount[item_id]} log entries and will have {remaining} log entries after deletion") if remaining <= 0: # no log entries will be there after deletion, need to go back in time for the latest logentry @@ -1352,11 +1550,15 @@ def remove_older_than_maxage(self): if new_must_keep_timestamp is None: return new_must_keep_time = self._datetime(new_must_keep_timestamp) - self.logger.info(f"remove_older_than_maxage: item = {itempath} no remaining log entry between {time_end} and now, thus can not remove log entries older than maxage, latest log is {new_must_keep_time}") + self.logger.info(f"remove_older_: {itempath} no remaining log entry between {time_end} and now, thus can not remove log entries older than maxage, latest log is {new_must_keep_time}") time_end = new_must_keep_time + datetime.timedelta(microseconds=-1) timestamp_end = self._timestamp( time_end ) - count_log_records_to_delete = self.readLogCount(item_id,time_end=self._timestamp( time_end)) + count_log_records_to_delete = self.readLogCount(item_id, time_end=self._timestamp( time_end)) + count_log_records_to_delete_str = f"{count_log_records_to_delete:,}".replace(',','.') + max_delete_logentries_str = f"{self.max_delete_logentries:,}".replace(',','.') + time_end_str = time_end.strftime("%d.%m.%Y - %H:%M") + self.logger.debug(f"remove_older_: {itempath} remove older than {time_end_str} - {count_log_records_to_delete_str} records to delete") # prevent to many deletions with strategy b) # assumption is made that logentries are evenly distributed over time @@ -1364,23 +1566,22 @@ def remove_older_than_maxage(self): # since only a linear approximation over time and counts is used, but it should do the trick # to prevent from database lockups after setting database_maxage to old/ancient items if count_log_records_to_delete > self.max_delete_logentries: - old_count_log_records_to_delete = count_log_records_to_delete - timestamp_oldest = self.readOldestLog( item_id) - time_oldest = self._datetime( timestamp_oldest) - timespan_deletion = timestamp_end - timestamp_oldest - assumed_chunk_size = count_log_records_to_delete / self.max_delete_logentries - new_deletion_timespan = timespan_deletion / assumed_chunk_size - timestamp_end = timestamp_oldest + new_deletion_timespan - time_end = self._datetime( timestamp_end) - count_log_records_to_delete = self.readLogCount(item_id,time_end=self._timestamp( time_end)) - self.logger.info(f"remove_older_than_maxage: item = {itempath} old count to delete {old_count_log_records_to_delete} was higher than {self.max_delete_logentries}") - self.logger.info(f"remove_older_than_maxage: item = {itempath} new count to delete {count_log_records_to_delete} has end {time_end}") - - if count_log_records_to_delete: + time_start_deletion = time.time() + cur = self._db.cursor() + self._execute(self._prepare("DELETE FROM {log} WHERE item_id = :id ORDER BY time ASC LIMIT :maxrecords;"), {'id': item_id, 'maxrecords': self.max_delete_logentries}, cur=cur) + cur.close() + time_used_for_deletion = time.time() - time_start_deletion + self.logger.info(f"remove_older_: {itempath} deleted {max_delete_logentries_str} of {count_log_records_to_delete_str} log entries - took {time_used_for_deletion:.2f} seconds, averaging {100*time_used_for_deletion/self.max_delete_logentries:.4f} seconds per 100 entries") + + # Re-Add item to worklist, since there are more records to be deleted + self._maxage_worklist.append(item) + + elif count_log_records_to_delete: time_start_deletion = time.time() self.deleteLog(item_id, time_end=timestamp_end, with_commit=False) time_used_for_deletion = time.time() - time_start_deletion - self.logger.info(f"remove_older_than_maxage: item = {itempath} deleted {count_log_records_to_delete} log entries until {time_end} took {time_used_for_deletion} seconds, averaging {time_used_for_deletion/count_log_records_to_delete} per second") + time_end_str = time_end.strftime("%d.%m.%Y - %H:%M") + self.logger.info(f"remove_older_: {itempath} deleted {count_log_records_to_delete_str} log entries until {time_end_str} took {time_used_for_deletion:.2f} seconds, averaging {100*time_used_for_deletion/count_log_records_to_delete:.4f} seconds per 100 entries") # update the logCount for the item logcount = self.readLogCount(item_id) @@ -1425,6 +1626,7 @@ def _count_logentries(self): self._item_logcount[item_id] = logcount self._items_total_entries += logcount self._webdata[item.id()].update({'logcount': logcount}) + #self._webdata[item.id()].update({'logcount': f"{logcount:,}".replace(',', '.')}) self._items_still_counting = False return @@ -1435,6 +1637,7 @@ def _count_logentries(self): # ------------------------------------------ def _initialize_db(self): + # initialize main db connection try: if not self._db.connected(): # limit connection requests to 20 seconds. @@ -1453,11 +1656,34 @@ def _initialize_db(self): {i: [self._prepare(query[0]), self._prepare(query[1])] for i, query in self._setup.items()}) self._db_initialized = True except Exception as e: - #self.logger.error("Database: Initialization failed: {}".format(e)) - #return False - self.logger.critical("Database: Initialization failed: {}".format(e)) - if self.driver.lower() == "sqlite3": + if self.driver.lower() == 'sqlite3': + self._sh.restart('SmartHomeNG (Database plugin stalled)') + exit(0) + else: + return False + + # initialize db maintenance connection + try: + if not self._db_maint.connected(): + # limit connection requests to 20 seconds. + current_time = time.time() + time_delta_last_maint_connect = current_time - self.last_maint_connect_time + self.logger.debug("DEBUG: delta {0}".format(time_delta_last_maint_connect)) + if (time_delta_last_maint_connect > 20): + self.last_maint_connect_time = time.time() + self._db_maint.connect() + else: + self.logger.error("Database reconnect (maintenance connection) supressed: Delta time: {0}".format(time_delta_last_connect)) + return False + + if not self._db_maint_initialized: + self._db_maint.setup( + {i: [self._prepare(query[0]), self._prepare(query[1])] for i, query in self._setup.items()}) + self._db_maint_initialized = True + except Exception as e: + self.logger.critical("Database: Initialization of maintenance connection failed: {}".format(e)) + if self.driver.lower() == 'sqlite3': self._sh.restart('SmartHomeNG (Database plugin stalled)') exit(0) else: diff --git a/database/assets/webif1.jpg b/database/assets/webif1.jpg deleted file mode 100755 index 145a673e8..000000000 Binary files a/database/assets/webif1.jpg and /dev/null differ diff --git a/database/assets/webif1_1.jpg b/database/assets/webif1_1.jpg deleted file mode 100755 index c89cd330d..000000000 Binary files a/database/assets/webif1_1.jpg and /dev/null differ diff --git a/database/assets/webif_databaseitems.jpg b/database/assets/webif_databaseitems.jpg new file mode 100755 index 000000000..5c9145d3d Binary files /dev/null and b/database/assets/webif_databaseitems.jpg differ diff --git a/database/assets/webif_details.jpg b/database/assets/webif_details.jpg new file mode 100755 index 000000000..9af9e4972 Binary files /dev/null and b/database/assets/webif_details.jpg differ diff --git a/database/assets/webif_orphanitems.jpg b/database/assets/webif_orphanitems.jpg new file mode 100755 index 000000000..1a806132b Binary files /dev/null and b/database/assets/webif_orphanitems.jpg differ diff --git a/database/assets/webif_pluginapi.jpg b/database/assets/webif_pluginapi.jpg new file mode 100755 index 000000000..c5c23c525 Binary files /dev/null and b/database/assets/webif_pluginapi.jpg differ diff --git a/database/locale.yaml b/database/locale.yaml index 42a9103f3..c9aabfa52 100755 --- a/database/locale.yaml +++ b/database/locale.yaml @@ -1,28 +1,38 @@ plugin_translations: # Translations for the plugin specially for the web interface - 'Aktionen': {'de': '=', 'en': 'Actions'} - 'Verbunden': {'de': '=', 'en': 'Connected'} - 'Treiber ': {'de': '=', 'en': 'Driver'} - 'Ja': {'de': '=', 'en': 'Yes'} - 'Nein': {'de': '=', 'en': 'No'} - 'mit': {'de': '=', 'en': 'with'} - 'Datenbank-Dump': {'de': '=', 'en': 'Database Dump'} - 'Zeit': {'de': '=', 'en': 'Time'} - 'Item ID': {'de': '=', 'en': '='} - 'Dauer': {'de': '=', 'en': 'Duration'} - 'Geändert': {'de': '=', 'en': 'Changed'} - 'Übersicht': {'de': '=', 'en': 'Overview'} - 'am': {'de': '=', 'en': 'on the'} - 'Gesamt': {'de': '=', 'en': 'Total'} - 'Datensätze': {'de': '=', 'en': 'Data Sets'} - 'Aktueller Wert': {'de': '=', 'en': 'Recent Value'} - 'ältester Wert': {'de': '=', 'en': 'oldest Value'} - 'Typ': {'de': '=', 'en': 'Type'} - 'Tabelle': {'de': '=', 'en': 'Table'} + 'Aktionen': {'de': '=', 'en': 'Actions'} + 'Verbunden': {'de': '=', 'en': 'Connected'} + 'Treiber': {'de': '=', 'en': 'Driver'} + 'Ja': {'de': '=', 'en': 'Yes'} + 'Nein': {'de': '=', 'en': 'No'} + 'mit': {'de': '=', 'en': 'with'} + 'SQL Dump': {'de': '=', 'en': '='} + 'CSV Dump': {'de': '=', 'en': '='} + 'Zeit': {'de': '=', 'en': 'Time'} + 'Item ID': {'de': '=', 'en': '='} + 'Dauer': {'de': '=', 'en': 'Duration'} + 'Geändert': {'de': '=', 'en': 'Changed'} + 'Übersicht': {'de': '=', 'en': 'Overview'} + 'am': {'de': '=', 'en': 'on the'} + 'Gesamt': {'de': '=', 'en': 'Total'} + 'Datensätze': {'de': '=', 'en': 'Data Sets'} + 'Aktueller Wert': {'de': '=', 'en': 'Recent Value'} + 'ältester Wert': {'de': '=', 'en': 'oldest Value'} + 'Letzte Änderung': {'de': '=', 'en': 'Last change'} + 'Typ': {'de': '=', 'en': 'Type'} + 'Tabelle': {'de': '=', 'en': 'Table'} + 'Verwaistes Item': {'de': '=', 'en': 'Orphan item'} - 'Plugin-API': {'de': '=', 'en': 'Plugin API', 'fr': ''} - 'Database Items': {'de': '=', 'en': '=', 'fr': ''} - 'Parameter': {'de': '=', 'en': 'Parameters', 'fr': ''} + 'Plugin-API': {'de': '=', 'en': 'Plugin API'} + 'Database Items': {'de': '=', 'en': '='} + 'Verwaiste Items': {'de': '=', 'en': 'Orphan Items'} + 'Parameter': {'de': '=', 'en': 'Parameters'} + + 'Wert': {'de': '=', 'en': 'Value'} + 'DB-ID': {'de': '=', 'en': '='} + 'VAL_NUM': {'de': 'Wert: Numerisch', 'en': 'Value: Numeric'} + 'VAL_STR': {'de': 'Wert: String', 'en': 'Value: String'} + 'VAL_BOOL': {'de': 'Wert: Boolean', 'en': 'Value: Boolean'} 'Item-ID in der Datenbank': 'de': 'Item-ID in der DB' @@ -33,7 +43,7 @@ plugin_translations: 'Die Datenbank enthält Daten zu {dbitems} Items': 'de': '=' 'en': ' The database contains data for {dbitems} items' - 'konfiguriertes max. Altermax. Alter': + 'konfiguriertes max. Alter': 'de': '=' 'en': 'configured max. age' 'Löschauftrag für die Einträge von Item ID {item_id} in der Tabelle "log" wurde erfolgreich initiiert!': @@ -78,9 +88,12 @@ plugin_translations: 'Zeige Einträge vom': 'de': '=' 'en': 'Show entries from' - 'Datenbank-Cleanup': + 'Datenbank-Cleanup starten': + 'de': '=' + 'en': 'Start Database-Cleanup' + 'Cleanup ist aktiv': 'de': '=' - 'en': 'Database Cleanup' + 'en': 'Cleanup is active' 'Datenbank-Cleanup wurde erfolgreich initiiert!': 'de': '=' 'en': 'Database cleanup successfully initiated!' @@ -90,6 +103,9 @@ plugin_translations: 'Wollen Sie alle Datensätze ohne zugehöriges Item wirklich löschen?': 'de': '=' 'en': 'Do you really want to delete all data sets without corresponding item?' + 'Anzahl Einträge': + 'de': '=' + 'en': '# of entries' 'Anzahl Einträge in LOG Tabelle für Item': 'de': '=' 'en': 'Number of data sets in LOG table for item' diff --git a/database/notizen.txt b/database/notizen.txt new file mode 100755 index 000000000..654c5324e --- /dev/null +++ b/database/notizen.txt @@ -0,0 +1,32 @@ + +$ time sqlite3 smarthomeng_copy.db "VACUUM;" + +vorher: 12896934912 Nov 25 13:23 smarthomeng_copy.db +nachher: Abgebrochen: Error: database disk image is malformed + + + +(py_38) smarthome@SmartHomeNG:/usr/local/shng_dev/var/db$ ls -l +insgesamt 35822208 +drwxrwsr-x 2 smarthome smarthome 4096 Nov 25 12:55 alte_dbs +-rw-r--r-- 1 smarthome smarthome 25784320 Nov 22 10:53 dbtest.db +-rw-rw-r-- 1 smarthome smarthome 25784320 Nov 22 10:53 dbtest_kopie.db +-rw-rw-r-- 1 smarthome smarthome 18939904 Nov 25 13:39 dbtest_vacuumed.db +drwxrwsr-x 2 smarthome smarthome 4096 Nov 25 13:34 Sicherung +-rw-r--r-- 1 smarthome smarthome 126976 Apr 28 2020 smarthome.db +-rw-rw-r-- 1 smarthome smarthome 12896934912 Nov 25 13:23 smarthomeng_copy.db +-rw-rw-r-- 1 smarthome smarthome 12896934912 Nov 25 14:28 smarthomeng.db +-rw-rw-r-- 1 smarthome smarthome 4558032 Nov 25 14:27 smarthomeng.db-journal +-rw-r--r-- 1 smarthome smarthome 10812834330 Nov 25 14:26 smarthomeng_dump.sql +-> +-rw-rw-r-- 1 smarthome smarthome 12896934912 Nov 25 13:23 smarthomeng_copy.db +-rw-rw-r-- 1 smarthome smarthome 12896934912 Nov 25 16:12 smarthomeng.db +-rw-r--r-- 1 smarthome smarthome 10812834330 Nov 25 14:48 smarthomeng_dump.sql +-rw-r--r-- 1 smarthome smarthome 9837748224 Nov 25 16:05 smarthomeng_neu.db + +smarthomeng_neu.db: +select count() from item; +629 + +select count() from log; +140.889.225 diff --git a/database/plugin.yaml b/database/plugin.yaml index 41d5443ea..389724872 100755 --- a/database/plugin.yaml +++ b/database/plugin.yaml @@ -5,18 +5,18 @@ plugin: description: # Alternative: description in multiple languages de: 'Database plugin, mit Unterstützung für SQLite 3 und MySQL' en: 'Database plugin, with support for SQLite 3 and MySQL' - maintainer: ohinckel, msinn - tester: psilo909, onkelandy, brandst, aschwith + maintainer: ohinckel, msinn, onkelandy + tester: psilo909, brandst, aschwith state: ready keywords: database - documentation: http://smarthomeng.de/user/plugins/database/user_doc.html support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1021844-neues-database-plugin - version: 1.6.3 # Plugin version - sh_minversion: 1.8.0 # minimum shNG version to use this plugin + version: 1.6.9 # Plugin version + sh_minversion: 1.9.3.2 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: True # plugin supports multi instance restartable: unknown + startorder: early # set start priority of plugin (early/normal/late) classname: Database # class containing the plugin parameters: @@ -66,8 +66,8 @@ parameters: max_delete_logentries: type: int - default: 1500 - valid_min: 20 + default: 20000 + valid_min: 1000 description: de: "Maximal auf einmal zu löschende Anzahl an Log Einträgen mit dem database_maxage Attribut, reduziert die Belastung der Datenbank bei alten Datenbeständen" en: "Maximum number of Logentries to delete at once with database_maxage attribute, reduces load on database with old datasets" @@ -80,27 +80,18 @@ parameters: de: "Falls dieser Parameter einen Wert größer 0 enthält: Standard maxage für Items, die kein maxage gesetzt haben" en: "If this parameter is > 0: maxage for Items that don't have a maxage set." - webif_pagelength: - type: int - default: 0 - valid_list: - - -1 - - 0 - - 25 - - 50 - - 100 + copy_database: + type: bool + default: False + description: + de: "Nur für SQLite3: Auf True setzen, um beim Start von SmartHomeNG eine Kopie der Datenbank Datei zu erzeugen" + en: "For SQLite3 only: Set to True to make a copy of the database file on startup of SmartHomeNG" + + copy_database_name: + type: str description: - de: 'Anzahl an Items, die standardmäßig in einer Web Interface Tabelle pro Seite angezeigt werden. - 0 = automatisch, -1 = alle' - en: 'Amount of items being listed in a web interface table per page by default. - 0 = automatic, -1 = all' - description_long: - de: 'Anzahl an Items, die standardmäßig in einer Web Interface Tabelle pro Seite angezeigt werden.\n - Bei 0 wird die Tabelle automatisch an die Höhe des Browserfensters angepasst.\n - Bei -1 werden alle Tabelleneinträge auf einer Seite angezeigt.' - en: 'Amount of items being listed in a web interface table per page by default.\n - 0 adjusts the table height automatically based on the height of the browser windows.\n - -1 shows all table entries on one page.' + de: "Nur für SQLite3: Pfad/Name der Datenbank Kopie" + en: "For SQLite3 only: Path/Name of the copy of the database file" item_attributes: # Definition of item attributes defined by this plugin diff --git a/database/user_doc.rst b/database/user_doc.rst index 013df4b7d..d88c8ff85 100755 --- a/database/user_doc.rst +++ b/database/user_doc.rst @@ -5,7 +5,14 @@ database ======== -Database plugin, mit Unterstützung für SQLite 3 und MySQL. +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + +Database plugin, mit Unterstützung für SQLite3 und MySQL. Verwenden Sie dieses Plugin, um Itemwerte in einer Datenbank zu speichern. Es unterstützt verschiedene Datenbanken, die eine Python DB API 2 `_ Implementierung @@ -24,10 +31,10 @@ Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/co **KEIN** **instance** Attribut konfiguriert werden darf, da sonst die Systemdaten nicht gespeichert werden und Abfragen aus dem Admin Interface und der smartVISU ins Leere laufen und Fehlermeldungen produzieren. -Standarmässig schreibt das Plugin vor dem Beenden von SmarthomeNG alle am Plugin registrierten Items nochmal mit aktuellem +Standarmäßig schreibt das Plugin vor dem Beenden von SmarthomeNG alle am Plugin registrierten Items nochmal mit aktuellem Wert in die Datenbank. Die kann durch Setzen des Item Attributes database_write_on_shutdown: False unterdrückt werden. Ein typischer Anwendungsfall sind zum Beispiel monoton steigende Werte wie Zählerstände, die selten geschrieben werden -und für die doppelte Einträge durch smarthomeNG Neustarts stoerend in Datenbank und optionalen Plots in einer +und für die doppelte Einträge durch smarthomeNG Neustarts störend in Datenbank und optionalen Plots in einer Visualisierung sind. @@ -37,12 +44,6 @@ Web Interface Das database Plugin verfügt über ein Webinterface, mit dessen Hilfe die Items die das Plugin nutzen übersichtlich dargestellt werden. -.. important:: - - Das Webinterface des Plugins kann mit SmartHomeNG v1.4.2 und davor **nicht** genutzt werden. - Es wird dann nicht geladen. Diese Einschränkung gilt nur für das Webinterface. Ansonsten gilt - für das Plugin die in den Metadaten angegebene minimale SmartHomeNG Version. - Aufruf des Webinterfaces ------------------------ @@ -53,24 +54,138 @@ Zeile das Icon in der Spalte **Web Interface** anklicken. Außerdem kann das Webinterface direkt über ``http://smarthome.local:8383/database`` bzw. ``http://smarthome.local:8383/database_`` aufgerufen werden. +Das Web Interface verfügt über 3 Tabs, sowie Informationen und Buttons im Kopfbereich. Im Kopfbereich werden +Informationen zum Zustand des Plugins und zur verwendeten Datenbank angezeigt. + + +Database Items +-------------- + +Auf diesem Tab werden die Items angezeigt, für welche Daten in der Datenbank gespeichert werden. + +.. image:: assets/webif_databaseitems.jpg + :class: screenshot + +Durch einen einen Klick auf den **CSV** Button in der Zeile des Items, wird ein Download der gespeicherten Daten +zu dem Item erzeugt. -Beispiele ---------- +Auf dem Tab wird als **Wert** der letzte in der Datenbank gespeicherte Wert angezeigt. Um eine Historie zu sehen, +muss rechts in der Zeile des Items auf den Button mit der Lupe geklickt werden. -Folgende Informationen können im Webinterface angezeigt werden: +Auf der Detail-Seite wird die Liste der gespeicherten Werte zu einem Tag angezeigt. Der anzuzeigende Tag kann rechts +im Kalender gewählt werden. -Oben rechts werden allgemeine Parameter zum Plugin angezeigt. +.. image:: assets/webif_details.jpg + :class: screenshot + +Zu jedem Wert wird angezeigt, wann er gespeichert wurde und für welche Dauer er gültig war. Beim aktuellen Wert +wird als Dauer **None** angezeigt, da der Wert noch gültig ist und die Dauer daher unbekannt ist. + +Durch einen Klick auf den Button **Übersicht**, kann zur Standard Anzeige des Tabs **Database Items** zurück gekehrt +werden. + + +Plugin-API +---------- -Im ersten Tab werden die Items angezeigt, die das database Plugin nutzen: +Auf diesem Tab werden die öffentlichen Funktionen des Plugins angezeigt, die z.B. in Logiken genutzt werden können. +Diese Informationen sind auch in dieser Dokumentation auf der Seite mit den Konfigurationsdaten vorhanden. -.. image:: assets/webif1.jpg +.. image:: assets/webif_pluginapi.jpg :class: screenshot -Auf der Detailseite zu den Item Einträgen werden die geloggten Werte angezeigt: -.. image:: assets/webif1_1.jpg +Verwaiste Items +--------------- + +Dieses Tab wird nur angezeigt, wenn in der Datenbank verwaiste Items vorhanden sind. Verwaiste Items sind Items, zu +denen Informationen in der Datenbank gespeichert sind, zu denen es aber im Item Tree von SmartHomeNG keine +Entsprechungen gibt, also kein Item mit dem gleichen Pfad, welches für das database Plugin konfiguriert ist. + +.. image:: assets/webif_orphanitems.jpg :class: screenshot +Wenn die Daten zu den verwaisten Items nicht mehr benötigt werden, können diese durch klicken des Buttons +**Datenbank-Cleanup starten** gelöscht werden. + +Sollen einige Daten erhalten bleiben, so müssen die Items dazu vorher in SmartHomeNG (wieder) konfiguriert werden. + + +Export von Daten +---------------- + +Das Plugin verfügt über zwei Möglichkeiten, um Daten zu exportieren. Wobei die Zweite (SQL Dump) nur bei Verwendung +von SQLite3 zur Verfügung steht. + +Der Export wird gestartet, indem einer der beiden Buttons im Kopfbereich des Plugins geklickt wird. +Anschließend wird auf dem System auf dem SmartHomeNG läuft, lokal ein Export der Daten erzeugt und anschließend +herunter geladen. Während die Erzeugung es Exports läuft, wird im Browser ein leeres Fenster angezeigt. Das +Fenster muss bis zum Abschluss des Exports geöffnet bleiben. Der Export kann, je nach Datenbank Größe, bis +zu über einer Stunde dauern. Nach Abschluss des Exports wird die Datei herunter geladen und im Fenster wird wieder das +Web Interface des database Plugins angezeigt. + + +CSV Dump +~~~~~~~~ + +Durch einen Klick auf den Button **CSV Dump** wird ein vollständiger Dump der in der Datenbank gespeicherten +Informationen erzeugt und im Browser runter geladen. + +Die Daten in der heruntergeladenen Datei haben folgende Struktur: + +.. code-block:: text + + item_id;item_name;time;duration;val_str;val_num;val_bool;changed;time_date;changed_date + 3;wohnung.kochen.kochfeldg.ma;1606258889619;17998;;217.0;1;1606258947266;2020-11-25 00:01:29.619000;2020-11-25 00:02:27.266000 + 3;wohnung.kochen.kochfeldg.ma;1606258907617;17993;;216.0;1;1606258947266;2020-11-25 00:01:47.617000;2020-11-25 00:02:27.266000 + 3;wohnung.kochen.kochfeldg.ma;1606258925610;5996;;217.0;1;1606258947266;2020-11-25 00:02:05.610000;2020-11-25 00:02:27.266000 + 3;wohnung.kochen.kochfeldg.ma;1606258931606;18006;;216.0;1;1606259007370;2020-11-25 00:02:11.606000;2020-11-25 00:03:27.370000 + 3;wohnung.kochen.kochfeldg.ma;1606258949612;5993;;217.0;1;1606259007370;2020-11-25 00:02:29.612000;2020-11-25 00:03:27.370000 + 3;wohnung.kochen.kochfeldg.ma;1606258955605;30001;;216.0;1;1606259007370;2020-11-25 00:02:35.605000;2020-11-25 00:03:27.370000 + 3;wohnung.kochen.kochfeldg.ma;1606258985606;53991;;217.0;1;1606259067523;2020-11-25 00:03:05.606000;2020-11-25 00:04:27.523000 + 3;wohnung.kochen.kochfeldg.ma;1606259039597;24006;;216.0;1;1606259067523;2020-11-25 00:03:59.597000;2020-11-25 00:04:27.523000 + 3;wohnung.kochen.kochfeldg.ma;1606259063603;11984;;217.0;1;1606259127224;2020-11-25 00:04:23.603000;2020-11-25 00:05:27.224000 + +Es handelt sich hierbei um einen reinen Dump der Daten, nicht um ein Abbild der Datenbank Struktur. + + +SQL Dump +~~~~~~~~ + +Im Gegensatz zum CSV Dump, wird bei einem SQL Dump die vollständige Datenbank (Daten und Struktur) herunter geladen. +Diese Funktion steht allerdings nur bei Nutzung einer SQLite3 Datenbank zur Verfügung. + +Die heruntergeladene Datei hat dabei folgendes Format: + +.. code-block:: text + + BEGIN TRANSACTION; + CREATE TABLE database_version(version NUMERIC, updated BIGINT, rollout TEXT, rollback TEXT); + INSERT INTO "database_version" VALUES(1,1518289184830,'CREATE TABLE log (time BIGINT, item_id INTEGER, duration BIGINT, val_str TEXT, val_num REAL, val_bool BOOLEAN, changed BIGINT);','DROP TABLE log;'); + INSERT INTO "database_version" VALUES(2,1518289184835,'CREATE TABLE item (id INTEGER, name varchar(255), time BIGINT, val_str TEXT, val_num REAL, val_bool BOOLEAN, changed BIGINT);','DROP TABLE item;'); + INSERT INTO "database_version" VALUES(3,1518289184840,'CREATE UNIQUE INDEX log_item_id_time ON log (item_id, time);','DROP INDEX log_item_id_time;'); + INSERT INTO "database_version" VALUES(4,1518289184845,'CREATE INDEX log_item_id_changed ON log (item_id, changed);','DROP INDEX log_item_id_changed;'); + INSERT INTO "database_version" VALUES(5,1518289184849,'CREATE UNIQUE INDEX item_id ON item (id);','DROP INDEX item_id;'); + INSERT INTO "database_version" VALUES(6,1518289184854,'CREATE INDEX item_name ON item (name);','DROP INDEX item_name;'); + CREATE TABLE item (id INTEGER, name varchar(255), time BIGINT, val_str TEXT, val_num REAL, val_bool BOOLEAN, changed BIGINT); + INSERT INTO "item" VALUES(3,'wohnung.kochen.kochfeldg.ma',1669554322161,NULL,202.0,1,1669554363596); + + ... + + INSERT INTO "log" VALUES(1669557938064,101,NULL,NULL,527.0,1,1669557938992); + INSERT INTO "log" VALUES(1669557928298,105,NULL,NULL,230.0,1,1669557939008); + INSERT INTO "log" VALUES(1669557928356,107,NULL,NULL,227.0,1,1669557939032); + INSERT INTO "log" VALUES(1669557906685,1446,NULL,'1.45',NULL,1,1669557939063); + INSERT INTO "log" VALUES(1669557906694,1447,NULL,'1.45',NULL,1,1669557939071); + CREATE UNIQUE INDEX log_item_id_time ON log (item_id, time); + CREATE INDEX log_item_id_changed ON log (item_id, changed); + CREATE UNIQUE INDEX item_id ON item (id); + CREATE INDEX item_name ON item (name); + COMMIT; + +Das herunter geladene SQL Skript kann in eine leere Datenbank importiert werden. Dieses kann zum Beispiel zum +Verkleinern des Datenbank Datei nach dem Löschen einer größeren Menge von Daten genutzt werden. + Aufbau der Datenbank ==================== @@ -94,13 +209,13 @@ Die `item` Tabelle enthält die folgenden Spalten: Die `log` Tabelle enthält die folgenden Spalten: * Column `time` - Ein UNIX Zeitstempel in eine Auflösung von Mikrosekunden - * Column `item_id` - Eine Referenz auf eine eindeutige Kennung eines Items in der Tabelle ìtem + * Column `item_id` - Eine Referenz auf eine eindeutige Kennung eines Items in der Tabelle `item` * Column `duration` - Die Dauer in Mikrosekunden * Column `val_str` - Der Itemwert als Zeichenkette wenn das Item den Typ `str` hat * Column `val_num` - Der Itemwert als Zahl, wenn das Item den Typ `num` hat * Column `val_bool` - Der Itemwert als Wahrheitswert, das Item den Typ `bool` oder `num` hat * Column `changed` - Ein UNIX Zeitstempel (in einer Auflösung von Mikrosekunden) der letzen Änderung -Es gibt aktuell nur eine Möglichkeit die Anzahl der Datensätze pro Item zu begrenzen: +Es gibt aktuell nur eine Möglichkeit die Anzahl der Datensätze pro Item zu begrenzen: Durch die Angabe des Item Attributs ``database_maxage`` wird das maximale Alter der Einträge eines Items begrenzt. -Regelmässig werden Werte deren Zeitstempel älter ist als die angegebene Zeitspanne aus der Datenbank gelöscht. \ No newline at end of file +Regelmässig werden Werte deren Zeitstempel älter ist als die angegebene Zeitspanne aus der Datenbank gelöscht. diff --git a/database/webif/__init__.py b/database/webif/__init__.py index 1a70c7f39..9c9178391 100755 --- a/database/webif/__init__.py +++ b/database/webif/__init__.py @@ -71,10 +71,8 @@ def index(self, reload=None, action=None, item_id=None, item_path=None, time_end :return: contents of the template after beeing rendered """ - try: - pagelength = self.plugin.webif_pagelength - except Exception: - pagelength = 100 + # try to get the webif pagelength from the module.yaml configuration + pagelength = self.plugin.get_parameter_value('webif_pagelength') if item_path is not None: item = self.plugin.items.return_item(item_path) delete_triggered = False @@ -229,17 +227,49 @@ def download_complete(self): @cherrypy.expose - def db_dump(self): + def db_csvdump(self): """ - returns the smarthomeNG sqlite database as download + returns the smarthomeNG database as download in csv format """ - self.plugin.dump( - '%s/var/db/smarthomedb_%s.dump' % (self.plugin.get_sh().base_dir, self.plugin.get_instance_name())) + filename = 'smarthomeng' + extension = '_dump.csv' + if self.plugin.get_instance_name() == '': + filename += extension + else: + filename += '_' + self.plugin.get_instance_name() + extension + pathname = os.path.join(self.plugin.get_sh().base_dir, 'var', 'db', filename) + + self.plugin.dump(pathname) + #self.plugin.dump( + # '%s/var/db/smarthomedb_%s.dump' % (self.plugin.get_sh().base_dir, self.plugin.get_instance_name())) + mime = 'application/octet-stream' - return cherrypy.lib.static.serve_file( - "%s/var/db/smarthomedb_%s.dump" % (self.plugin.get_sh().base_dir, self.plugin.get_instance_name()), - mime, "%s/var/db/" % self.plugin.get_sh().base_dir) + # disposition should bie 'attachment' or 'inline' + return cherrypy.lib.static.serve_file(pathname, mime, disposition='attachment', name=filename) + #return cherrypy.lib.static.serve_file( + # "%s/var/db/smarthomedb_%s.dump" % (self.plugin.get_sh().base_dir, self.plugin.get_instance_name()), + # mime, "%s/var/db/" % self.plugin.get_sh().base_dir) + + @cherrypy.expose + def db_sqldump(self): + """ + returns the smarthomeNG sqlite database as download of a complete sql dump + """ + filename = 'smarthomeng' + extension = '_dump.sql' + if self.plugin.get_instance_name() == '': + filename += extension + else: + filename += '_' + self.plugin.get_instance_name() + extension + pathname = os.path.join(self.plugin.get_sh().base_dir, 'var', 'db', filename) + + if self.plugin.sqlite_dump(pathname): + mime = 'application/octet-stream' + # disposition should bie 'attachment' or 'inline' + return cherrypy.lib.static.serve_file(pathname, mime, disposition='attachment', name=filename) + + return @cherrypy.expose diff --git a/database/webif/templates/base_database.html b/database/webif/templates/base_database.html index 3e57efc8e..e477d155a 100755 --- a/database/webif/templates/base_database.html +++ b/database/webif/templates/base_database.html @@ -1,48 +1,81 @@ {% extends "base_plugin.html" %} - {% set logo_frame = false %} - - -{%- block scripts %} -{{ super() }} +{%- block pluginscripts %} -{%- endblock scripts %} - -{%- block pluginscripts %} - {%- endblock pluginscripts %} -{%- block styles %} -{{ super() }} +{%- block pluginstyles %} - -{%- endblock styles %} +{%- endblock pluginstyles %} {% block headtable %} - @@ -106,6 +141,14 @@ + + + + + + + + {% set first = True %} {% for key, value in p._db._params.items() %} {% if loop.index % 4 == 0 %} @@ -114,7 +157,8 @@ {% if key != "passwd" %} {% else %} - + + {% endif %} {% if loop.index % 3 > 0 and loop.last %} diff --git a/database/webif/templates/index.html b/database/webif/templates/index.html index cffe671b7..79206e7d7 100755 --- a/database/webif/templates/index.html +++ b/database/webif/templates/index.html @@ -1,7 +1,38 @@ {% extends "base_database.html" %} -{% set update_interval = 10000 %} +{% set update_interval = [(((10 * (p._handled_items | length)) / 1000) | round | int) * 1000, 5000]|max %} + {% set dataSet = 'overview' %} {% set tab1title = _('Database Items') %} + +{%- block pluginscripts %} +{{ super() }} + +{%- endblock pluginscripts %} + {% block bodytab1 %} -
{{ _('Die folgenden {items} Items sind dieser Instanz zugewiesen, {with_maxage} Items haben ein maxage.', vars={'items': len(p._handled_items), 'with_maxage': len(p._items_with_maxage)}) }} @@ -80,17 +86,17 @@
{{ _('Cleanup ist aktiv') }}{% if p.remove_orphan %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %}
{{ key }}{{ value }}{{ key }}{% for letter in value %}*{% endfor %}{{ key }}{% for letter in value %}*{% endfor %}
- - - - - - + + + + + + {% if p.count_logentries %} - + {% endif %} - - + + @@ -110,24 +116,25 @@ {% endif %} {% endif %} - - - - - + + + + + + {% if p.count_logentries %} - + {% endif %} {% if p.get_iattr_value(item.conf, 'database_maxage') %} - + {% else %} {% if p._default_maxage > 0 %} - + {% else %} - + {% endif %} {% endif %} -
{{ _('Item') }}{{ _('Init') }}{{ _('Typ') }}{{ _('Wert') }}{{ _('Item-ID in der Datenbank') }}
{{ _('Item') }}{{ _('Init') }}{{ _('Typ') }}{{ _('Wert') }}{{ _('DB-ID') }}{{ _('Anzahl Einträge') }}{{ _('Anzahl Einträge') }}{{ _('konfiguriertes max. Alter') }}{{ _('Aktionen') }}{{ _('konfiguriertes max. Alter') }}{{ _('Aktionen') }}
{{ item.property.path }}{{ _('Init') if p.get_iattr_value(item.conf, 'database').lower() == 'init' else '-'}}{{ item.property.type }}{{ val }}{{ p.id(item, create=False) }}{{ item.property.path }}{{ _('Init') if p.get_iattr_value(item.conf, 'database').lower() == 'init' else '-'}}{{ item.property.type }}{{ val }}{{ p.id(item, create=False) }}{{ p._item_logcount[p.id(item, create=False)] }}{{ p._item_logcount[p.id(item, create=False)] }}{{ p.get_iattr_value(item.conf, 'database_maxage') }} {{ _('Tage') }}: {{ p.get_maxage_ts(item).strftime('%d.%m.%Y %H:%M') }}{{ p.get_iattr_value(item.conf, 'database_maxage') }} {{ _('Tage') }}: {{ p.get_maxage_ts(item).strftime('%d.%m.%Y %H:%M') }}default: {{ p._default_maxage }} {{ _('Tage') }}default: {{ p._default_maxage }} {{ _('Tage') }}-- + {% if p.id(item, create=False) == None %} {% else %} @@ -144,7 +151,64 @@ {% endblock bodytab1 %} + +{% if len(p.orphanlist) > 0 %} + {% set tabcount = 3 %} +{% endif %} + +{% set tab3title = _('Verwaiste Items') %} +{% block bodytab3 %} +
+
+ {% if p.remove_orphan or len(p.orphanlist) == 0 %} + + {% else %} + + {% endif %} +
+ + + + + + + + + {% if p.count_logentries %} + + {% endif %} + + + + {% for item in p.orphanlist %} + + + + + + + {% if p.count_logentries %} + + {% endif %} + + {% endfor %} + +
{{ _('Verwaistes Item') }}{{ _('Letzte Änderung') }}{{ _('Typ') }}{{ _('DB-ID') }}{{ _('Anzahl Einträge') }}
{{ item }}{% if language == 'en' %}{{ p.db_lastchange(item).strftime('%m/%d/%Y %H:%M:%S') }}{% else %}{{ p.db_lastchange(item).strftime('%d.%m.%Y %H:%M:%S') }}{% endif %}{{ _(p.db_itemtype(item)) }}{{ p.id(item, create=False) }}{{ p._orphan_logcount[p.id(item, create=False)] }}
+
+{% endblock bodytab3 %} + {% block buttons %} - - +{% if p.driver == 'sqlite3' %} + +{% endif %} + + + + {% endblock buttons %} diff --git a/database/webif/templates/item_details.html b/database/webif/templates/item_details.html index 5a18974a6..d9bf28d4d 100755 --- a/database/webif/templates/item_details.html +++ b/database/webif/templates/item_details.html @@ -1,7 +1,7 @@ {% extends "base_database.html" %} {% set dataSet = 'item_details' %} {% set update_params = item_id %} -{% set update_interval = (200 * (log_array | length)) %} +{% set update_interval = (((600 * (log_array | length)) / 1000) | round | int) * 1000 %} {% set tab1title = _('Database Items')+' ('+item_path+')' %} {% block buttons %} {{ super() }} @@ -77,30 +77,31 @@
- - - - - {% if item.property.type == 'num' %}{% endif %} - {% if item.property.type == 'bool' %}{% endif %} - {% if item.property.type == 'str' %}{% endif %} - {% if item.property.type == 'foo' %}{% endif %} - - + + + + + {% if item.property.type == 'num' %}{% endif %} + {% if item.property.type == 'bool' %}{% endif %} + {% if item.property.type == 'str' %}{% endif %} + {% if item.property.type == 'foo' %}{% endif %} + + {% for data in log_array %} - - - + + + + {% if item.property.type == 'num' %}{% endif %} {% if item.property.type == 'bool' %}{% endif %} {% if item.property.type == 'str' %}{% endif %} {% if item.property.type == 'foo' %}{% endif %} - - + - + + + + @@ -71,66 +292,39 @@ -{% set tabcount = 1 %} +{% set tabcount = 2 %} {% set start_tab = 1 %} + -{% set tab1title = "" ~ p.get_shortname() ~ " " ~ _(('Geräte')) ~ "" %} +{% set tab1title = "" ~ p.get_shortname() ~ " " ~ _(('Items')) ~ " (" ~ p._plg_item_dict|length ~ ")" %} {% block bodytab1 %} -
- -{% if p._buses|length %} -
{{ p._buses|length }}-{{ _('Bus gefunden') }}
-
{{ _('Item ID') }}{{ _('Zeit') }}{{ _('Dauer') }} (sec)VAL_NUMVAL_BOOLVAL_STRVAL_STR{{ _('Geändert') }}{{ _('Aktionen') }}
{{ _('DB-ID') }}{{ _('Zeit') }}{{ _('Dauer') }} (sec){{ _('VAL_NUM') }}{{ _('VAL_BOOL') }}{{ _('VAL_STR') }}{{ _('VAL_STR') }}{{ _('Geändert') }}{{ _('Aktionen') }}
{{ data[1] }}{% if language == 'en' %}{{ data[0].strftime('%m/%d/%Y %H:%M:%S') }}{% else %}{{ data[0].strftime('%d.%m.%Y %H:%M:%S') }}{% endif %}{% if p._seconds(data[2]) == none %}{{ p._seconds(data[2]) }}{% else %}{{ ("%.2f"|format(p._seconds(data[2])|float)).rstrip('0').rstrip('.') }}{% endif %}{{ data[1] }}{% if language == 'en' %}{{ data[0].strftime('%m/%d/%Y %H:%M:%S') }}{% else %}{{ data[0].strftime('%d.%m.%Y %H:%M:%S') }}{% endif %}{% if p._seconds(data[2]) == none %}{{ p._seconds(data[2]) }}{% else %}{{ ("%.2f"|format(p._seconds(data[2])|float)).rstrip('0').rstrip('.') }}{% endif %}{{ ("%.2f"|format(data[4]|float)).rstrip('0').rstrip('.') }}{{ data[5] }}{{ data[3] }}{{ data[3] }}{% if language == 'en' %}{{ data[6].strftime('%m/%d/%Y %H:%M:%S') }}{% else %}{{ data[6].strftime('%d.%m.%Y %H:%M:%S') }}{% endif %} + {% if language == 'en' %}{{ data[6].strftime('%m/%d/%Y %H:%M:%S') }}{% else %}{{ data[6].strftime('%d.%m.%Y %H:%M:%S') }}{% endif %} {% if loop.index < 3 and (day is none or (day+" "+month+" "+year == now.strftime('%d %m %Y'))) %} {% set is_disabled = true %} {% else %} @@ -130,98 +131,98 @@ $('#datepicker').datepicker('update', '{% if day and month and year %}{% if language == "en" %}{{ month }}/{{ day }}/{{ year }}{% else %}{{ day }}.{{ month }}.{{ year }}{% endif %}{% else %}{% if language == "en" %}{{ now.strftime('%m/%d/%Y') }}{% else %}{{ now.strftime('%d.%m.%Y') }}{% endif %}{% endif %}'); function handleUpdatedData(response, dataSet) { - if (dataSet === 'item_details') { - objResponse = JSON.parse(response); - myProto = document.getElementById(dataSet); - if (checkDate()) { - if ( $.fn.dataTable.isDataTable('#item_details_table') ) { - item_details_table = $('#item_details_table').DataTable(); - } - // It's necessary to completely clear the current table and refill it later on. - item_details_table.clear(); - let i = 1; - let now = new Date(); - let now_day = String(now.getDate()).padStart(2, '0'); - let now_month = String((now.getMonth()+1)).padStart(2, '0'); - let now_year = String(now.getFullYear()).padStart(2, '0'); - let now_date = now_day + " " + now_month + " " + now_year; - for (item in objResponse) { - let item_id = objResponse[item][1]; - let item_path = "{{ item_path }}"; + if (dataSet === 'item_details') { + objResponse = JSON.parse(response); + myProto = document.getElementById(dataSet); + if (checkDate()) { + if ( $.fn.dataTable.isDataTable('#item_details_table') ) { + item_details_table = $('#item_details_table').DataTable(); + } + // It's necessary to completely clear the current table and refill it later on. + item_details_table.clear(); + let i = 1; + let now = new Date(); + let now_day = String(now.getDate()).padStart(2, '0'); + let now_month = String((now.getMonth()+1)).padStart(2, '0'); + let now_year = String(now.getFullYear()).padStart(2, '0'); + let now_date = now_day + " " + now_month + " " + now_year; + for (item in objResponse) { + let item_id = objResponse[item][1]; + let item_path = "{{ item_path }}"; - let date = new Date(Date.parse(objResponse[item][0])); - let day = String(date.getDate()).padStart(2, '0'); - let month = String((date.getMonth()+1)).padStart(2, '0'); - let year = String(date.getFullYear()).padStart(2, '0'); - let time = String(date.getHours()).padStart(2, '0')+ - ":"+String(date.getMinutes()).padStart(2, '0')+ - ":"+String(date.getSeconds()).padStart(2, '0'); - let date_time; - let is_disabled = null; - if (i < 3 && (day == null || (day+" "+month+" "+year == now_date))) - is_disabled = true; - else - is_disabled = false; + let date = new Date(Date.parse(objResponse[item][0])); + let day = String(date.getDate()).padStart(2, '0'); + let month = String((date.getMonth()+1)).padStart(2, '0'); + let year = String(date.getFullYear()).padStart(2, '0'); + let time = String(date.getHours()).padStart(2, '0')+ + ":"+String(date.getMinutes()).padStart(2, '0')+ + ":"+String(date.getSeconds()).padStart(2, '0'); + let date_time; + let is_disabled = null; + if (i < 3 && (day == null || (day+" "+month+" "+year == now_date))) + is_disabled = true; + else + is_disabled = false; - {% if language == 'en' %} - date_time = month+"/"+day+"/"+year+" "+time; - {% else %} - date_time = day + "." + month + "." + year + " " + time; - {% endif %} - let orig_duration = objResponse[item][2]; - let duration = Math.round(objResponse[item][2]/1000 * 10) / 10; - if (orig_duration == null) { - orig_duration = "None"; - duration = "None"; - } - let value; - {% if item.property.type == 'num' %} - var tenToN = 10 ** 2; - value = (Math.round(objResponse[item][4] * tenToN)) / tenToN; - {% endif %} - {% if item.property.type == 'bool' %} value = objResponse[item][5];{% endif %} - {% if item.property.type == 'str' %} value = objResponse[item][3];{% endif %} - {% if item.property.type == 'foo' %} value = objResponse[item][3];{% endif %} - let changed = new Date(Date.parse(objResponse[item][6])); - let changed_day = String(changed.getDate()).padStart(2, '0'); - let changed_month = String((changed.getMonth()+1)).padStart(2, '0'); - let changed_year = String(changed.getFullYear()).padStart(2, '0'); - let changed_time = String(changed.getHours()).padStart(2, '0')+ - ":"+String(changed.getMinutes()).padStart(2, '0')+ - ":"+String(changed.getSeconds()).padStart(2, '0'); - let changed_date_time; - let buttons = ""; - let day_month_year = ""; - if (day && month && year) day_month_year = "&day=" + day + "&month=" + month + "&year=" + year; - if (is_disabled) - { - buttons = ""; - } + {% if language == 'en' %} + date_time = month+"/"+day+"/"+year+" "+time; + {% else %} + date_time = day + "." + month + "." + year + " " + time; + {% endif %} + let orig_duration = objResponse[item][2]; + let duration = Math.round(objResponse[item][2]/1000 * 10) / 10; + if (orig_duration == null) { + orig_duration = "None"; + duration = "None"; + } + let value; + {% if item.property.type == 'num' %} + var tenToN = 10 ** 2; + value = (Math.round(objResponse[item][4] * tenToN)) / tenToN; + {% endif %} + {% if item.property.type == 'bool' %} value = objResponse[item][5];{% endif %} + {% if item.property.type == 'str' %} value = objResponse[item][3];{% endif %} + {% if item.property.type == 'foo' %} value = objResponse[item][3];{% endif %} + let changed = new Date(Date.parse(objResponse[item][6])); + let changed_day = String(changed.getDate()).padStart(2, '0'); + let changed_month = String((changed.getMonth()+1)).padStart(2, '0'); + let changed_year = String(changed.getFullYear()).padStart(2, '0'); + let changed_time = String(changed.getHours()).padStart(2, '0')+ + ":"+String(changed.getMinutes()).padStart(2, '0')+ + ":"+String(changed.getSeconds()).padStart(2, '0'); + let changed_date_time; + let buttons = ""; + let day_month_year = ""; + if (day && month && year) day_month_year = "&day=" + day + "&month=" + month + "&year=" + year; + if (is_disabled) + { + buttons = ""; + } - else - { - buttons = ""; - } - {% if language == 'en' %} - changed_date_time = changed_month+"/"+changed_day+"/"+changed_year+" "+changed_time; - {% else %} - changed_date_time = changed_day + "." + changed_month + "." + changed_year + " " + changed_time; - {% endif %} - let entry_id = objResponse[item][0].replace("T", " "); - let newRow = item_details_table.row.add( [ item_id, date_time, duration, value, changed_date_time, buttons ] ).draw(false).node(); - newRow.id = entry_id+"_"+orig_duration; - $('td:eq(0)', newRow).attr('id', entry_id+'_'+orig_duration+'_id'); - $('td:eq(1)', newRow).attr('id', entry_id+'_'+orig_duration+'_time'); - $('td:eq(2)', newRow).attr('id', entry_id+'_'+orig_duration+'_duration').addClass('duration'); - $('td:eq(3)', newRow).attr('id', entry_id+'_'+orig_duration+'_value'); - $('td:eq(4)', newRow).attr('id', entry_id+'_'+orig_duration+'_changed'); - $('td:eq(5)', newRow).attr('id', entry_id+'_'+orig_duration+'_buttons'); - i += 1; - console.log("Added new row: " + entry_id+"_"+orig_duration); - } - item_details_table.draw(false); + else + { + buttons = ""; + } + {% if language == 'en' %} + changed_date_time = changed_month+"/"+changed_day+"/"+changed_year+" "+changed_time; + {% else %} + changed_date_time = changed_day + "." + changed_month + "." + changed_year + " " + changed_time; + {% endif %} + let entry_id = objResponse[item][0].replace("T", " "); + let newRow = item_details_table.row.add( [ null, item_id, date_time, duration, value, changed_date_time, buttons ] ).draw(false).node(); + newRow.id = entry_id+"_"+orig_duration; + $('td:eq(1)', newRow).attr('id', entry_id+'_'+orig_duration+'_id'); + $('td:eq(2)', newRow).attr('id', entry_id+'_'+orig_duration+'_time'); + $('td:eq(3)', newRow).attr('id', entry_id+'_'+orig_duration+'_duration').addClass('duration'); + $('td:eq(4)', newRow).attr('id', entry_id+'_'+orig_duration+'_value'); + $('td:eq(5)', newRow).attr('id', entry_id+'_'+orig_duration+'_changed'); + $('td:eq(6)', newRow).attr('id', entry_id+'_'+orig_duration+'_buttons'); + i += 1; + console.log("Added new row: " + entry_id+"_"+orig_duration); } + item_details_table.draw(false); } } + } {% endblock bodytab1 %} diff --git a/db_addon/__init__.py b/db_addon/__init__.py new file mode 100644 index 000000000..82d733f47 --- /dev/null +++ b/db_addon/__init__.py @@ -0,0 +1,2874 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2022- Michael Wenzel wenzel_michael@web.de +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# This plugin provides additional functionality to mysql database +# connected via database plugin +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +from lib.model.smartplugin import SmartPlugin +from lib.item import Items +from lib.item.item import Item +from lib.shtime import Shtime +from lib.plugin import Plugins +from .webif import WebInterface +import lib.db + +import sqlvalidator +import datetime +import time +import re +import queue +from dateutil.relativedelta import relativedelta +from typing import Union +import threading + +DAY = 'day' +WEEK = 'week' +MONTH = 'month' +YEAR = 'year' + + +class DatabaseAddOn(SmartPlugin): + """ + Main class of the Plugin. Does all plugin specific stuff and provides the update functions for the items + """ + + PLUGIN_VERSION = '1.0.0' + + def __init__(self, sh): + """ + Initializes the plugin. + """ + + # Call init code of parent class (SmartPlugin) + super().__init__() + + # get item and shtime instance + self.shtime = Shtime.get_instance() + self.items = Items.get_instance() + self.plugins = Plugins.get_instance() + + # define properties // cache dicts + self.current_values = {} # Dict to hold min and max value of current day / week / month / year for items + self.previous_values = {} # Dict to hold value of end of last day / week / month / year for items + self.item_cache = {} # Dict to hold item_id, oldest_log_ts and oldest_entry for items + + # define properties // database, database connection, working queue and status + self.item_queue = queue.Queue() # Queue containing all to be executed items + self.work_item_queue_thread = None # Working Thread for queue + self._db_plugin = None # object if database plugin + self._db = None # object of database + self.connection_data = None # connection data list of database + self.db_driver = None # driver of the used database + self.db_instance = None # instance of the used database + self.item_attribute_search_str = 'database' # attribute, on which an item configured for database can be identified + self.last_connect_time = 0 # mechanism for limiting db connection requests + self.alive = None # Is plugin alive? + self.startup_finished = False # Startup of Plugin finished + self.suspended = False # Is plugin activity suspended + self._active_queue_item = '-' # String holding item path of currently executed item + + # define properties // Debugs + self.parse_debug = False # Enable / Disable debug logging for method 'parse item' + self.execute_debug = False # Enable / Disable debug logging for method 'execute items' + self.sql_debug = False # Enable / Disable debug logging for sql stuff + self.onchange_debug = False # Enable / Disable debug logging for method 'handle_onchange' + self.prepare_debug = False # Enable / Disable debug logging for query preparation + + # define properties // default mysql settings + self.default_connect_timeout = 60 + self.default_net_read_timeout = 60 + + # define properties // plugin parameters + self.db_configname = self.get_parameter_value('database_plugin_config') + self.startup_run_delay = self.get_parameter_value('startup_run_delay') + self.ignore_0 = self.get_parameter_value('ignore_0') + self.use_oldest_entry = self.get_parameter_value('use_oldest_entry') + + # init cache dicts + self._init_cache_dicts() + + # activate debug logger + if self.log_level == 10: # info: 20 debug: 10 + self.parse_debug = True + self.execute_debug = True + self.sql_debug = True + self.onchange_debug = True + self.prepare_debug = True + + # init webinterface + self.init_webinterface(WebInterface) + + def run(self): + """ + Run method for the plugin + """ + + self.logger.debug("Run method called") + + # check existence of db-plugin, get parameters, and init connection to db + if not self._check_db_existence(): + self.logger.error(f"Check of existence of database plugin incl connection check failed. Plugin not loaded") + return self.deinit() + + self._db = lib.db.Database("DatabaseAddOn", self.db_driver, self.connection_data) + if not self._db.api_initialized: + self.logger.error("Initialization of database API failed") + return self.deinit() + + self.logger.debug("Initialization of database API successful") + + # init db + if not self._initialize_db(): + return self.deinit() + + # check db connection settings + if self.db_driver is not None and self.db_driver.lower() == 'pymysql': + self._check_db_connection_setting() + + # add scheduler for cyclic trigger item calculation + self.scheduler_add('cyclic', self.execute_due_items, prio=3, cron='5 0 0 * * *', cycle=None, value=None, offset=None, next=None) + + # add scheduler to trigger items to be calculated at startup with delay + dt = self.shtime.now() + datetime.timedelta(seconds=(self.startup_run_delay + 3)) + self.logger.info(f"Set scheduler for calculating startup-items with delay of {self.startup_run_delay + 3}s to {dt}.") + self.scheduler_add('startup', self.execute_startup_items, next=dt) + + # set plugin to alive + self.alive = True + + # start the queue consumer thread + self._work_item_queue_thread_startup() + + def stop(self): + """ + Stop method for the plugin + """ + + self.logger.debug("Stop method called") + self.alive = False + self.scheduler_remove('cyclic') + self._work_item_queue_thread_shutdown() + + def parse_item(self, item: Item): + """ + Default plugin parse_item method. Is called when the plugin is initialized. + + The plugin can, corresponding to its attribute keywords, decide what to do with the item in the future, like adding it to an internal array for future reference + :param item: The item to process. + :return: If the plugin needs to be informed of an items change you should return a call back function + like the function update_item down below. An example when this is needed is the knx plugin + where parse_item returns the update_item function when the attribute knx_send is found. + This means that when the items value is about to be updated, the call back function is called + with the item, caller, source and dest as arguments and in case of the knx plugin the value + can be sent to the knx with a knx write function within the knx plugin. + """ + + def get_database_item() -> Item: + """ + Returns item from shNG config which is an item with database attribut valid for current db_addon item + """ + + _lookup_item = item + + for i in range(3): + if self.has_iattr(_lookup_item.conf, self.item_attribute_search_str): + return _lookup_item + else: + self.logger.debug(f"Attribut '{self.item_attribute_search_str}' has not been found for item={item.path()} {i + 1} level above item.") + _lookup_item = _lookup_item.return_parent() + + def get_db_addon_item() -> bool: + """ + Returns item from shNG config which is item with db_addon attribut valid for database item + + """ + + for child in item.return_children(): + if _check_db_addon_fct(child): + return True + + for child_child in child.return_children(): + if _check_db_addon_fct(child_child): + return True + + for child_child_child in child_child.return_children(): + if _check_db_addon_fct(child_child_child): + return True + + return False + + def _check_db_addon_fct(check_item) -> bool: + if self.has_iattr(check_item.conf, 'db_addon_fct'): + __db_addon_fct = self.get_iattr_value(check_item.conf, 'db_addon_fct').lower() + if onchange_attribute(__db_addon_fct): + self.logger.debug(f"db_addon item for database item {item.id()} found.") + return True + return False + + # handle all items with db_addon_fct + if self.has_iattr(item.conf, 'db_addon_fct'): + + if self.parse_debug: + self.logger.debug(f"parse item: {item.id()} due to 'db_addon_fct'") + + # get attribute value + _db_addon_fct = self.get_iattr_value(item.conf, 'db_addon_fct').lower() + + # get attribute if item should be calculated at plugin startup + _db_addon_startup = self.get_iattr_value(item.conf, 'db_addon_startup') + + # get attribute if certain value should be ignored at db query + if self.has_iattr(item.conf, 'database_ignore_value'): + _db_addon_ignore_value = self.get_iattr_value(item.conf, 'database_ignore_value') + elif any(x in str(item.id()) for x in self.ignore_0): + _db_addon_ignore_value = 0 + else: + _db_addon_ignore_value = None + + # get database item + _database_item = get_database_item() + + # return if no database_item + if _database_item is None: + self.logger.warning(f"No database item found for {item.id()}: Item ignored. Maybe you should check instance of database plugin.") + return + + # create items configs + item_config_data_dict = {'db_addon': 'function', 'attribute': _db_addon_fct, 'database_item': _database_item, 'ignore_value': _db_addon_ignore_value} + _update_cycle = None + + if self.parse_debug: + self.logger.debug(f"Item '{item.id()}' added with db_addon_fct={_db_addon_fct} and database_item={_database_item.id()}") + + # handle items with for daily run + if daily_attribute(_db_addon_fct): + _update_cycle = 'daily' + + # handle items for weekly + elif weekly_attribute(_db_addon_fct): + _update_cycle = 'weekly' + + # handle items for monthly run + elif monthly_attribute(_db_addon_fct): + _update_cycle = 'monthly' + + # handle items for yearly run + elif yearly_attribute(_db_addon_fct): + _update_cycle = 'yearly' + + # handle static items starting with 'general_' + elif _db_addon_fct.startswith('general_'): + _update_cycle = 'static' + + # handle all functions with 'summe' like waermesumme, kaeltesumme, gruenlandtemperatursumme + elif 'summe' in _db_addon_fct: + if not self.has_iattr(item.conf, 'db_addon_params'): + self.logger.warning(f"Item '{item.id()}' with db_addon_fct={_db_addon_fct} ignored, since parameter using 'db_addon_params' not given. Item will be ignored.") + return + + _db_addon_params = params_to_dict(self.get_iattr_value(item.conf, 'db_addon_params')) + if _db_addon_params is None or 'year' not in _db_addon_params: + self.logger.info(f"No 'year' for evaluation via 'db_addon_params' of item {item.id()} for function {_db_addon_fct} given. Default with 'current year' will be used.") + _db_addon_params = {} if _db_addon_params is None else _db_addon_params + _db_addon_params.update({'year': 'current'}) + + item_config_data_dict.update({'params': _db_addon_params}) + _update_cycle = 'daily' + + # handle tagesmitteltemperatur + elif _db_addon_fct == 'tagesmitteltemperatur': + if not self.has_iattr(item.conf, 'db_addon_params'): + self.logger.warning(f"Item '{item.id()}' with db_addon_fct={_db_addon_fct} ignored, since parameter using 'db_addon_params' not given. Item will be ignored.") + return + + _db_addon_params = params_to_dict(self.get_iattr_value(item.conf, 'db_addon_params')) + item_config_data_dict.update({'params': _db_addon_params}) + _update_cycle = 'daily' + + # handle db_request + elif _db_addon_fct == 'db_request': + if not self.has_iattr(item.conf, 'db_addon_params'): + self.logger.warning(f"Item '{item.id()}' with db_addon_fct={_db_addon_fct} ignored, since parameter using 'db_addon_params' not given. Item will be ignored") + return + + _db_addon_params = self.get_iattr_value(item.conf, 'db_addon_params') + _db_addon_params = params_to_dict(_db_addon_params) + if _db_addon_params is None: + self.logger.warning(f"Error occurred during parsing of item attribute 'db_addon_params' of item {item.id()}. Item will be ignored.") + return + + if self.parse_debug: + self.logger.debug(f"parse_item: {_db_addon_fct=} for item={item.id()}, {_db_addon_params=}") + + if not any(param in _db_addon_params for param in ('func', 'timeframe')): + self.logger.warning(f"Item '{item.id()}' with {_db_addon_fct=} ignored, not all mandatory parameters in {_db_addon_params=} given. Item will be ignored.") + return + + item_config_data_dict.update({'params': _db_addon_params}) + _timeframe = _db_addon_params.get('group', None) + if not _timeframe: + _timeframe = _db_addon_params.get('timeframe', None) + if _timeframe == 'day': + _update_cycle = 'daily' + elif _timeframe == 'week': + _update_cycle = 'weekly' + elif _timeframe == 'month': + _update_cycle = 'monthly' + elif _timeframe == 'year': + _update_cycle = 'yearly' + else: + self.logger.warning(f"Item '{item.id()}' with {_db_addon_fct=} ignored. Not able to detect update cycle.") + + # handle on_change items + elif onchange_attribute(_db_addon_fct): + _update_cycle = 'on-change' + + # debug log item cycle + if self.parse_debug: + self.logger.debug(f"Item '{item.id()}' added to be run {_update_cycle}.") + + # add item to be run on startup (onchange_items shall not be run at startup, but at first noticed change of item value; therefore remove for list of items to be run at startup) + if (_db_addon_startup and not onchange_attribute(_db_addon_fct)) or (_db_addon_fct.startswith('general_')): + if self.parse_debug: + self.logger.debug(f"Item '{item.id()}' added to be run on startup") + item_config_data_dict.update({'startup': True}) + else: + item_config_data_dict.update({'startup': False}) + + # add item to plugin item dict + self.add_item(item, config_data_dict=item_config_data_dict) + item_config = self.get_item_config(item) + item_config.update({'cycle': _update_cycle}) + + # handle all items with db_addon_info + elif self.has_iattr(item.conf, 'db_addon_info'): + if self.parse_debug: + self.logger.debug(f"parse item: {item.id()} due to used item attribute 'db_addon_info'") + self.add_item(item, config_data_dict={'db_addon': 'info', 'attribute': f"info_{self.get_iattr_value(item.conf, 'db_addon_info').lower()}", 'startup': True}) + + # handle all items with db_addon_admin + elif self.has_iattr(item.conf, 'db_addon_admin'): + if self.parse_debug: + self.logger.debug(f"parse item: {item.id()} due to used item attribute 'db_addon_admin'") + self.add_item(item, config_data_dict={'db_addon': 'admin', 'attribute': f"admin_{self.get_iattr_value(item.conf, 'db_addon_admin').lower()}"}) + return self.update_item + + # Reference to 'update_item' für alle Items mit Attribut 'database', um die on_change Items zu berechnen + elif self.has_iattr(item.conf, self.item_attribute_search_str) and get_db_addon_item(): + self.logger.debug(f"reference to update_item for item '{item}' will be set due to on-change") + self.add_item(item, config_data_dict={'db_addon': 'database'}) + return self.update_item + + def update_item(self, item, caller=None, source=None, dest=None): + """ + Handle updated item + This method is called, if the value of an item has been updated by SmartHomeNG. + It should write the changed value out to the device (hardware/interface) that is managed by this plugin. + + :param item: item to be updated towards the plugin + :param caller: if given it represents the callers name + :param source: if given it represents the source + :param dest: if given it represents the dest + """ + + if self.alive and caller != self.get_shortname(): + # handle database items + if item in self._database_items: + # self.logger.debug(f"update_item was called with item {item.property.path} with value {item()} from caller {caller}, source {source} and dest {dest}") + if not self.startup_finished: + self.logger.info(f"Handling of 'on-change' is paused for startup. No updated will be processed.") + elif self.suspended: + self.logger.info(f"Plugin is suspended. No updated will be processed.") + else: + self.logger.info(f"+ Updated item '{item.id()}' with value {item()} will be put to queue for processing. {self.item_queue.qsize() + 1} items to do.") + self.item_queue.put((item, item())) + + # handle admin items + elif self.has_iattr(item.conf, 'db_addon_admin'): + self.logger.debug(f"update_item was called with item {item.property.path} from caller {caller}, source {source} and dest {dest}") + if self.get_iattr_value(item.conf, 'db_addon_admin') == 'suspend': + self.suspend(item()) + elif self.get_iattr_value(item.conf, 'db_addon_admin') == 'recalc_all': + self.execute_all_items() + item(False, self.get_shortname()) + elif self.get_iattr_value(item.conf, 'db_addon_admin') == 'clean_cache_values': + self._init_cache_dicts() + item(False, self.get_shortname()) + + def execute_due_items(self) -> None: + """ + Execute all items, which are due + """ + + if self.execute_debug: + self.logger.debug("execute_due_items called") + + if not self.suspended: + _todo_items = self._create_due_items() + self.logger.info(f"{len(_todo_items)} items are due and will be calculated.") + [self.item_queue.put(i) for i in _todo_items] + else: + self.logger.info(f"Plugin is suspended. No items will be calculated.") + + def execute_startup_items(self) -> None: + """ + Execute all startup_items + """ + if self.execute_debug: + self.logger.debug("execute_startup_items called") + + if not self.suspended: + self.logger.info(f"{len(self._startup_items)} items will be calculated at startup.") + [self.item_queue.put(i) for i in self._startup_items] + self.startup_finished = True + else: + self.logger.info(f"Plugin is suspended. No items will be calculated.") + + def execute_static_items(self) -> None: + """ + Execute all static items + """ + if self.execute_debug: + self.logger.debug("execute_static_item called") + + if not self.suspended: + self.logger.info(f"{len(self._static_items)} items will be calculated.") + [self.item_queue.put(i) for i in self._static_items] + else: + self.logger.info(f"Plugin is suspended. No items will be calculated.") + + def execute_info_items(self) -> None: + """ + Execute all info items + """ + if self.execute_debug: + self.logger.debug("execute_info_items called") + + if not self.suspended: + self.logger.info(f"{len(self._static_items)} items will be calculated.") + [self.item_queue.put(i) for i in self._static_items] + else: + self.logger.info(f"Plugin is suspended. No items will be calculated.") + + def execute_all_items(self) -> None: + """ + Execute all ondemand items + """ + + if not self.suspended: + self.logger.info(f"Values for all {len(self._ondemand_items)} items with 'db_addon_fct' attribute, which are not 'on-change', will be calculated!") + [self.item_queue.put(i) for i in self._ondemand_items] + else: + self.logger.info(f"Plugin is suspended. No items will be calculated.") + + def work_item_queue(self) -> None: + """ + Handles item queue were all to be executed items were be placed in. + + """ + + self.logger.info(f"work_item_queue called.") + + while self.alive: + try: + queue_entry = self.item_queue.get(True, 10) + self.logger.info(f"{queue_entry} received.") + except queue.Empty: + self._active_queue_item = '-' + pass + else: + if isinstance(queue_entry, tuple): + item, value = queue_entry + self.logger.info(f"# {self.item_queue.qsize() + 1} item(s) to do. || 'on-change' item {item.id()} with {value=} will be processed.") + self._active_queue_item = str(item.id()) + self.handle_onchange(item, value) + else: + self.logger.info(f"# {self.item_queue.qsize() + 1} item(s) to do. || 'on-demand' item {queue_entry.id()} will be processed.") + self._active_queue_item = str(queue_entry.id()) + self.handle_ondemand(queue_entry) + + def handle_ondemand(self, item: Item) -> None: + """ + Calculate value for requested item, fill cache dicts and set item value. + + :param item: Item for which value will be calculated + """ + + # set/get parameters + item_config = self.get_item_config(item) + _db_addon_fct = item_config['attribute'] + _database_item = item_config.get('database_item') + _ignore_value = item_config.get('ignore_value') + _result = None + + # handle info functions + if _db_addon_fct.startswith('info_'): + # handle info_db_version + if _db_addon_fct == 'info_db_version': + _result = self._get_db_version() + + # handle general functions + elif _db_addon_fct.startswith('general_'): + # handle oldest_value + if _db_addon_fct == 'general_oldest_value': + _result = self._get_oldest_value(_database_item) + + # handle oldest_log + elif _db_addon_fct == 'general_oldest_log': + _result = self._get_oldest_log(_database_item) + + # handle item starting with 'verbrauch_' + elif _db_addon_fct.startswith('verbrauch_'): + + if self.execute_debug: + self.logger.debug(f"handle_ondemand: 'verbrauch' detected.") + + _result = self._handle_verbrauch(_database_item, _db_addon_fct) + + if _result and _result < 0: + self.logger.warning(f"Result of item {item.id()} with {_db_addon_fct=} was negative. Something seems to be wrong.") + + # handle item starting with 'zaehlerstand_' of format 'zaehlerstand_timeframe_timedelta' like 'zaehlerstand_woche_minus1' + elif _db_addon_fct.startswith('zaehlerstand_'): + + if self.execute_debug: + self.logger.debug(f"handle_ondemand: 'zaehlerstand' detected.") + + _result = self._handle_zaehlerstand(_database_item, _db_addon_fct) + + # handle item starting with 'minmax_' + elif _db_addon_fct.startswith('minmax_'): + + if self.execute_debug: + self.logger.debug(f"handle_ondemand: 'minmax' detected.") + + _result = self._handle_min_max(_database_item, _db_addon_fct, _ignore_value) + + # handle item starting with 'serie_' + elif _db_addon_fct.startswith('serie_'): + _db_addon_params = STD_REQUEST_DICT[_db_addon_fct] + _db_addon_params['item'] = _database_item + + if self.execute_debug: + self.logger.debug(f"handle_ondemand: 'serie' detected with {_db_addon_params=}") + + _result = self._handle_serie(_db_addon_params) + + # handle kaeltesumme + elif _db_addon_fct == 'kaeltesumme': + _db_addon_params = item_config['params'] + _db_addon_params['_database_item'] = item_config['database_item'] + + if self.execute_debug: + self.logger.debug(f"handle_ondemand: {_db_addon_fct=} detected; {_db_addon_params=}") + + _result = self._handle_kaeltesumme(**_db_addon_params) + + # handle waermesumme + elif _db_addon_fct == 'waermesumme': + _db_addon_params = item_config['params'] + _db_addon_params['_database_item'] = item_config['database_item'] + + if self.execute_debug: + self.logger.debug(f"handle_ondemand: {_db_addon_fct=} detected; {_db_addon_params=}") + + _result = self._handle_waermesumme(**_db_addon_params) + + # handle gruenlandtempsumme + elif _db_addon_fct == 'gruenlandtempsumme': + _db_addon_params = item_config['params'] + _db_addon_params['_database_item'] = item_config['database_item'] + + if self.execute_debug: + self.logger.debug(f"handle_ondemand: {_db_addon_fct=} detected; {_db_addon_params=}") + + _result = self._handle_gruenlandtemperatursumme(**_db_addon_params) + + # handle tagesmitteltemperatur + elif _db_addon_fct == 'tagesmitteltemperatur': + _db_addon_params = item_config['params'] + _db_addon_params['_database_item'] = item_config['database_item'] + + if self.execute_debug: + self.logger.debug(f"handle_ondemand: {_db_addon_fct=} detected; {_db_addon_params=}") + + _result = self._handle_tagesmitteltemperatur(**_db_addon_params) + + # handle db_request + elif _db_addon_fct == 'db_request': + _db_addon_params = item_config['params'] + _db_addon_params['îtem'] = item_config['database_item'] + + if self.execute_debug: + self.logger.debug(f"handle_ondemand: {_db_addon_fct=} detected with {_db_addon_params=}") + + if _db_addon_params.keys() & {'func', 'item', 'timeframe'}: + _result = self._query_item(**_db_addon_params) + else: + self.logger.error(f"Attribute 'db_addon_params' not containing needed params for Item {item.id} with {_db_addon_fct=}.") + + # handle everything else + else: + self.logger.warning(f"handle_ondemand: Function '{_db_addon_fct}' for item {item.id()} not defined or found.") + return + + # log result + if self.execute_debug: + self.logger.debug(f"handle_ondemand: result is {_result} for item '{item.id()}' with '{_db_addon_fct=}'") + + if _result is None: + self.logger.info(f" Result was None; No item value will be set.") + return + + # set item value and put data into plugin_item_dict + self.logger.info(f" Item value for '{item.id()}' will be set to {_result}") + item_config = self.get_item_config(item) + item_config.update({'value': _result}) + item(_result, self.get_shortname()) + + def handle_onchange(self, updated_item: Item, value: float) -> None: + """ + Get item and item value for which an update has been detected, fill cache dicts and set item value. + + :param updated_item: Item which has been updated + :param value: Value of updated item + """ + + if self.onchange_debug: + self.logger.debug(f"handle_onchange called with updated_item={updated_item.id()} and value={value}.") + + relevant_item_list = self.get_item_list('database_item', updated_item) + if self.onchange_debug: + self.logger.debug(f"Following items where identified for update: {relevant_item_list}.") + + for item in relevant_item_list: + item_config = self.get_item_config(item) + _database_item = item_config['database_item'] + _db_addon_fct = item_config['attribute'] + _var = _db_addon_fct.split('_') + _ignore_value = item_config['ignore_value'] + + # handle minmax on-change items like minmax_heute_max, minmax_heute_min, minmax_woche_max, minmax_woche_min..... + if _db_addon_fct.startswith('minmax') and len(_var) == 3 and _var[2] in ['min', 'max']: + _timeframe = convert_timeframe(_var[1]) + _func = _var[2] + _cache_dict = self.current_values[_timeframe] + + if self.onchange_debug: + self.logger.debug(f"handle_onchange: 'minmax' item {updated_item.id()} with {_func=} detected. Check for update of _cache_dicts and item value.") + + _initial_value = False + _new_value = None + + # make sure, that database item is in cache dict + if _database_item not in _cache_dict: + _cache_dict[_database_item] = {} + if _cache_dict[_database_item].get(_func, None) is None: + _cached_value = self._query_item(func=_func, item=_database_item, timeframe=_timeframe, start=0, end=0, ignore_value=_ignore_value)[0][1] + _initial_value = True + if self.onchange_debug: + self.logger.debug(f"handle_onchange: Item={updated_item.id()} with _func={_func} and _timeframe={_timeframe} not in cache dict. recent value={_cached_value}.") + else: + _cached_value = _cache_dict[_database_item][_func] + + if _cached_value: + # check value for update of cache dict + if _func == 'min' and value < _cached_value: + _new_value = value + if self.onchange_debug: + self.logger.debug(f"handle_onchange: new value={_new_value} lower then current min_value={_cached_value}. _cache_dict will be updated") + elif _func == 'max' and value > _cached_value: + _new_value = value + if self.onchange_debug: + self.logger.debug(f"handle_onchange: new value={_new_value} higher then current max_value={_cached_value}. _cache_dict will be updated") + else: + if self.onchange_debug: + self.logger.debug(f"handle_onchange: new value={_new_value} will not change max/min for period.") + else: + _cached_value = value + + if _initial_value and not _new_value: + _new_value = _cached_value + if self.onchange_debug: + self.logger.debug(f"handle_onchange: initial value for item will be set with value {_new_value}") + + if _new_value: + _cache_dict[_database_item][_func] = _new_value + self.logger.info(f"Item value for '{item.id()}' with func={_func} will be set to {_new_value}") + item_config = self.get_item_config(item) + item_config.update({'value': _new_value}) + item(_new_value, self.get_shortname()) + else: + self.logger.info(f"Received value={value} is not influencing min / max value. Therefore item {item.id()} will not be changed.") + + # handle verbrauch on-change items ending with heute, woche, monat, jahr + elif _db_addon_fct.startswith('verbrauch') and len(_var) == 2 and _var[1] in ['heute', 'woche', 'monat', 'jahr']: + _timeframe = convert_timeframe(_var[1]) + _cache_dict = self.previous_values[_timeframe] + + # make sure, that database item is in cache dict + if _database_item not in _cache_dict: + _cached_value = self._query_item(func='max', item=_database_item, timeframe=_timeframe, start=1, end=1, ignore_value=_ignore_value)[0][1] + _cache_dict[_database_item] = _cached_value + if self.onchange_debug: + self.logger.debug(f"handle_onchange: Item={updated_item.id()} with {_timeframe=} not in cache dict. Value {_cached_value} has been added.") + else: + _cached_value = _cache_dict[_database_item] + + # calculate value, set item value, put data into plugin_item_dict + if _cached_value is not None: + _new_value = round(value - _cached_value, 1) + self.logger.info(f"Item value for '{item.id()}' will be set to {_new_value}") + item_config = self.get_item_config(item) + item_config.update({'value': _new_value}) + item(_new_value, self.get_shortname()) + else: + self.logger.info(f"Value for end of last {_timeframe} not available. No item value will be set.") + + @property + def log_level(self): + return self.logger.getEffectiveLevel() + + @property + def queue_backlog(self): + return self.item_queue.qsize() + + @property + def active_queue_item(self): + return self._active_queue_item + + @property + def db_version(self): + return self._get_db_version() + + @property + def _startup_items(self) -> list: + return self.get_item_list('startup', True) + + @property + def _onchange_items(self) -> list: + return self.get_item_list('cycle', 'on-change') + + @property + def _daily_items(self) -> list: + return self.get_item_list('cycle', 'daily') + + @property + def _weekly_items(self) -> list: + return self.get_item_list('cycle', 'weekly') + + @property + def _monthly_items(self) -> list: + return self.get_item_list('cycle', 'monthly') + + @property + def _yearly_items(self) -> list: + return self.get_item_list('cycle', 'yearly') + + @property + def _static_items(self) -> list: + return self.get_item_list('cycle', 'static') + + @property + def _admin_items(self) -> list: + return self.get_item_list('db_addon', 'admin') + + @property + def _info_items(self) -> list: + return self.get_item_list('db_addon', 'info') + + @property + def _database_items(self) -> list: + return self.get_item_list('db_addon', 'database') + + @property + def _ondemand_items(self) -> list: + return self._daily_items + self._weekly_items + self._monthly_items + self._yearly_items + self._static_items + + ############################## + # Public functions + ############################## + + def gruenlandtemperatursumme(self, item: Item, year: Union[int, str]) -> Union[int, None]: + """ + Query database for gruenlandtemperatursumme for given year or year/month + https://de.wikipedia.org/wiki/Gr%C3%BCnlandtemperatursumme + + :param item: item object or item_id for which the query should be done + :param year: year the gruenlandtemperatursumme should be calculated for + + :return: gruenlandtemperatursumme + """ + + return self._handle_gruenlandtemperatursumme(item, year) + + def waermesumme(self, item: Item, year, month: Union[int, str] = None) -> Union[int, None]: + """ + Query database for waermesumme for given year or year/month + + :param item: item object or item_id for which the query should be done + :param year: year the waermesumme should be calculated for + :param month: month the waermesumme should be calculated for + + :return: waermesumme + """ + + return self._handle_waermesumme(item, year, month) + + def kaeltesumme(self, item: Item, year, month: Union[int, str] = None) -> Union[int, None]: + """ + Query database for kaeltesumme for given year or year/month + + :param item: item object or item_id for which the query should be done + :param year: year the kaeltesumme should be calculated for + :param month: month the kaeltesumme should be calculated for + + :return: kaeltesumme + """ + + return self._handle_kaeltesumme(item, year, month) + + def tagesmitteltemperatur(self, item: Item, count: int = None) -> list: + """ + Query database for tagesmitteltemperatur + + :param item: item object or item_id for which the query should be done + :param count: start of timeframe defined by number of time increments starting from now to the left (into the past) + + :return: tagesmitteltemperatur + :rtype: list of tuples + """ + + return self._handle_tagesmitteltemperatur(_database_item=item, count=count) + + def fetch_log(self, func: str, item: Item, timeframe: str, start: int = None, end: int = 0, count: int = None, group: str = None, group2: str = None, ignore_value=None) -> Union[list, None]: + """ + Query database, format response and return it + + :param func: function to be used at query + :param item: item str or item_id for which the query should be done + :param timeframe: time increment für definition of start, end, count (day, week, month, year) + :param start: start of timeframe (oldest) for query given in x time increments (default = None, meaning complete database) + :param end: end of timeframe (newest) for query given in x time increments (default = 0, meaning today, end of last week, end of last month, end of last year) + :param count: start of timeframe defined by number of time increments starting from end to the left (into the past) + :param group: first grouping parameter (default = None, possible values: day, week, month, year) + :param group2: second grouping parameter (default = None, possible values: day, week, month, year) + :param ignore_value: value of val_num, which will be ignored during query + + :return: formatted query response + """ + + if isinstance(item, str): + item = self.items.return_item(item) + if count: + start, end = count_to_start(count) + return self._query_item(func=func, item=item, timeframe=timeframe, start=start, end=end, group=group, group2=group2, ignore_value=ignore_value) + + def fetch_raw(self, query: str, params: dict = None) -> Union[list, None]: + """ + Fetch database with given query string and params + + :param query: database query to be executed + :param params: query parameters + + :return: result of database query + """ + + if params is None: + params = {} + + formatted_sql = sqlvalidator.format_sql(query) + sql_query = sqlvalidator.parse(formatted_sql) + + if not sql_query.is_valid(): + self.logger.error(f"fetch_raw: Validation of query failed with error: {sql_query.errors}") + return + + return self._fetchall(query, params) + + def suspend(self, state: bool = False) -> bool: + """ + Will pause value evaluation of plugin + + """ + + if state: + self.logger.warning("Plugin is set to 'suspended'. Queries to database will not be made until suspension is cancelled.") + self.suspended = True + self._clear_queue() + else: + self.logger.warning("Plugin suspension cancelled. Queries to database will be resumed.") + self.suspended = False + + # write back value to item, if one exists + for item in self.get_item_list('db_addon', 'admin'): + item_config = self.get_item_config(item) + if item_config['attribute'] == 'suspend': + item(self.suspended, self.get_shortname()) + + return self.suspended + + ############################## + # Support stuff + ############################## + + def _handle_min_max(self, _database_item: Item, _db_addon_fct: str, _ignore_value): + """ + Handle execution of min/max calculation + + """ + + _var = _db_addon_fct.split('_') + _result = None + _timeframes = ['heute', 'woche', 'monat', 'jahr'] + + # handle all on_change functions of format 'minmax_timeframe_function' like 'minmax_heute_max' + if len(_var) == 3 and _var[1] in _timeframes and _var[2] in ['min', 'max']: + if self.execute_debug: + self.logger.debug(f"on-change function={_var[0]} with {_var[1]} detected; will be calculated by next change of database item") + + # handle all 'last' functions in format 'minmax_last_window_function' like 'minmax_last_24h_max' + elif len(_var) == 4 and _var[1] == 'last' and _var[3] in ['min', 'max', 'avg']: + _window = _var[2] + _func = _var[3] + _timeframe = convert_timeframe(_window[-1:]) + _timedelta = int(_window[:-1]) + + if self.execute_debug: + self.logger.debug(f"_handle_min_max: 'last' function detected. {_window=}, {_func=}") + + if _timeframe in ['day', 'week', 'month', 'year']: + _result = self._query_item(func=_func, item=_database_item, timeframe=_timeframe, start=_timedelta, end=0, ignore_value=_ignore_value)[0][1] + + # handle all functions 'min/max/avg' in format 'minmax_timeframe_timedelta_func' like 'minmax_heute_minus2_max' + elif len(_var) == 4 and _var[1] in _timeframes and _var[2].startswith('minus') and _var[3] in ['min', 'max', 'avg']: + _timeframe = convert_timeframe(_var[1]) # day, week, month, year + _timedelta = _var[2][-1] # 1, 2, 3, ... + _func = _var[3] # min, max, avg + + if self.execute_debug: + self.logger.debug(f"_handle_min_max: _db_addon_fct={_func} detected; {_timeframe=}, {_timedelta=}") + + if isinstance(_timedelta, str) and _timedelta.isdigit(): + _timedelta = int(_timedelta) + + if isinstance(_timedelta, int): + _result = self._query_item(func=_func, item=_database_item, timeframe=_timeframe, start=_timedelta, end=_timedelta, ignore_value=_ignore_value)[0][1] + + return _result + + def _handle_zaehlerstand(self, _database_item: Item, _db_addon_fct: str): + """ + Handle execution of Zaehlerstand calculation + + """ + + _var = _db_addon_fct.split('_') # zaehlerstand_heute_minus1 + _result = None + _func = _var[0] + _timeframe = convert_timeframe(_var[1]) + _timedelta = _var[2][-1] + + if self.execute_debug: + self.logger.debug(f"_handle_zaehlerstand: {_func} function detected. {_timeframe=}, {_timedelta=}") + + if isinstance(_timedelta, str) and _timedelta.isdigit(): + _timedelta = int(_timedelta) + + if _func == 'zaehlerstand': + _result = self._query_item(func='max', item=_database_item, timeframe=_timeframe, start=_timedelta, end=_timedelta)[0][1] + + return _result + + def _handle_verbrauch(self, _database_item: Item, _db_addon_fct: str): + """ + Handle execution of verbrauch calculation + + """ + + _var = _db_addon_fct.split('_') + _result = None + + # handle all on_change functions of format 'verbrauch_timeframe' like 'verbrauch_heute' + if len(_var) == 2 and _var[1] in ['heute', 'woche', 'monat', 'jahr']: + if self.execute_debug: + self.logger.debug(f"on_change function={_var[1]} detected; will be calculated by next change of database item") + + # handle all functions 'verbrauch' in format 'verbrauch_timeframe_timedelta' like 'verbrauch_heute_minus2' + elif len(_var) == 3 and _var[1] in ['heute', 'woche', 'monat', 'jahr'] and _var[2].startswith('minus'): + _timeframe = convert_timeframe(_var[1]) + _timedelta = _var[2][-1] + + if self.execute_debug: + self.logger.debug(f"_handle_verbrauch: '{_db_addon_fct}' function detected. {_timeframe=}, {_timedelta=}") + + if isinstance(_timedelta, str) and _timedelta.isdigit(): + _timedelta = int(_timedelta) + + if isinstance(_timedelta, int): + _result = self._consumption_calc(_database_item, _timeframe, start=_timedelta + 1, end=_timedelta) + + # handle all functions of format 'verbrauch_function_window_timeframe_timedelta' like 'verbrauch_rolling_12m_woche_minus1' + elif len(_var) == 5 and _var[1] == 'rolling' and _var[4].startswith('minus'): + _func = _var[1] + _window = _var[2] # 12m + _window_inc = int(_window[:-1]) # 12 + _window_dur = convert_timeframe(_window[-1]) # day, week, month, year + _timeframe = convert_timeframe(_var[3]) # day, week, month, year + _timedelta = _var[4][-1] # 1 + + if self.execute_debug: + self.logger.debug(f"_handle_verbrauch: '{_func}' function detected. {_window=}, {_timeframe=}, {_timedelta=}") + + if isinstance(_timedelta, str) and _timedelta.isdigit(): + _timedelta = int(_timedelta) + _endtime = _timedelta + + if _func == 'rolling' and _window_dur in ['day', 'week', 'month', 'year']: + _starttime = convert_duration(_timeframe, _window_dur) * _window_inc + _result = self._consumption_calc(_database_item, _timeframe, _starttime, _endtime) + + # handle all functions of format 'verbrauch_timeframe_timedelta' like 'verbrauch_jahreszeitraum_minus1' + elif len(_var) == 3 and _var[1] == 'jahreszeitraum' and _var[2].startswith('minus'): + _timeframe = convert_timeframe(_var[1]) # day, week, month, year + _timedelta = _var[2][-1] # 1 oder 2 oder 3 + + if self.execute_debug: + self.logger.debug(f"_handle_verbrauch: '{_db_addon_fct}' function detected. {_timeframe=}, {_timedelta=}") + + if isinstance(_timedelta, str) and _timedelta.isdigit(): + _timedelta = int(_timedelta) + + if isinstance(_timedelta, int): + _today = datetime.date.today() + _year = _today.year - _timedelta + _start_date = datetime.date(_year, 1, 1) - relativedelta(days=1) # Start ist Tag vor dem 1.1., damit Abfrage den Maximalwert von 31.12. 00:00:00 bis 1.1. 00:00:00 ergibt + _end_date = _today - relativedelta(years=_timedelta) + _start = (_today - _start_date).days + _end = (_today - _end_date).days + + _result = self._consumption_calc(_database_item, _timeframe, _start, _end) + + return _result + + def _handle_serie(self, _db_addon_params: dict): + """ + Handle execution of serie calculation + + """ + return self._query_item(**_db_addon_params) + + def _handle_kaeltesumme(self, _database_item: Item, year: Union[int, str], month: Union[int, str] = None) -> Union[int, None]: + """ + Query database for kaeltesumme for given year or year/month + + :param _database_item: item object or item_id for which the query should be done + :param year: year the kaeltesumme should be calculated for + :param month: month the kaeltesumme should be calculated for + :return: kaeltesumme + """ + + # check validity of given year + if not valid_year(year): + self.logger.error(f"kaeltesumme: Year for item={_database_item.id()} was {year}. This is not a valid year. Query cancelled.") + return + + if year == 'current': + if datetime.date.today() < datetime.date(int(datetime.date.today().year), 9, 21): + year = datetime.date.today().year - 1 + else: + year = datetime.date.today().year + + if month is None: + start_date = datetime.date(int(year), 9, 21) + end_date = datetime.date(int(year) + 1, 3, 22) + group2 = 'year' + elif valid_month(month): + start_date = datetime.date(int(year), int(month), 1) + end_date = start_date + relativedelta(months=+1) - datetime.timedelta(days=1) + group2 = 'month' + else: + self.logger.error(f"kaeltesumme: Month for item={_database_item.id()} was {month}. This is not a valid month. Query cancelled.") + return + + today = datetime.date.today() + if start_date > today: + self.logger.error(f"kaeltesumme: Start time for query of item={_database_item.id()} is in future. Query cancelled.") + return + + start = (today - start_date).days + end = (today - end_date).days if end_date < today else 0 + if start < end: + self.logger.error(f"kaeltesumme: End time for query of item={_database_item.id()} is before start time. Query cancelled.") + return + + _db_addon_params = STD_REQUEST_DICT.get('kaltesumme_year_month', None) + _db_addon_params.update({'start': start, 'end': end, 'group2': group2, 'item': _database_item}) + + # query db and generate values + _result = self._query_item(**_db_addon_params) + self.logger.debug(f"kaeltesumme: {_result=} for {_database_item.id()=} with {year=} and {month=}") + + # calculate value + value = 0 + if _result == [[None, None]]: + return + try: + if month: + value = _result[0][1] + else: + for entry in _result: + entry_value = entry[1] + if entry_value: + value += entry_value + return int(value) + except Exception as e: + self.logger.error(f"Error {e} occurred during calculation of kaeltesumme with {_result=} for {_database_item.id()=} with {year=} and {month=}") + + def _handle_waermesumme(self, _database_item: Item, year: Union[int, str], month: Union[int, str] = None) -> Union[int, None]: + """ + Query database for waermesumme for given year or year/month + + :param _database_item: item object or item_id for which the query should be done + :param year: year the waermesumme should be calculated for + :param month: month the waermesumme should be calculated for + :return: waermesumme + """ + + if not valid_year(year): + self.logger.error(f"waermesumme: Year for item={_database_item.id()} was {year}. This is not a valid year. Query cancelled.") + return + + if year == 'current': + year = datetime.date.today().year + + if month is None: + start_date = datetime.date(int(year), 3, 20) + end_date = datetime.date(int(year), 9, 21) + group2 = 'year' + elif valid_month(month): + start_date = datetime.date(int(year), int(month), 1) + end_date = start_date + relativedelta(months=+1) - datetime.timedelta(days=1) + group2 = 'month' + else: + self.logger.error(f"waermesumme: Month for item={_database_item.id()} was {month}. This is not a valid month. Query cancelled.") + return + + today = datetime.date.today() + if start_date > today: + self.logger.info(f"waermesumme: Start time for query of item={_database_item.id()} is in future. Query cancelled.") + return + + start = (today - start_date).days + end = (today - end_date).days if end_date < today else 0 + if start < end: + self.logger.error(f"waermesumme: End time for query of item={_database_item.id()} is before start time. Query cancelled.") + return + + _db_addon_params = STD_REQUEST_DICT.get('waermesumme_year_month', None) + _db_addon_params.update({'start': start, 'end': end, 'group2': group2, 'item': _database_item}) + + # query db and generate values + _result = self._query_item(**_db_addon_params)[0][1] + self.logger.debug(f"waermesumme_year_month: {_result=} for {_database_item.id()=} with {year=} and {month=}") + + # calculate value + if _result == [[None, None]]: + return + + if _result is not None: + return int(_result) + else: + return + + def _handle_gruenlandtemperatursumme(self, _database_item: Item, year: Union[int, str]) -> Union[int, None]: + """ + Query database for gruenlandtemperatursumme for given year or year/month + + :param _database_item: item object or item_id for which the query should be done + :param year: year the gruenlandtemperatursumme should be calculated for + :return: gruenlandtemperatursumme + """ + + if not valid_year(year): + self.logger.error(f"gruenlandtemperatursumme: Year for item={_database_item.id()} was {year}. This is not a valid year. Query cancelled.") + return + + current_year = datetime.date.today().year + + if year == 'current': + year = current_year + + year = int(year) + year_delta = current_year - year + if year_delta < 0: + self.logger.error(f"gruenlandtemperatursumme: Start time for query of item={_database_item.id()} is in future. Query cancelled.") + return + + _db_addon_params = STD_REQUEST_DICT.get('gts', None) + _db_addon_params.update({'start': year_delta, 'end': year_delta, 'item': _database_item}) + + # query db and generate values + _result = self._query_item(**_db_addon_params) + + # calculate value and return it + if _result == [[None, None]]: + return + + try: + gts = 0 + for entry in _result: + dt = datetime.datetime.fromtimestamp(int(entry[0]) / 1000) + if dt.month == 1: + gts += float(entry[1]) * 0.5 + elif dt.month == 2: + gts += float(entry[1]) * 0.75 + else: + gts += entry[1] + return int(round(gts, 0)) + except Exception as e: + self.logger.error(f"Error {e} occurred during calculation of gruenlandtemperatursumme with {_result=} for {_database_item.id()=}") + + def _handle_tagesmitteltemperatur(self, _database_item: Item, count: int = None) -> list: + """ + Query database for tagesmitteltemperatur + + :param _database_item: item object or item_id for which the query should be done + :param count: start of timeframe defined by number of time increments starting from now to the left (into the past) + :return: tagesmitteltemperatur + """ + + start, end = count_to_start(count) + _db_addon_params = STD_REQUEST_DICT.get('tagesmittelwert_hour_days', None) + _db_addon_params.update({'item': _database_item, 'start': start, 'end': end}) + + return self._query_item(**_db_addon_params)[0][1] + + def _create_due_items(self) -> list: + """ + Create set of items which are due and resets cache dicts + + :return: set of items, which need to be processed + + """ + + _todo_items = set() + _todo_items.update(set(self._daily_items)) + self.current_values[DAY] = {} + self.previous_values[DAY] = {} + + # wenn jetzt Wochentag = Montag ist, werden auch die wöchentlichen Items berechnet + if self.shtime.now().hour == 0 and self.shtime.now().minute == 0 and self.shtime.weekday(self.shtime.today()) == 1: + _todo_items.update(set(self._weekly_items)) + # self.wochenwert_dict = {} + # self.vorwochenendwert_dict = {} + self.current_values[WEEK] = {} + self.previous_values[WEEK] = {} + + # wenn jetzt der erste Tage eines Monates ist, werden auch die monatlichen Items berechnet + if self.shtime.now().hour == 0 and self.shtime.now().minute == 0 and self.shtime.now().day == 1: + _todo_items.update(set(self._monthly_items)) + # self.monatswert_dict = {} + # self.vormonatsendwert_dict = {} + self.current_values[MONTH] = {} + self.previous_values[MONTH] = {} + + # wenn jetzt der erste Tage des ersten Monates eines Jahres ist, werden auch die jährlichen Items berechnet + if self.shtime.now().hour == 0 and self.shtime.now().minute == 0 and self.shtime.now().day == 1 and self.shtime.now().month == 1: + _todo_items.update(set(self._yearly_items)) + # self.jahreswert_dict = {} + # self.vorjahresendwert_dict = {} + self.current_values[YEAR] = {} + self.previous_values[YEAR] = {} + + return list(_todo_items) + + def _check_db_existence(self) -> bool: + """ + Check existence of database plugin with given config name + + :return: Status of db existence + """ + + try: + _db_plugin = self.plugins.return_plugin(self.db_configname) + except Exception as e: + self.logger.error(f"Database plugin not loaded, Error was {e}. No need for DatabaseAddOn Plugin.") + return False + else: + if not _db_plugin: + self.logger.error(f"Database plugin not loaded or given ConfigName {self.db_configname} not correct. No need for DatabaseAddOn Plugin.") + return False + else: + self.logger.debug(f"Corresponding plugin 'database' with given config name '{self.db_configname}' found.") + self._db_plugin = _db_plugin + return self._get_db_parameter() + + def _get_db_parameter(self) -> bool: + """ + Get driver of database and connection parameter + + :return: Status of db connection parameters + """ + + try: + self.db_driver = self._db_plugin.get_parameter_value('driver') + except Exception as e: + self.logger.error(f"Error {e} occurred during getting database plugin parameter 'driver'. DatabaseAddOn Plugin not loaded.") + return False + else: + if self.db_driver.lower() == 'pymysql': + self.logger.debug(f"Database is of type 'mysql' found.") + if self.db_driver.lower() == 'sqlite3': + self.logger.debug(f"Database is of type 'sqlite' found.") + + # get database plugin parameters + try: + db_instance = self._db_plugin.get_instance_name() + if db_instance != "": + self.db_instance = db_instance + self.item_attribute_search_str = f"{self.item_attribute_search_str}@{self.db_instance}" + self.connection_data = self._db_plugin.get_parameter_value('connect') # pymsql ['host:localhost', 'user:smarthome', 'passwd:smarthome', 'db:smarthome', 'port:3306'] + self.logger.debug(f"Database Plugin available with instance={self.db_instance} and connection={self.connection_data}") + except Exception as e: + self.logger.error(f"Error {e} occurred during getting database plugin parameters. DatabaseAddOn Plugin not loaded.") + return False + else: + return True + + def _initialize_db(self) -> bool: + """ + Initializes database connection + + :return: Status of initialization + """ + + try: + if not self._db.connected(): + # limit connection requests to 20 seconds. + current_time = time.time() + time_delta_last_connect = current_time - self.last_connect_time + # self.logger.debug(f"DEBUG: delta {time_delta_last_connect}") + if time_delta_last_connect > 20: + self.last_connect_time = time.time() + self._db.connect() + else: + self.logger.error(f"_initialize_db: Database reconnect suppressed: Delta time: {time_delta_last_connect}") + return False + except Exception as e: + self.logger.critical(f"_initialize_db: Database: Initialization failed: {e}") + return False + else: + return True + + def _check_db_connection_setting(self) -> None: + """ + Check Setting of DB connection for stable use. + """ + try: + connect_timeout = int(self._get_db_connect_timeout()[1]) + if connect_timeout < self.default_connect_timeout: + self.logger.warning(f"DB variable 'connect_timeout' should be adjusted for proper working to {self.default_connect_timeout}. Current setting is {connect_timeout}. You need to insert adequate entries into /etc/mysql/my.cnf within section [mysqld].") + except Exception: + pass + + try: + net_read_timeout = int(self._get_db_net_read_timeout()[1]) + if net_read_timeout < self.default_net_read_timeout: + self.logger.warning(f"DB variable 'net_read_timeout' should be adjusted for proper working to {self.default_net_read_timeout}. Current setting is {net_read_timeout}. You need to insert adequate entries into /etc/mysql/my.cnf within section [mysqld].") + except Exception: + pass + + def _get_oldest_log(self, item: Item) -> int: + """ + Get timestamp of the oldest entry of item from cache dict or get value from db and put it to cache dict + + :param item: Item, for which query should be done + :return: timestamp of the oldest log + """ + + _oldest_log = self.item_cache.get(item, {}).get('oldest_log', None) + + if _oldest_log is None: + item_id = self._get_itemid(item) + _oldest_log = self._read_log_oldest(item_id) + if item not in self.item_cache: + self.item_cache[item] = {} + self.item_cache[item]['oldest_log'] = _oldest_log + + if self.prepare_debug: + self.logger.debug(f"_get_oldest_log for item {item.id()} = {_oldest_log}") + + return _oldest_log + + def _get_oldest_value(self, item: Item) -> Union[int, float, bool]: + """ + Get value of the oldest log of item from cache dict or get value from db and put it to cache dict + + :param item: Item, for which query should be done + :return: oldest value + """ + + _oldest_entry = self.item_cache.get(item, {}).get('_oldest_entry', None) + + if _oldest_entry is not None: + _oldest_value = _oldest_entry[0][4] + else: + item_id = self._get_itemid(item) + validity = False + i = 0 + _oldest_value = -999999999 + while validity is False: + oldest_entry = self._read_log_timestamp(item_id, self._get_oldest_log(item)) + i += 1 + if isinstance(oldest_entry, list) and isinstance(oldest_entry[0], tuple) and len(oldest_entry[0]) >= 4: + if item not in self.item_cache: + self.item_cache[item] = {} + self.item_cache[item]['oldest_entry'] = oldest_entry + _oldest_value = oldest_entry[0][4] + validity = True + elif i == 10: + validity = True + self.logger.error(f"oldest_value for item {item.id()} could not be read; value is set to -999999999") + + if self.prepare_debug: + self.logger.debug(f"_get_oldest_value for item {item.id()} = {_oldest_value}") + + return _oldest_value + + def _get_itemid(self, item: Item) -> int: + """ + Returns the ID of the given item from cache dict or request it from database + + :param item: Item to get the ID for + :return: id of the item within the database + """ + + # self.logger.debug(f"_get_itemid called with item={item.id()}") + _item_id = self.item_cache.get(item, {}).get('id', None) + if _item_id is None: + row = self._read_item_table(item) + if row: + if len(row) > 0: + _item_id = int(row[0]) + if item not in self.item_cache: + self.item_cache[item] = {} + self.item_cache[item]['id'] = _item_id + return _item_id + + def _get_itemid_for_query(self, item: Item) -> Union[int, None]: + """ + Get DB item id for query + + :param item: item, the query should be done for + + """ + + if isinstance(item, Item): + item_id = self._get_itemid(item) + elif isinstance(item, str) and item.isdigit(): + item_id = int(item) + elif isinstance(item, int): + item_id = item + else: + item_id = None + return item_id + + def _handle_query_result(self, query_result: Union[list, None]) -> list: + """ + Handle query result containing list + + :param query_result: list of query result with [[value, value], [value, value] for regular result, [[None, None]] for errors, [[0,0]] for 'no values for requested timeframe' + + """ + + # if query delivers None, abort + if query_result is None: + # if query delivers None, abort + self.logger.error(f"Error occurred during _query_item. Aborting...") + _result = [[None, None]] + elif len(query_result) == 0: + _result = [[0, 0]] + self.logger.info(f" No values for item in requested timeframe in database found.") + else: + _result = [] + for element in query_result: + timestamp = element[0] + value = element[1] + if timestamp and value is not None: + _result.append([timestamp, round(value, 1)]) + if not _result: + _result = [[None, None]] + + # if self.prepare_debug: + # self.logger.debug(f"_handle_query_result: {_result=}") + + return _result + + def _consumption_calc(self, item, timeframe: str, start: int, end: int) -> Union[float, None]: + """ + Handle query for Verbrauch + + :param item: item, the query should be done for + :param timeframe: timeframe as week, month, year + :param start: beginning of timeframe + :param start: end of timeframe + + """ + + if self.prepare_debug: + self.logger.debug(f"_consumption_calc called with {item=},{timeframe=},{start=},{end=}") + + _result = None + + # get value for end and check it; + value_end = self._query_item(func='max', item=item, timeframe=timeframe, start=end, end=end)[0][1] + if self.prepare_debug: + self.logger.debug(f"_consumption_calc {value_end=}") + + if value_end is None: # if None (Error) return + return + elif value_end == 0: # wenn die Query "None" ergab, was wiederum bedeutet, dass zum Abfragezeitpunkt keine Daten vorhanden sind, ist der value hier gleich 0 → damit der Verbrauch für die Abfrage auch Null + _result = 0 + else: + # get value for start and check it; + # value_start = self._query_item(func='max', item=item, timeframe=timeframe, start=start, end=start)[0][1] + value_start = self._query_item(func='min', item=item, timeframe=timeframe, start=end, end=end)[0][1] + if self.prepare_debug: + self.logger.debug(f"_consumption_calc {value_start=}") + + if value_start is None: # if None (Error) return + return + + # ToDo: Prüfen, unter welchen Bedingungen value_start == 0 bzw. wie man den nächsten Eintrag nutzt. + if value_start == 0: # wenn der Wert zum Startzeitpunkt 0 ist, gab es dort keinen Eintrag (also keinen Verbrauch), dann frage den nächsten Eintrag in der DB ab. + self.logger.info(f"No DB Entry found for requested start date. Looking for next DB entry.") + # value_start = self._handle_query_result(self._query_log_next(item=item, timeframe=timeframe, timedelta=start))[0][1] + value_start = self._handle_query_result(self._query_item(func='next', item=item, timeframe=timeframe, start=start))[0][1] + if self.prepare_debug: + self.logger.debug(f"_consumption_calc: next available value is {value_start=}") + + if value_end is not None and value_start is not None: + _result = round(value_end - value_start, 1) + + if self.prepare_debug: + self.logger.debug(f"_consumption_calc: {_result=} for {item=},{timeframe=},{start=},{end=}") + + return _result + + def _query_item(self, func: str, item, timeframe: str, start: int = None, end: int = 0, group: str = None, group2: str = None, ignore_value=None) -> list: + """ + Do diverse checks of input, and prepare query of log by getting item_id, start / end in timestamp etc. + + :param func: function to be used at query + :param item: item object or item_id for which the query should be done + :param timeframe: time increment für definition of start, end (day, week, month, year) + :param start: start of timeframe (oldest) for query given in x time increments (default = None, meaning complete database) + :param end: end of timeframe (newest) for query given in x time increments (default = 0, meaning end of today, end of last week, end of last month, end of last year) + :param group: first grouping parameter (default = None, possible values: day, week, month, year) + :param group2: second grouping parameter (default = None, possible values: day, week, month, year) + :param ignore_value: value of val_num, which will be ignored during query + + :return: query response / list for value pairs [[None, None]] for errors, [[0,0]] for + """ + + if self.prepare_debug: + self.logger.debug(f"_query_item called with {func=}, item={item.id()}, {timeframe=}, {start=}, {end=}, {group=}, {group2=}, {ignore_value=}") + + # SET DEFAULT RESULT + result = [[None, None]] + + # CHECK CORRECTNESS OF TIMEFRAME + if timeframe not in ["year", "month", "week", "day"]: + self.logger.error(f"_query_item: Requested {timeframe=} for item={item.id()} not defined; Need to be year, month, week, day'. Query cancelled.") + return result + + # CHECK CORRECTNESS OF START / END + if start < end: + self.logger.warning(f"_query_item: Requested {start=} for item={item.id()} is not valid since {start=} < {end=}. Query cancelled.") + return result + + # DEFINE ITEM_ID + item_id = self._get_itemid_for_query(item) + if not item_id: + self.logger.error(f"_query_item: ItemId for item={item.id()} not found. Query cancelled.") + return result + + # DEFINE START AND END OF QUERY AS TIMESTAMP IN MICROSECONDS + ts_start, ts_end = get_start_end_as_timestamp(timeframe, start, end) + oldest_log = int(self._get_oldest_log(item)) + + if start is None: + ts_start = oldest_log + + if self.prepare_debug: + self.logger.debug(f"_query_item: Requested {timeframe=} with {start=} and {end=} resulted in start being timestamp={ts_start} / {timestamp_to_timestring(ts_start)} and end being timestamp={ts_end} / {timestamp_to_timestring(ts_end)}") + + # CHECK IF VALUES FOR END TIME AND START TIME ARE IN DATABASE + if ts_end < oldest_log: # (Abfrage abbrechen, wenn Endzeitpunkt in UNIX-timestamp der Abfrage kleiner (und damit jünger) ist, als der UNIX-timestamp des ältesten Eintrages) + self.logger.info(f"_query_item: Requested end time timestamp={ts_end} / {timestamp_to_timestring(ts_end)} of query for Item='{item.id()}' is prior to oldest entry with timestamp={oldest_log} / {timestamp_to_timestring(oldest_log)}. Query cancelled.") + return result + + if ts_start < oldest_log: + if not self.use_oldest_entry: + self.logger.info(f"_query_item: Requested start time timestamp={ts_start} / {timestamp_to_timestring(ts_start)} of query for Item='{item.id()}' is prior to oldest entry with timestamp={oldest_log} / {timestamp_to_timestring(oldest_log)}. Query cancelled.") + return result + else: + self.logger.info(f"_query_item: Requested start time timestamp={ts_start} / {timestamp_to_timestring(ts_start)} of query for Item='{item.id()}' is prior to oldest entry with timestamp={oldest_log} / {timestamp_to_timestring(oldest_log)}. Oldest available entry will be used.") + ts_start = oldest_log + + log = self._query_log_timestamp(func=func, item_id=item_id, ts_start=ts_start, ts_end=ts_end, group=group, group2=group2, ignore_value=ignore_value) + result = self._handle_query_result(log) + + if self.prepare_debug: + self.logger.debug(f"_query_item: value for item={item.id()} with {timeframe=}, {func=}: {result}") + + return result + + def _init_cache_dicts(self) -> None: + """ + init all cache dicts + """ + + self.logger.info(f"All cache_dicts will be initiated.") + + self.item_cache = {} + + self.current_values = { + DAY: {}, + WEEK: {}, + MONTH: {}, + YEAR: {} + } + + self.previous_values = { + DAY: {}, + WEEK: {}, + MONTH: {}, + YEAR: {} + } + + def _clear_queue(self) -> None: + """ + Clear working queue + """ + + self.logger.info(f"Working queue will be cleared. Calculation run will end.") + self.item_queue.queue.clear() + + def _work_item_queue_thread_startup(self): + """ + Start a thread to work item queue + """ + + try: + _name = 'plugins.' + self.get_fullname() + '.work_item_queue' + self.work_item_queue_thread = threading.Thread(target=self.work_item_queue, name=_name) + self.work_item_queue_thread.daemon = False + self.work_item_queue_thread.start() + self.logger.debug("Thread for 'work_item_queue_thread' has been started") + except threading.ThreadError: + self.logger.error("Unable to launch thread for 'work_item_queue_thread'.") + self.work_item_queue_thread = None + + def _work_item_queue_thread_shutdown(self): + """ + Shut down the thread to work item queue + """ + + if self.work_item_queue_thread: + self.work_item_queue_thread.join() + if self.work_item_queue_thread.is_alive(): + self.logger.error("Unable to shut down 'work_item_queue_thread' thread") + else: + self.logger.info("Thread 'work_item_queue_thread' has been terminated.") + self.work_item_queue_thread = None + + ############################## + # DB Query Preparation + ############################## + + def _query_log_timestamp(self, func: str, item_id: int, ts_start: int, ts_end: int, group: str = None, group2: str = None, ignore_value=None) -> Union[list, None]: + """ + Assemble a mysql query str and param dict based on given parameters, get query response and return it + + :param func: function to be used at query + :param item_id: database item_id for which the query should be done + :param ts_start: start for query given in timestamp in microseconds + :param ts_end: end for query given in timestamp in microseconds + :param group: first grouping parameter (default = None, possible values: day, week, month, year) + :param group2: second grouping parameter (default = None, possible values: day, week, month, year) + :param ignore_value: value of val_num, which will be ignored during query + + :return: query response + + """ + + # DO DEBUG LOG + if self.prepare_debug: + self.logger.debug(f"_query_log_timestamp: Called with {func=}, {item_id=}, {ts_start=}, {ts_end=}, {group=}, {group2=}, {ignore_value=}") + + # DEFINE GENERIC QUERY PARTS + _select = { + 'avg': 'time, ROUND(AVG(val_num * duration) / AVG(duration), 1) as value ', + 'avg1': 'time, ROUND(AVG(value), 1) as value FROM (SELECT time, ROUND(AVG(val_num), 1) as value ', + 'min': 'time, ROUND(MIN(val_num), 1) as value ', + 'max': 'time, ROUND(MAX(val_num), 1) as value ', + 'max1': 'time, ROUND(MAX(value), 1) as value FROM (SELECT time, ROUND(MAX(val_num), 1) as value ', + 'sum': 'time, ROUND(SUM(val_num), 1) as value ', + 'on': 'time, ROUND(SUM(val_bool * duration) / SUM(duration), 1) as value ', + 'integrate': 'time, ROUND(SUM(val_num * duration),1) as value ', + 'sum_max': 'time, ROUND(SUM(value), 1) as value FROM (SELECT time, ROUND(MAX(val_num), 1) as value ', + 'sum_avg': 'time, ROUND(SUM(value), 1) as value FROM (SELECT time, ROUND(AVG(val_num * duration) / AVG(duration), 1) as value ', + 'sum_min_neg': 'time, ROUND(SUM(value), 1) as value FROM (SELECT time, IF(min(val_num) < 0, ROUND(MIN(val_num), 1), 0) as value ', + 'diff_max': 'time, value1 - LAG(value1) OVER (ORDER BY time) AS value FROM (SELECT time, ROUND(MAX(val_num), 1) as value1 ', + 'next': 'time, val_num as value ' + } + + _table_alias = { + 'avg': '', + 'avg1': ') AS table1 ', + 'min': '', + 'max': '', + 'max1': ') AS table1 ', + 'sum': '', + 'on': '', + 'integrate': '', + 'sum_max': ') AS table1 ', + 'sum_avg': ') AS table1 ', + 'sum_min_neg': ') AS table1 ', + 'diff_max': ') AS table1 ', + 'next': '', + } + + _order = "time DESC LIMIT 1 " if func == "next" else "time ASC " + + _where = "item_id = :item_id AND time < :ts_start" if func == "next" else "item_id = :item_id AND time BETWEEN :ts_start AND :ts_end " + + _db_table = 'log ' + + # DEFINE mySQL QUERY PARTS + _group_by_sql = { + "year": "GROUP BY YEAR(FROM_UNIXTIME(time/1000)) ", + "month": "GROUP BY YEAR(FROM_UNIXTIME(time/1000)), MONTH(FROM_UNIXTIME(time/1000)) ", + "week": "GROUP BY YEARWEEK(FROM_UNIXTIME(time/1000), 5) ", + "day": "GROUP BY DATE(FROM_UNIXTIME(time/1000)) ", + "hour": "GROUP BY DATE(FROM_UNIXTIME(time/1000)), HOUR(FROM_UNIXTIME(time/1000)) ", + None: '' + } + + # DEFINE SQLITE QUERY PARTS + _group_by_sqlite = { + "year": "GROUP BY strftime('%Y', date((time/1000),'unixepoch')) ", + "month": "GROUP BY strftime('%Y%m', date((time/1000),'unixepoch')) ", + "week": "GROUP BY strftime('%Y%W', date((time/1000),'unixepoch')) ", + "day": "GROUP BY date((time/1000),'unixepoch') ", + "hour": "GROUP BY date((time/1000),'unixepoch'), strftime('%H', date((time/1000),'unixepoch')) ", + None: '' + } + + ###################################### + + # SELECT QUERY PARTS DEPENDING IN DB DRIVER + if self.db_driver.lower() == 'pymysql': + _group_by = _group_by_sql + elif self.db_driver.lower() == 'sqlite3': + _group_by = _group_by_sqlite + else: + self.logger.error('DB Driver unknown') + return + + # CHECK CORRECTNESS OF FUNC + if func not in _select: + self.logger.error(f"_query_log_timestamp: Requested {func=} for {item_id=} not defined. Query cancelled.") + return + + # CHECK CORRECTNESS OF GROUP AND GROUP2 + if group not in _group_by: + self.logger.error(f"_query_log_timestamp: Requested {group=} for item={item_id=} not defined. Query cancelled.") + return + if group2 not in _group_by: + self.logger.error(f"_query_log_timestamp: Requested {group=} for item={item_id=} not defined. Query cancelled.") + return + + # HANDLE IGNORE VALUES + if func in ['min', 'max', 'max1', 'sum_max', 'sum_avg', 'sum_min_neg', 'diff_max']: # extend _where statement for excluding boolean values == 0 for defined functions + _where = f'{_where}AND val_bool = 1 ' + if ignore_value: # if value to be ignored are defined, extend _where statement + _where = f'{_where}AND val_num != {ignore_value} ' + + # SET PARAMS + params = { + 'item_id': item_id, + 'ts_start': ts_start + } + + if func != "next": + params['ts_end'] = ts_end + + # ASSEMBLE QUERY + query = f"SELECT {_select[func]}FROM {_db_table}WHERE {_where}{_group_by[group]}ORDER BY {_order}{_table_alias[func]}{_group_by[group2]}".strip() + + if self.db_driver.lower() == 'sqlite3': + query = query.replace('IF', 'IIF') + + # DO DEBUG LOG + if self.prepare_debug: + self.logger.debug(f"_query_log_timestamp: {query=}, {params=}") + + # REQUEST DATABASE AND RETURN RESULT + return self._fetchall(query, params) + + def _read_log_all(self, item): + """ + Read the oldest log record for given item + + :param item: Item to read the record for + :type item: item + + :return: Log record for Item + """ + + if self.prepare_debug: + self.logger.debug(f"_read_log_all: Called for item={item}") + + # DEFINE ITEM_ID - create item_id from item or string input of item_id and break, if not given + item_id = self._get_itemid_for_query(item) + if not item_id: + self.logger.error(f"_read_log_all: ItemId for item={item.id()} not found. Query cancelled.") + return + + if item_id: + query = "SELECT * FROM log WHERE (item_id = :item_id) AND (time = None OR 1 = 1)" + params = {'item_id': item_id} + result = self._fetchall(query, params) + return result + + def _read_log_oldest(self, item_id: int, cur=None) -> int: + """ + Read the oldest log record for given database ID + + :param item_id: Database ID of item to read the record for + :type item_id: int + :param cur: A database cursor object if available (optional) + + :return: Log record for the database ID + """ + + params = {'item_id': item_id} + query = "SELECT min(time) FROM log WHERE item_id = :item_id;" + return self._fetchall(query, params, cur=cur)[0][0] + + def _read_log_timestamp(self, item_id: int, timestamp: int, cur=None) -> Union[list, None]: + """ + Read database log record for given database ID + + :param item_id: Database ID of item to read the record for + :type item_id: int + :param timestamp: timestamp for the given value + :type timestamp: int + :param cur: A database cursor object if available (optional) + + :return: Log record for the database ID at given timestamp + """ + + params = {'item_id': item_id, 'timestamp': timestamp} + query = "SELECT * FROM log WHERE item_id = :item_id AND time = :timestamp;" + return self._fetchall(query, params, cur=cur) + + def _read_item_table(self, item): + """ + Read item table + + :param item: name or Item_id of the item within the database + :type item: item + + :return: Data for the selected item + :rtype: tuple + """ + + columns_entries = ('id', 'name', 'time', 'val_str', 'val_num', 'val_bool', 'changed') + columns = ", ".join(columns_entries) + + if isinstance(item, Item): + query = f"SELECT {columns} FROM item WHERE name = '{str(item.id())}'" + return self._fetchone(query) + + elif isinstance(item, str) and item.isdigit(): + item = int(item) + query = f"SELECT {columns} FROM item WHERE id = {item}" + return self._fetchone(query) + + def _get_db_version(self) -> str: + """ + Query the database version and provide result + """ + + query = 'SELECT sqlite_version()' if self.db_driver.lower() == 'sqlite3' else 'SELECT VERSION()' + return self._fetchone(query)[0] + + def _get_db_connect_timeout(self) -> str: + """ + Query database timeout + """ + + query = "SHOW GLOBAL VARIABLES LIKE 'connect_timeout'" + return self._fetchone(query) + + def _get_db_net_read_timeout(self) -> str: + """ + Query database timeout net_read_timeout + """ + + query = "SHOW GLOBAL VARIABLES LIKE 'net_read_timeout'" + return self._fetchone(query) + + ############################## + # Database specific stuff + ############################## + + def _execute(self, query: str, params: dict = None, cur=None): + if params is None: + params = {} + + return self._query(self._db.execute, query, params, cur) + + def _fetchone(self, query: str, params: dict = None, cur=None): + if params is None: + params = {} + + return self._query(self._db.fetchone, query, params, cur) + + def _fetchall(self, query: str, params: dict = None, cur=None): + if params is None: + params = {} + + tuples = self._query(self._db.fetchall, query, params, cur) + return None if tuples is None else list(tuples) + + def _query(self, fetch, query: str, params: dict = None, cur=None): + if params is None: + params = {} + + if self.sql_debug: + self.logger.debug(f"_query: Called with {query=}, {params=}, {cur=}") + + if not self._initialize_db(): + return None + + if cur is None: + if self._db.verify(5) == 0: + self.logger.error("_query: Connection to database not recovered.") + return None + # if not self._db.lock(300): + # self.logger.error("_query: Can't query due to fail to acquire lock.") + # return None + + query_readable = re.sub(r':([a-z_]+)', r'{\1}', query).format(**params) + + try: + tuples = fetch(query, params, cur=cur) + except Exception as e: + self.logger.error(f"_query: Error for query '{query_readable}': {e}") + else: + if self.sql_debug: + self.logger.debug(f"_query: Result of '{query_readable}': {tuples}") + return tuples + # finally: + # if cur is None: + # self._db.release() + + +############################## +# Helper functions +############################## + + +def params_to_dict(string: str) -> Union[dict, None]: + """ Parse a string with named arguments and comma separation to dict; (e.g. string = 'year=2022, month=12') + """ + + try: + res_dict = dict((a.strip(), b.strip()) for a, b in (element.split('=') for element in string.split(', '))) + except Exception: + return None + else: + # convert to int and remove possible double quotes + for key in res_dict: + if isinstance(res_dict[key], str): + res_dict[key] = res_dict[key].replace('"', '') + res_dict[key] = res_dict[key].replace("'", "") + if res_dict[key].isdigit(): + res_dict[key] = int(float(res_dict[key])) + + # check correctness if known key values (func=str, item, timeframe=str, start=int, end=int, count=int, group=str, group2=str, year=int, month=int): + for key in res_dict: + if key in ('func', 'timeframe', 'group', 'group2') and not isinstance(res_dict[key], str): + return None + elif key in ('start', 'end', 'count') and not isinstance(res_dict[key], int): + return None + elif key in 'year': + if not valid_year(res_dict[key]): + return None + elif key in 'month': + if not valid_month(res_dict[key]): + return None + return res_dict + + +def valid_year(year: Union[int, str]) -> bool: + """ + Check if given year is digit and within allowed range + """ + + if ((isinstance(year, int) or (isinstance(year, str) and year.isdigit())) and ( + 1980 <= int(year) <= datetime.date.today().year)) or (isinstance(year, str) and year == 'current'): + return True + else: + return False + + +def valid_month(month: Union[int, str]) -> bool: + """ + Check if given month is digit and within allowed range + """ + + if (isinstance(month, int) or (isinstance(month, str) and month.isdigit())) and (1 <= int(month) <= 12): + return True + else: + return False + + +def timestamp_to_timestring(timestamp: int) -> str: + """ + Parse timestamp from db query to string representing date and time + """ + + return datetime.datetime.utcfromtimestamp(timestamp / 1000).strftime('%Y-%m-%d %H:%M:%S') + + +def convert_timeframe(timeframe: str) -> str: + """ + Convert timeframe + + """ + + convertion = { + 'tag': 'day', + 'heute': 'day', + 'woche': 'week', + 'monat': 'month', + 'jahr': 'year', + 'vorjahreszeitraum': 'day', + 'jahreszeitraum': 'day', + 'd': 'day', + 'w': 'week', + 'm': 'month', + 'y': 'year' + } + + return convertion.get(timeframe, None) + + +def convert_duration(timeframe: str, window_dur: str) -> int: + """ + Convert duration + + """ + + _d_in_y = 365 + _d_in_w = 7 + _m_in_y = 12 + _w_in_y = _d_in_y / _d_in_w + _w_in_m = _w_in_y / _m_in_y + _d_in_m = _d_in_y / _m_in_y + + conversion = { + 'day': {'day': 1, + 'week': _d_in_w, + 'month': _d_in_m, + 'year': _d_in_y, + }, + 'week': {'day': 1 / _d_in_w, + 'week': 1, + 'month': _w_in_m, + 'year': _w_in_y + }, + 'month': {'day': 1 / _d_in_m, + 'week': 1 / _w_in_m, + 'month': 1, + 'year': _m_in_y + }, + 'year': {'day': 1 / _d_in_y, + 'week': 1 / _w_in_y, + 'month': 1 / _m_in_y, + 'year': 1 + } + } + + return round(int(conversion[timeframe][window_dur]), 0) + + +def count_to_start(count: int = 0, end: int = 0): + """ + Converts given count and end ot start and end + """ + + return end + count, end + + +def get_start_end_as_timestamp(timeframe: str, start: int, end: int) -> tuple: + """ + Provides start and end as timestamp in microseconds from timeframe with start and end + + :param timeframe: timeframe as week, month, year + :param start: beginning timeframe in x timeframes from now + :param end: end of timeframe in x timeframes from now + + :return: start time in timestamp in microseconds, end time in timestamp in microseconds + + """ + + return datetime_to_timestamp(get_start(timeframe, start)) * 1000, datetime_to_timestamp(get_end(timeframe, end)) * 1000 + + +def get_start(timeframe: str, start: int) -> datetime: + """ + Provides start as datetime + + :param timeframe: timeframe as week, month, year + :param start: beginning timeframe in x timeframes from now + + """ + + if start is None: + start = 0 + + if timeframe == 'week': + _dt_start = week_beginning(start) + elif timeframe == 'month': + _dt_start = month_beginning(start) + elif timeframe == 'year': + _dt_start = year_beginning(start) + else: + _dt_start = day_beginning(start) + + return _dt_start + + +def get_end(timeframe: str, end: int) -> datetime: + """ + Provides end as datetime + + :param timeframe: timeframe as week, month, year + :param end: end of timeframe in x timeframes from now + + """ + + if timeframe == 'week': + _dt_end = week_end(end) + elif timeframe == 'month': + _dt_end = month_end(end) + elif timeframe == 'year': + _dt_end = year_end(end) + else: + _dt_end = day_end(end) + + return _dt_end + + +def year_beginning(delta: int = 0) -> datetime: + """ + provides datetime of beginning of year of today minus x years + """ + + _dt = datetime.datetime.combine(datetime.date.today(), datetime.datetime.min.time()) + return _dt.replace(month=1, day=1) - relativedelta(years=delta) + + +def year_end(delta: int = 0) -> datetime: + """ + provides datetime of end of year of today minus x years + """ + + return year_beginning(delta) + relativedelta(years=1) + + +def month_beginning(delta: int = 0) -> datetime: + """ + provides datetime of beginning of month minus x month + """ + + _dt = datetime.datetime.combine(datetime.date.today(), datetime.datetime.min.time()) + return _dt.replace(day=1) - relativedelta(months=delta) + + +def month_end(delta: int = 0) -> datetime: + """ + provides datetime of end of month minus x month + """ + + return month_beginning(delta) + relativedelta(months=1) + + +def week_beginning(delta: int = 0) -> datetime: + """ + provides datetime of beginning of week minus x weeks + """ + + _dt = datetime.datetime.combine(datetime.date.today(), datetime.datetime.min.time()) + return _dt - relativedelta(days=(datetime.date.today().weekday() + (delta * 7))) + + +def week_end(delta: int = 0) -> datetime: + """ + provides datetime of end of week minus x weeks + """ + + return week_beginning(delta) + relativedelta(days=6) + + +def day_beginning(delta: int = 0) -> datetime: + """ + provides datetime of beginning of today minus x days + """ + + return datetime.datetime.combine(datetime.date.today(), datetime.datetime.min.time()) - relativedelta(days=delta) + + +def day_end(delta: int = 0) -> datetime: + """ + provides datetime of end of today minus x days + """ + + return day_beginning(delta) + relativedelta(days=1) + + +def datetime_to_timestamp(dt: datetime) -> int: + """ + Provides timestamp from given datetime + """ + + return int(dt.replace(tzinfo=datetime.timezone.utc).timestamp()) + + +def check_substring_in_str(lookfor: Union[str, list], target: str) -> bool: + for entry in lookfor: + if isinstance(entry, str): + if entry in target: + return True + elif isinstance(entry, list): + result = True + for element in entry: + result = result and element in target # einmal False setzt alles auf False + if result: + return True + return False + + +def onchange_attribute(db_addon_fct) -> bool: + """ + Return True if attribute indicates Item to be calculated on-change + + ONCHANGE_ATTRIBUTES = ['verbrauch_heute', 'verbrauch_woche', 'verbrauch_monat', 'verbrauch_jahr', + 'minmax_heute_min', 'minmax_heute_max', + 'minmax_woche_min', 'minmax_woche_max', + 'minmax_monat_min', 'minmax_monat_max', + 'minmax_jahr_min', 'minmax_jahr_max'] + + """ + return True if not any(substring in db_addon_fct for substring in ['minus', 'serie', 'last']) else False + + +def daily_attribute(db_addon_fct) -> bool: + """ + Return True if attribute indicates Item to be calculated daily" + """ + return True if check_substring_in_str(['heute_minus', 'last_', 'jahreszeitraum', ['serie', 'tag'], ['serie', 'stunde']], db_addon_fct) else False + + +def weekly_attribute(db_addon_fct) -> bool: + """ + Return True if attribute indicates Item to be calculated weekly" + """ + return True if check_substring_in_str(['woche_minus', ['serie', 'woche']], db_addon_fct) else False + + +def monthly_attribute(db_addon_fct) -> bool: + """ + Return True if attribute indicates Item to be calculated daily" + """ + return True if check_substring_in_str(['monat_minus', ['serie', 'monat']], db_addon_fct) else False + + +def yearly_attribute(db_addon_fct) -> bool: + """ + Return True if attribute indicates Item to be calculated yearly" + """ + return True if check_substring_in_str(['jahr_minus', ['serie', 'jahr']], db_addon_fct) else False + + +STD_REQUEST_DICT = { + 'serie_minmax_monat_min_15m': {'func': 'min', 'timeframe': 'month', 'start': 15, 'end': 0, 'group': 'month'}, + 'serie_minmax_monat_max_15m': {'func': 'max', 'timeframe': 'month', 'start': 15, 'end': 0, 'group': 'month'}, + 'serie_minmax_monat_avg_15m': {'func': 'avg', 'timeframe': 'month', 'start': 15, 'end': 0, 'group': 'month'}, + 'serie_minmax_woche_min_30w': {'func': 'min', 'timeframe': 'week', 'start': 30, 'end': 0, 'group': 'week'}, + 'serie_minmax_woche_max_30w': {'func': 'max', 'timeframe': 'week', 'start': 30, 'end': 0, 'group': 'week'}, + 'serie_minmax_woche_avg_30w': {'func': 'avg', 'timeframe': 'week', 'start': 30, 'end': 0, 'group': 'week'}, + 'serie_minmax_tag_min_30d': {'func': 'min', 'timeframe': 'day', 'start': 30, 'end': 0, 'group': 'day'}, + 'serie_minmax_tag_max_30d': {'func': 'max', 'timeframe': 'day', 'start': 30, 'end': 0, 'group': 'day'}, + 'serie_minmax_tag_avg_30d': {'func': 'avg', 'timeframe': 'day', 'start': 30, 'end': 0, 'group': 'day'}, + 'serie_verbrauch_tag_30d': {'func': 'diff_max', 'timeframe': 'day', 'start': 30, 'end': 0, 'group': 'day'}, + 'serie_verbrauch_woche_30w': {'func': 'diff_max', 'timeframe': 'week', 'start': 30, 'end': 0, 'group': 'week'}, + 'serie_verbrauch_monat_18m': {'func': 'diff_max', 'timeframe': 'month', 'start': 18, 'end': 0, 'group': 'month'}, + 'serie_zaehlerstand_tag_30d': {'func': 'max', 'timeframe': 'day', 'start': 30, 'end': 0, 'group': 'day'}, + 'serie_zaehlerstand_woche_30w': {'func': 'max', 'timeframe': 'week', 'start': 30, 'end': 0, 'group': 'week'}, + 'serie_zaehlerstand_monat_18m': {'func': 'max', 'timeframe': 'month', 'start': 18, 'end': 0, 'group': 'month'}, + 'serie_waermesumme_monat_24m': {'func': 'sum_max', 'timeframe': 'month', 'start': 24, 'end': 0, 'group': 'day', 'group2': 'month'}, + 'serie_kaeltesumme_monat_24m': {'func': 'sum_max', 'timeframe': 'month', 'start': 24, 'end': 0, 'group': 'day', 'group2': 'month'}, + 'serie_tagesmittelwert': {'func': 'max', 'timeframe': 'year', 'start': 0, 'end': 0, 'group': 'day'}, + 'serie_tagesmittelwert_stunde_0d': {'func': 'avg1', 'timeframe': 'day', 'start': 0, 'end': 0, 'group': 'hour', 'group2': 'day'}, + 'serie_tagesmittelwert_tag_stunde_30d': {'func': 'avg1', 'timeframe': 'day', 'start': 30, 'end': 0, 'group': 'hour', 'group2': 'day'}, + 'waermesumme_year_month': {'func': 'sum_max', 'timeframe': 'day', 'start': None, 'end': None, 'group': 'day', 'group2': None}, + 'kaltesumme_year_month': {'func': 'sum_min_neg', 'timeframe': 'day', 'start': None, 'end': None, 'group': 'day', 'group2': None}, + 'gts': {'func': 'max', 'timeframe': 'year', 'start': None, 'end': None, 'group': 'day'}, + } + +############################## +# Backup +############################## +# +# def _delta_value(self, item, time_str_1, time_str_2): +# """ Computes a difference of values on 2 points in time for an item +# +# :param item: Item, for which query should be done +# :param time_str_1: time sting as per database-Plugin for newer point in time (e.g.: 200i) +# :param time_str_2: Zeitstring gemäß database-Plugin for older point in time(e.g.: 400i) +# """ +# +# time_since_oldest_log = self._time_since_oldest_log(item) +# end = int(time_str_1[0:len(time_str_1) - 1]) +# +# if time_since_oldest_log > end: +# # self.logger.debug(f'_delta_value: fetch DB with {item.id()}.db(max, {time_str_1}, {time_str_1})') +# value_1 = self._db_plugin._single('max', time_str_1, time_str_1, item.id()) +# +# # self.logger.debug(f'_delta_value: fetch DB with {item.id()}.db(max, {time_str_2}, {time_str_2})') +# value_2 = self._db_plugin._single('max', time_str_2, time_str_2, item.id()) +# +# if value_1 is not None: +# if value_2 is None: +# self.logger.info(f'No entries for Item {item.id()} in DB found for requested enddate {time_str_1}; try to use oldest entry instead') +# value_2 = self._get_oldest_value(item) +# if value_2 is not None: +# value = round(value_1 - value_2, 2) +# # self.logger.debug(f'_delta_value for item={item.id()} with time_str_1={time_str_1} and time_str_2={time_str_2} is {value}') +# return value +# else: +# self.logger.info(f'_delta_value for item={item.id()} using time_str_1={time_str_1} is older as oldest_entry. Therefore no DB request initiated.') +# +# def _single_value(self, item, time_str_1, func='max'): +# """ Gets value at given point im time from database +# +# :param item: item, for which query should be done +# :param time_str_1: time sting as per database-Plugin for point in time (e.g.: 200i) +# :param func: function of database plugin +# """ +# +# # value = None +# # value = item.db(func, time_str_1, time_str_1) +# value = self._db_plugin._single(func, time_str_1, time_str_1, item.id()) +# if value is None: +# self.logger.info(f'No entries for Item {item.id()} in DB found for requested end {time_str_1}; try to use oldest entry instead') +# value = int(self._get_oldest_value(item)) +# # self.logger.debug(f'_single_value for item={item.id()} with time_str_1={time_str_1} is {value}') +# return value +# +# def _connect_to_db(self, host=None, user=None, password=None, db=None): +# """ Connect to DB via pymysql +# """ +# +# if not host: +# host = self.connection_data[0].split(':', 1)[1] +# if not user: +# user = self.connection_data[1].split(':', 1)[1] +# if not password: +# password = self.connection_data[2].split(':', 1)[1] +# if not db: +# db = self.connection_data[3].split(':', 1)[1] +# port = self.connection_data[4].split(':', 1)[1] +# +# try: +# connection = pymysql.connect(host=host, user=user, password=password, db=db, charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor) +# except Exception as e: +# self.logger.error(f"Connection to Database failed with error {e}!.") +# return +# else: +# return connection +# +# +# def _get_itemid_via_db_plugin(self, item): +# """ Get item_id of item out of dict or request it from db via database plugin and put it into dict +# """ +# +# # self.logger.debug(f"_get_itemid called for item={item}") +# +# _item_id = self.itemid_dict.get(item, None) +# if _item_id is None: +# _item_id = self._db_plugin.id(item) +# self.itemid_dict[item] = _item_id +# +# return _item_id +# +# def _get_time_strs(self, key, x): +# """ Create timestrings for database query depending in key with +# +# :param key: key for getting the time strings +# :param x: time difference as increment +# :return: tuple of timestrings (timestr closer to now, timestr more in the past) +# +# """ +# +# self.logger.debug(f"_get_time_strs called with key={key}, x={x}") +# +# if key == 'heute': +# _time_str_1 = self._time_str_heute_minus_x(x - 1) +# _time_str_2 = self._time_str_heute_minus_x(x) +# elif key == 'woche': +# _time_str_1 = self._time_str_woche_minus_x(x - 1) +# _time_str_2 = self._time_str_woche_minus_x(x) +# elif key == 'monat': +# _time_str_1 = self._time_str_monat_minus_x(x - 1) +# _time_str_2 = self._time_str_monat_minus_x(x) +# elif key == 'jahr': +# _time_str_1 = self._time_str_jahr_minus_x(x - 1) +# _time_str_2 = self._time_str_jahr_minus_x(x) +# elif key == 'vorjahreszeitraum': +# _time_str_1 = self._time_str_heute_minus_jahre_x(x + 1) +# _time_str_2 = self._time_str_jahr_minus_x(x+1) +# else: +# _time_str_1 = None +# _time_str_2 = None +# +# # self.logger.debug(f"_time_str_1={_time_str_1}, _time_str_2={_time_str_2}") +# return _time_str_1, _time_str_2 +# +# def _time_str_heute_minus_x(self, x=0): +# """ Creates an str for db request in min from time since beginning of today""" +# return f"{self.shtime.time_since(self.shtime.today(-x), 'im')}i" +# +# def _time_str_woche_minus_x(self, x=0): +# """ Creates an str for db request in min from time since beginning of week""" +# return f"{self.shtime.time_since(self.shtime.beginning_of_week(self.shtime.calendar_week(), None, -x), 'im')}i" +# +# def _time_str_monat_minus_x(self, x=0): +# """ Creates an str for db request in min for time since beginning of month""" +# return f"{self.shtime.time_since(self.shtime.beginning_of_month(None, None, -x), 'im')}i" +# +# def _time_str_jahr_minus_x(self, x=0): +# """ Creates an str for db request in min for time since beginning of year""" +# return f"{self.shtime.time_since(self.shtime.beginning_of_year(None, -x), 'im')}i" +# +# def _time_str_heute_minus_jahre_x(self, x=0): +# """ Creates an str for db request in min for time since now x years ago""" +# return f"{self.shtime.time_since(self.shtime.now() + relativedelta(years=-x), 'im')}i" +# +# def _time_since_oldest_log(self, item): +# """ Ermittlung der Zeit in ganzen Minuten zwischen "now" und dem ältesten Eintrag eines Items in der DB +# +# :param item: Item, for which query should be done +# :return: time in minutes from oldest entry to now +# """ +# +# _timestamp = self._get_oldest_log(item) +# _oldest_log_dt = datetime.datetime.fromtimestamp(int(_timestamp) / 1000, +# datetime.timezone.utc).astimezone().strftime( +# '%Y-%m-%d %H:%M:%S %Z%z') +# return self.shtime.time_since(_oldest_log_dt, resulttype='im') +# +# @staticmethod +# def _get_dbtimestamp_from_date(date): +# """ Compute a timestamp for database entry from given date +# +# :param date: datetime object / string of format 'yyyy-mm' +# """ +# +# d = None +# if isinstance(date, datetime.date): +# d = date +# elif isinstance(date, str): +# date = date.split('-') +# if len(date) == 2: +# year = int(date[0]) +# month = int(date[1]) +# if (1980 <= year <= datetime.date.today().year) and (1 <= month <= 12): +# d = datetime.date(year, month, 1) +# +# if d: +# return int(time.mktime(d.timetuple()) * 1000) +# +# def fetch_min_monthly_count(sh, item, count=None): +# _logger.warning(f"Die Userfunction 'fetch_min_monthly_count' wurde aufgerufen mit item {item} and count {count}") +# +# if type(item) is str: +# item = get_item_id(item) +# if count is None: +# # query = f"SELECT CONCAT(YEAR(FROM_UNIXTIME(time/1000)), '-', LPAD(MONTH(FROM_UNIXTIME(time/1000)), 2, '0')) AS Date, MIN(val_num) FROM log WHERE item_id = {item} GROUP BY Date ORDER BY Date ASC" +# query = f"SELECT time, MIN(val_num) FROM log WHERE item_id = {item} GROUP BY YEAR(FROM_UNIXTIME(time/1000)), MONTH(FROM_UNIXTIME(time/1000)) ORDER BY time ASC" +# else: +# # query = f"SELECT CONCAT(YEAR(FROM_UNIXTIME(time/1000)), '-', LPAD(MONTH(FROM_UNIXTIME(time/1000)), 2, '0')) AS Date, MIN(val_num) FROM log WHERE item_id = {item} AND DATE(FROM_UNIXTIME(time/1000)) > DATE_SUB(now(), INTERVAL {count} MONTH) GROUP BY Date ORDER BY Date ASC" +# query = f"SELECT time, MIN(val_num) FROM log WHERE item_id = {item} AND DATE(FROM_UNIXTIME(time/1000)) > DATE_SUB(DATE_FORMAT(NOW() ,'%Y-%m-01'), INTERVAL {count} MONTH) GROUP BY YEAR(FROM_UNIXTIME(time/1000)), MONTH(FROM_UNIXTIME(time/1000)) ORDER BY time ASC" +# +# result = [] +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# finally: +# connection.close() +# +# value_list = [] +# for element in result: +# value_list.append([element['time'], element['MIN(val_num)']]) +# +# _logger.warning(f'mysql.fetch_min_monthly_count value_list: {value_list}') +# return value_list +# +# def fetch_max_monthly_count(sh, item, count=None): +# _logger.warning(f"Die Userfunction 'fetch_max_monthly_count' wurde aufgerufen mit item {item} and count {count}") +# +# if type(item) is str: +# item = get_item_id(item) +# if count is None: +# # query = f"SELECT CONCAT(YEAR(FROM_UNIXTIME(time/1000)), '-', LPAD(MONTH(FROM_UNIXTIME(time/1000)), 2, '0')) AS Date, MAX(val_num) FROM log WHERE item_id = {item} GROUP BY Date ORDER BY Date ASC" +# query = f"SELECT time, MAX(val_num) FROM log WHERE item_id = {item} GROUP BY YEAR(FROM_UNIXTIME(time/1000)), MONTH(FROM_UNIXTIME(time/1000)) ORDER BY time ASC" +# else: +# # query = f"SELECT CONCAT(YEAR(FROM_UNIXTIME(time/1000)), '-', LPAD(MONTH(FROM_UNIXTIME(time/1000)), 2, '0')) AS Date, MAX(val_num) FROM log WHERE item_id = {item} AND DATE(FROM_UNIXTIME(time/1000)) > DATE_SUB(now(), INTERVAL {count} MONTH) GROUP BY Date ORDER BY Date ASC" +# query = f"SELECT time, MAX(val_num), DATE(FROM_UNIXTIME(time/1000)) as DATE FROM log WHERE item_id = {item} AND DATE(FROM_UNIXTIME(time/1000)) > DATE_SUB(DATE_FORMAT(NOW() ,'%Y-%m-01'), INTERVAL {count} MONTH) GROUP BY YEAR(FROM_UNIXTIME(time/1000)), MONTH(FROM_UNIXTIME(time/1000)) ORDER BY time ASC" +# +# result = [] +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# finally: +# connection.close() +# +# _logger.warning(f'mysql.fetch_max_monthly_count result: {result}') +# +# value_list = [] +# for element in result: +# value_list.append([element['time'], element['MAX(val_num)']]) +# +# _logger.warning(f'mysql.fetch_max_monthly_count value_list: {value_list}') +# return value_list +# +# def fetch_avg_monthly_count(sh, item, count=None): +# _logger.warning(f"Die Userfunction 'fetch_avg_monthly_count' wurde aufgerufen mit item {item} and count {count}") +# +# if type(item) is str: +# item = get_item_id(item) +# if count is None: +# query = f"SELECT time, ROUND(AVG(val_num * duration) / AVG(duration),2) as AVG FROM log WHERE item_id = {item} GROUP BY YEAR(FROM_UNIXTIME(time/1000)), MONTH(FROM_UNIXTIME(time/1000)) ORDER BY time ASC" +# else: +# query = f"SELECT time, ROUND(AVG(val_num * duration) / AVG(duration),2) as AVG FROM log WHERE item_id = {item} AND DATE(FROM_UNIXTIME(time/1000)) > DATE_SUB(DATE_FORMAT(NOW() ,'%Y-%m-01'), INTERVAL {count} MONTH) GROUP BY YEAR(FROM_UNIXTIME(time/1000)), MONTH(FROM_UNIXTIME(time/1000)) ORDER BY time ASC" +# +# result = [] +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# finally: +# connection.close() +# +# value_list = [] +# for element in result: +# value_list.append([element['time'], element['AVG']]) +# +# _logger.warning(f'mysql.fetch_avg_monthly_count value_list: {value_list}') +# return value_list +# +# def fetch_min_max_monthly_count(sh, item, count=None): +# _logger.warning(f"Die Userfunction 'fetch_min_max_monthly_count' wurde aufgerufen mit item {item} and count {count}") +# +# if type(item) is str: +# item = get_item_id(item) +# if count is None: +# query = f"SELECT CONCAT(YEAR(FROM_UNIXTIME(time/1000)), '-', LPAD(MONTH(FROM_UNIXTIME(time/1000)), 2, '0')) AS Date, MAX(val_num), MIN(val_num) FROM log WHERE item_id = {item} GROUP BY Date ORDER BY Date DESC" +# else: +# query = f"SELECT CONCAT(YEAR(FROM_UNIXTIME(time/1000)), '-', LPAD(MONTH(FROM_UNIXTIME(time/1000)), 2, '0')) AS Date, MAX(val_num), MIN(val_num) FROM log WHERE item_id = {item} AND DATE(FROM_UNIXTIME(time/1000)) > DATE_SUB(now(), INTERVAL {count} MONTH) GROUP BY Date ORDER BY Date DESC" +# +# result = [] +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# finally: +# connection.close() +# _logger.warning(f'mysql result: {result}') +# return result +# +# def fetch_min_max_monthly_year(sh, item, year=None): +# _logger.warning(f"Die Userfunction 'fetch_min_max_monthly_year' wurde aufgerufen mit item {item} and year {year}") +# +# if type(item) is str: +# item = get_item_id(item) +# if year is None: +# year = datetime.now().year +# +# query = f"SELECT CONCAT(YEAR(FROM_UNIXTIME(time/1000)), '-', LPAD(MONTH(FROM_UNIXTIME(time/1000)), 2, '0')) AS Date, MAX(val_num), MIN(val_num) FROM log WHERE item_id = {item} AND YEAR(FROM_UNIXTIME(time/1000)) = {year} GROUP BY Date ORDER BY Date DESC" +# result = [] +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# finally: +# connection.close() +# _logger.warning(f'mysql result: {result}') +# return result +# +# def fetch_min_weekly_count(sh, item, count=None): +# _logger.warning(f"Die Userfunction 'fetch_min_weekly_count' wurde aufgerufen mit item {item} and count {count}") +# +# if type(item) is str: +# item = get_item_id(item) +# if count is None: +# count = 51 +# query = f"SELECT time, MIN(val_num), DATE(FROM_UNIXTIME(time/1000)) as DATE FROM log WHERE item_id = {item} AND DATE(FROM_UNIXTIME(time/1000)) > DATE_SUB(DATE_ADD(CURDATE(), INTERVAL - WEEKDAY(CURDATE()) DAY), INTERVAL {count} WEEK) GROUP BY YEAR(FROM_UNIXTIME(time/1000)), WEEK(FROM_UNIXTIME(time/1000)) ORDER BY time ASC" +# result = [] +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# finally: +# connection.close() +# +# value_list = [] +# for element in result: +# value_list.append([element['time'], element['MIN(val_num)']]) +# +# _logger.warning(f'mysql.fetch_min_weekly_count value_list: {value_list}') +# return value_list +# +# def fetch_max_weekly_count(sh, item, count=None): +# _logger.warning(f"Die Userfunction 'fetch_max_weekly_count' wurde aufgerufen mit item {item} and count {count}") +# +# if type(item) is str: +# item = get_item_id(item) +# if count is None: +# count = 51 +# query = f"SELECT time, MAX(val_num) FROM log WHERE item_id = {item} AND DATE(FROM_UNIXTIME(time/1000)) > DATE_SUB(DATE_ADD(CURDATE(), INTERVAL - WEEKDAY(CURDATE()) DAY), INTERVAL {count} WEEK) GROUP BY YEAR(FROM_UNIXTIME(time/1000)), WEEK(FROM_UNIXTIME(time/1000)) ORDER BY time ASC" +# result = [] +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# finally: +# connection.close() +# +# value_list = [] +# for element in result: +# value_list.append([element['time'], element['MAX(val_num)']]) +# +# _logger.warning(f'mysql.fetch_max_weekly_count value_list: {value_list}') +# return value_list +# +# def fetch_avg_weekly_count(sh, item, count=None): +# _logger.warning(f"Die Userfunction 'fetch_avg_weekly_count' wurde aufgerufen mit item {item} and count {count}") +# +# if type(item) is str: +# item = get_item_id(item) +# if count is None: +# count = 51 +# query = f"SELECT time, ROUND(AVG(val_num * duration) / AVG(duration),2) as AVG FROM log WHERE item_id = {item} AND DATE(FROM_UNIXTIME(time/1000)) > DATE_SUB(DATE_ADD(CURDATE(), INTERVAL - WEEKDAY(CURDATE()) DAY), INTERVAL {count} WEEK) GROUP BY YEAR(FROM_UNIXTIME(time/1000)), WEEK(FROM_UNIXTIME(time/1000)) ORDER BY time ASC" +# result = [] +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# finally: +# connection.close() +# +# value_list = [] +# for element in result: +# value_list.append([element['time'], element['AVG']]) +# +# _logger.warning(f'mysql.fetch_avg_weekly_count value_list: {value_list}') +# return value_list +# +# def fetch_min_max_weekly_count(sh, item, count=None): +# _logger.warning(f"Die Userfunction 'fetch_min_max_weekly_count' wurde aufgerufen mit item {item} and count {count}") +# +# if type(item) is str: +# item = get_item_id(item) +# if count is None: +# count = 51 +# query = f"SELECT time, MAX(val_num), MIN(val_num), DATE(FROM_UNIXTIME(time/1000)) as DATE FROM log WHERE item_id = {item} AND DATE(FROM_UNIXTIME(time/1000)) > DATE_SUB(DATE_ADD(CURDATE(), INTERVAL - WEEKDAY(CURDATE()) DAY), INTERVAL {count} WEEK) GROUP BY YEAR(FROM_UNIXTIME(time/1000)), WEEK(FROM_UNIXTIME(time/1000)) ORDER BY time ASC" +# result = [] +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# finally: +# connection.close() +# _logger.warning(f'mysql result: {result}') +# return result +# +# def fetch_min_max_weekly_year(sh, item, year=None): +# _logger.warning(f"Die Userfunction 'fetch_min_max_weekly_year' wurde aufgerufen mit item {item} and year {year}") +# +# if type(item) is str: +# item = get_item_id(item) +# if year is None: +# year = datetime.now().year +# +# query = f"SELECT CONCAT(YEAR(FROM_UNIXTIME(time/1000)), '/', LPAD(WEEK(FROM_UNIXTIME(time/1000)), 2, '0')) AS Date, MAX(val_num), MIN(val_num) FROM log WHERE item_id = {item} AND YEAR(FROM_UNIXTIME(time/1000)) = {year} GROUP BY Date ORDER BY Date DESC" +# result = [] +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# finally: +# connection.close() +# _logger.warning(f'mysql result: {result}') +# return result +# +# def fetch_min_daily_count(sh, item, count=None): +# _logger.warning(f"Die Userfunction 'fetch_min_daily_count' wurde aufgerufen mit item {item} as type {type(item)} and count {count}") +# +# if type(item) is str: +# item = get_item_id(item) +# if count is None: +# count = 30 +# +# query = f"SELECT time, MIN(val_num) FROM log WHERE item_id = {item} AND DATE(FROM_UNIXTIME(time/1000)) > DATE_SUB(now(), INTERVAL {count} DAY) GROUP BY DATE(FROM_UNIXTIME(time/1000)) ORDER BY time ASC" +# result = [] +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# finally: +# connection.close() +# +# value_list = [] +# for element in result: +# value_list.append([element['time'], element['MIN(val_num)']]) +# +# _logger.warning(f'mysql.fetch_min_daily_count value_list: {value_list}') +# return value_list +# +# def fetch_max_daily_count(sh, item, count=None): +# _logger.warning(f"Die Userfunction 'fetch_max_daily_count' wurde aufgerufen mit item {item} as type {type(item)} and count {count}") +# +# if type(item) is str: +# item = get_item_id(item) +# if count is None: +# count = 30 +# +# query = f"SELECT time, MAX(val_num) FROM log WHERE item_id = {item} AND DATE(FROM_UNIXTIME(time/1000)) > DATE_SUB(now(), INTERVAL {count} DAY) GROUP BY DATE(FROM_UNIXTIME(time/1000)) ORDER BY time ASC" +# result = [] +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# finally: +# connection.close() +# +# +# value_list = [] +# for element in result: +# value_list.append([element['time'], element['MAX(val_num)']]) +# +# _logger.warning(f'mysql.fetch_max_daily_count value_list: {value_list}') +# return value_list +# +# def fetch_min_max_daily_count(sh, item, count=None): +# _logger.warning(f"Die Userfunction 'fetch_min_max_daily_count' wurde aufgerufen mit item {item} as type {type(item)} and count {count}") +# +# if type(item) is str: +# item = get_item_id(item) +# if count is None: +# count = 30 +# +# query = f"SELECT DATE(FROM_UNIXTIME(time/1000)) AS Date, MAX(val_num), MIN(val_num) FROM log WHERE item_id = {item} AND DATE(FROM_UNIXTIME(time/1000)) > DATE_SUB(now(), INTERVAL {count} DAY) GROUP BY Date ORDER BY Date DESC" +# result = [] +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# finally: +# connection.close() +# _logger.warning(f'mysql result: {result}') +# return result +# +# def fetch_min_max_daily_year(sh, item, year=None): +# _logger.warning(f"Die Userfunction 'fetch_min_max_daily_year' wurde aufgerufen mit item {item} and year {year}") +# +# if type(item) is str: +# item = get_item_id(item) +# if year is None: +# year = datetime.now().year +# +# query = f"SELECT DATE(FROM_UNIXTIME(time/1000)) AS Date, MAX(val_num), MIN(val_num) FROM log WHERE item_id = {item} AND YEAR(FROM_UNIXTIME(time/1000)) = {year} GROUP BY Date ORDER BY Date DESC" +# result = [] +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# finally: +# connection.close() +# _logger.warning(f'mysql result: {result}') +# return result +# +# def _fetch_query(self, query): +# +# self.logger.debug(f"'_fetch_query' has been called with query={query}") +# connection = self._connect_to_db() +# if connection: +# try: +# connection = connect_db(sh) +# with connection.cursor() as cursor: +# cursor.execute(query) +# result = cursor.fetchall() +# except Exception as e: +# self.logger.error(f"_fetch_query failed with error={e}") +# else: +# self.logger.debug(f'_fetch_query result={result}') +# return result +# finally: +# connection.close() diff --git a/db_addon/locale.yaml b/db_addon/locale.yaml new file mode 100644 index 000000000..2c14a15da --- /dev/null +++ b/db_addon/locale.yaml @@ -0,0 +1,12 @@ +# translations for the web interface +plugin_translations: + # Translations for the plugin specially for the web interface + 'daily': {'de': 'täglich', 'en': 'daily'} + 'weekly': {'de': 'wöchentlich', 'en': '='} + 'monthly': {'de': 'monatlich', 'en': '='} + 'yearly': {'de': 'jährlich', 'en': '='} + + # Alternative format for translations of longer texts: + 'Hier kommt der Inhalt des Webinterfaces hin.': + de: '=' + en: 'Here goes the content of the web interface.' diff --git a/db_addon/plugin.yaml b/db_addon/plugin.yaml new file mode 100644 index 000000000..7aeb65439 --- /dev/null +++ b/db_addon/plugin.yaml @@ -0,0 +1,1073 @@ +# Metadata for the plugin +plugin: + # Global plugin attributes + type: system # plugin type (gateway, interface, protocol, system, web) + description: + de: 'Add-On für das database Plugin zur Datenauswertung' + en: 'Add-On for the database plugin for data evaluation' + maintainer: sisamiwe + tester: bmx, onkelandy # Who tests this plugin? + state: ready # change to ready when done with development +# keywords: iot xyz +# documentation: https://github.com/smarthomeNG/smarthome/wiki/CLI-Plugin # url of documentation (wiki) page + support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1848494-support-thread-databaseaddon-plugin + version: 1.0.0 # Plugin version (must match the version specified in __init__.py) + sh_minversion: 1.9.3.5 # minimum shNG version to use this plugin +# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) + py_minversion: 3.8 # minimum Python version to use for this plugin +# py_maxversion: # maximum Python version to use for this plugin (leave empty if latest) + multi_instance: false # plugin supports multi instance + restartable: unknown + classname: DatabaseAddOn # class containing the plugin + +parameters: + database_plugin_config: + type: str + default: 'database' + description: + de: "Konfiguration des Plugin 'Database', für die das Plugin 'DatabaseAddOn' verwendet wird" + en: "Config of Plugin 'Database, for which the Plugin 'DatabaseAddOn' should be active" + + startup_run_delay: + type: int + default: 60 + description: + de: 'Zeitlicher Abstand in Sekunden, mit der die Berechnungen bei Startup ausgeführt werden sollen' + en: 'Delay in seconds, after which the startup calculations will be run' + + ignore_0: + type: list + default: [] + description: + de: "Bei Items, bei denen ein String aus der Liste im Pfadnamen vorkommt, werden 0-Werte (val_num = 0) bei Datenbankauswertungen ignoriert. + Beispieleintrag: temp | hum" + en: "At items having a entry of that list in path, val_num=0 will be ignored for database queries. + Example: temp | hum" + + use_oldest_entry: + type: bool + default: False + description: + de: "True: Verwendung des ältesten Eintrags des Items in der Datenbank, falls der Start des Abfragezeitraums zeitlich vor diesem Eintrag liegt + False: Abbruch der Datenbankabfrage" + en: "True: Use of oldest entry of item in database, if start of query is prior to oldest entry + False: Cancel query" + +item_attributes: + db_addon_fct: + type: str + description: + de: 'Auswertefunktion des DB-Addon Plugins' + en: 'Evaluation Function of DB-Addon Plugins' + valid_list: + # Verbrauch + - 'verbrauch_heute' #num onchange Verbrauch am heutigen Tag (Differenz zwischen aktuellem Wert und den Wert am Ende des vorherigen Tages) + - 'verbrauch_woche' #num onchange Verbrauch in der aktuellen Woche + - 'verbrauch_monat' #num onchange Verbrauch im aktuellen Monat + - 'verbrauch_jahr' #num onchange Verbrauch im aktuellen Jahr + - 'verbrauch_heute_minus1' #num daily Verbrauch gestern (heute -1 Tag) (Differenz zwischen Wert am Ende des gestrigen Tages und dem Wert am Ende des Tages danach) + - 'verbrauch_heute_minus2' #num daily Verbrauch vorgestern (heute -2 Tage) + - 'verbrauch_heute_minus3' #num daily Verbrauch heute -3 Tage + - 'verbrauch_heute_minus4' #num daily Verbrauch heute -4 Tage + - 'verbrauch_heute_minus5' #num daily Verbrauch heute -5 Tage + - 'verbrauch_heute_minus6' #num daily Verbrauch heute -6 Tage + - 'verbrauch_heute_minus7' #num daily Verbrauch heute -7 Tage + - 'verbrauch_woche_minus1' #num weekly Verbrauch Vorwoche (aktuelle Woche -1) + - 'verbrauch_woche_minus2' #num weekly Verbrauch aktuelle Woche -2 Wochen + - 'verbrauch_woche_minus3' #num weekly Verbrauch aktuelle Woche -3 Wochen + - 'verbrauch_woche_minus4' #num weekly Verbrauch aktuelle Woche -4 Wochen + - 'verbrauch_monat_minus1' #num monthly Verbrauch Vormonat (aktueller Monat -1) + - 'verbrauch_monat_minus2' #num monthly Verbrauch aktueller Monat -2 Monate + - 'verbrauch_monat_minus3' #num monthly Verbrauch aktueller Monat -3 Monate + - 'verbrauch_monat_minus4' #num monthly Verbrauch aktueller Monat -4 Monate + - 'verbrauch_monat_minus12' #num monthly Verbrauch aktueller Monat -12 Monate + - 'verbrauch_jahr_minus1' #num yearly Verbrauch Vorjahr (aktuelles Jahr -1 Jahr) + - 'verbrauch_jahr_minus2' #num yearly Verbrauch aktuelles Jahr -2 Jahre + - 'verbrauch_rolling_12m_heute_minus1' #num daily Verbrauch der letzten 12 Monate ausgehend im Ende des letzten Tages + - 'verbrauch_rolling_12m_woche_minus1' #num weekly Verbrauch der letzten 12 Monate ausgehend im Ende der letzten Woche + - 'verbrauch_rolling_12m_monat_minus1' #num monthly Verbrauch der letzten 12 Monate ausgehend im Ende des letzten Monats + - 'verbrauch_rolling_12m_jahr_minus1' #num yearly Verbrauch der letzten 12 Monate ausgehend im Ende des letzten Jahres + - 'verbrauch_jahreszeitraum_minus1' #num daily Verbrauch seit dem 1.1. bis zum heutigen Tag des Vorjahres + - 'verbrauch_jahreszeitraum_minus2' #num daily Verbrauch seit dem 1.1. bis zum heutigen Tag vor 2 Jahren + - 'verbrauch_jahreszeitraum_minus3' #num daily Verbrauch seit dem 1.1. bis zum heutigen Tag vor 3 Jahren + # Zaehlerstand + - 'zaehlerstand_heute_minus1' #num daily Zählerstand / Wert am Ende des letzten Tages (heute -1 Tag) + - 'zaehlerstand_woche_minus1' #num weekly Zählerstand / Wert am Ende der letzten Woche (aktuelle Woche -1 Woche) + - 'zaehlerstand_woche_minus2' #num weekly Zählerstand / Wert am Ende der vorletzten Woche (aktuelle Woche -2 Woche) + - 'zaehlerstand_woche_minus3' #num weekly Zählerstand / Wert am Ende der aktuellen Woche -3 Woche + - 'zaehlerstand_monat_minus1' #num monthly Zählerstand / Wert am Ende des letzten Monates (aktueller Monat -1 Monat) + - 'zaehlerstand_monat_minus2' #num monthly Zählerstand / Wert am Ende des vorletzten Monates (aktueller Monat -2 Monate) + - 'zaehlerstand_monat_minus3' #num monthly Zählerstand / Wert am Ende des aktuellen Monats -3 Monate + - 'zaehlerstand_jahr_minus1' #num yearly Zählerstand / Wert am Ende des letzten Jahres (aktuelles Jahr -1 Jahr) + - 'zaehlerstand_jahr_minus2' #num yearly Zählerstand / Wert am Ende des vorletzten Jahres (aktuelles Jahr -2 Jahre) + - 'zaehlerstand_jahr_minus3' #num yearly Zählerstand / Wert am Ende des aktuellen Jahres -3 Jahre + # Wertehistorie min/max + - 'minmax_last_24h_min' #num daily minimaler Wert der letzten 24h + - 'minmax_last_24h_max' #num daily maximaler Wert der letzten 24h + - 'minmax_last_24h_avg' #num daily durchschnittlicher Wert der letzten 24h + - 'minmax_last_7d_min' #num daily minimaler Wert der letzten 7 Tage + - 'minmax_last_7d_max' #num daily maximaler Wert der letzten 7 Tage + - 'minmax_last_7d_avg' #num daily durchschnittlicher Wert der letzten 7 Tage + - 'minmax_heute_min' #num onchange Minimalwert seit Tagesbeginn + - 'minmax_heute_max' #num onchange Maximalwert seit Tagesbeginn + - 'minmax_heute_minus1_min' #num daily Minimalwert gestern (heute -1 Tag) + - 'minmax_heute_minus1_max' #num daily Maximalwert gestern (heute -1 Tag) + - 'minmax_heute_minus1_avg' #num daily Durchschnittswert gestern (heute -1 Tag) + - 'minmax_heute_minus2_min' #num daily Minimalwert vorgestern (heute -2 Tage) + - 'minmax_heute_minus2_max' #num daily Maximalwert vorgestern (heute -2 Tage) + - 'minmax_heute_minus2_avg' #num daily Durchschnittswert vorgestern (heute -2 Tage) + - 'minmax_heute_minus3_min' #num daily Minimalwert heute vor 3 Tagen + - 'minmax_heute_minus3_max' #num daily Maximalwert heute vor 3 Tagen + - 'minmax_heute_minus3_avg' #num daily Durchschnittswert heute vor 3 Tagen + - 'minmax_woche_min' #num onchange Minimalwert seit Wochenbeginn + - 'minmax_woche_max' #num onchange Maximalwert seit Wochenbeginn + - 'minmax_woche_minus1_min' #num weekly Minimalwert Vorwoche (aktuelle Woche -1) + - 'minmax_woche_minus1_max' #num weekly Maximalwert Vorwoche (aktuelle Woche -1) + - 'minmax_woche_minus1_avg' #num weekly Durchschnittswert Vorwoche (aktuelle Woche -1) + - 'minmax_woche_minus2_min' #num weekly Minimalwert aktuelle Woche -2 Wochen + - 'minmax_woche_minus2_max' #num weekly Maximalwert aktuelle Woche -2 Wochen + - 'minmax_woche_minus2_avg' #num weekly Durchschnittswert aktuelle Woche -2 Wochen + - 'minmax_monat_min' #num onchange Minimalwert seit Monatsbeginn + - 'minmax_monat_max' #num onchange Maximalwert seit Monatsbeginn + - 'minmax_monat_minus1_min' #num monthly Minimalwert Vormonat (aktueller Monat -1) + - 'minmax_monat_minus1_max' #num monthly Maximalwert Vormonat (aktueller Monat -1) + - 'minmax_monat_minus1_avg' #num monthly Durchschnittswert Vormonat (aktueller Monat -1) + - 'minmax_monat_minus2_min' #num monthly Minimalwert aktueller Monat -2 Monate + - 'minmax_monat_minus2_max' #num monthly Maximalwert aktueller Monat -2 Monate + - 'minmax_monat_minus2_avg' #num monthly Durchschnittswert aktueller Monat -2 Monate + - 'minmax_jahr_min' #num onchange Minimalwert seit Jahresbeginn + - 'minmax_jahr_max' #num onchange Maximalwert seit Jahresbeginn + - 'minmax_jahr_minus1_min' #num yearly Minimalwert Vorjahr (aktuelles Jahr -1 Jahr) + - 'minmax_jahr_minus1_max' #num yearly Maximalwert Vorjahr (aktuelles Jahr -1 Jahr) + - 'minmax_jahr_minus1_avg' #num yearly Durchschnittswert Vorjahr (aktuelles Jahr -1 Jahr) + # Serie + - 'serie_minmax_monat_min_15m' #list monthly monatlicher Minimalwert der letzten 15 Monate (gleitend) + - 'serie_minmax_monat_max_15m' #list monthly monatlicher Maximalwert der letzten 15 Monate (gleitend) + - 'serie_minmax_monat_avg_15m' #list monthly monatlicher Mittelwert der letzten 15 Monate (gleitend) + - 'serie_minmax_woche_min_30w' #list weekly wöchentlicher Minimalwert der letzten 30 Wochen (gleitend) + - 'serie_minmax_woche_max_30w' #list weekly wöchentlicher Maximalwert der letzten 30 Wochen (gleitend) + - 'serie_minmax_woche_avg_30w' #list weekly wöchentlicher Mittelwert der letzten 30 Wochen (gleitend) + - 'serie_minmax_tag_min_30d' #list daily täglicher Minimalwert der letzten 30 Tage (gleitend) + - 'serie_minmax_tag_max_30d' #list daily täglicher Maximalwert der letzten 30 Tage (gleitend) + - 'serie_minmax_tag_avg_30d' #list daily täglicher Mittelwert der letzten 30 Tage (gleitend) + - 'serie_verbrauch_tag_30d' #list daily Verbrauch pro Tag der letzten 30 Tage + - 'serie_verbrauch_woche_30w' #list weekly Verbrauch pro Woche der letzten 30 Wochen + - 'serie_verbrauch_monat_18m' #list monthly Verbrauch pro Monat der letzten 18 Monate + - 'serie_zaehlerstand_tag_30d' #list daily Zählerstand am Tagesende der letzten 30 Tage + - 'serie_zaehlerstand_woche_30w' #list weekly Zählerstand am Wochenende der letzten 30 Wochen + - 'serie_zaehlerstand_monat_18m' #list monthly Zählerstand am Monatsende der letzten 18 Monate + - 'serie_waermesumme_monat_24m' #list monthly monatliche Wärmesumme der letzten 24 Monate + - 'serie_kaeltesumme_monat_24m' #list monthly monatliche Kältesumme der letzten 24 Monate + - 'serie_tagesmittelwert_stunde_0d' #list daily Stundenmittelwert für den aktuellen Tag + - 'serie_tagesmittelwert_tag_stunde_30d' #list daily Stundenmittelwert pro Tag der letzten 30 Tage (bspw. zur Berechnung der Tagesmitteltemperatur basierend auf den Mittelwert der Temperatur pro Stunde + # Allgemein + - 'general_oldest_value' #num ------ Ausgabe des ältesten Wertes des entsprechenden "Parent-Items" mit database Attribut + - 'general_oldest_log' #list ------ Ausgabe des Timestamp des ältesten Eintrages des entsprechenden "Parent-Items" mit database Attribut + # Komplex Hinweis: db_addon_params needed + - 'kaeltesumme' #num daily Berechnet die Kältesumme für einen Zeitraum, db_addon_params: (year=mandatory, month=optional) + - 'waermesumme' #num daily Berechnet die Wärmesumme für einen Zeitraum, db_addon_params: (year=mandatory, month=optional) + - 'gruenlandtempsumme' #num daily Berechnet die Grünlandtemperatursumme für einen Zeitraum, db_addon_params: (year=mandatory) + - 'tagesmitteltemperatur' #list daily Berechnet die Tagesmitteltemperatur auf basis der stündlichen Durchschnittswerte eines Tages für die angegebene Anzahl von Tagen (days=optional) + - 'db_request' #list 'group' Abfrage der DB: db_addon_params: (func=mandatory, item=mandatory, timespan=mandatory, start=optional, end=optional, count=optional, group=optional, group2=optional): + valid_list_description: # Beschreibung -> notwendiger Item-Type + # Verbrauch + - 'Verbrauch am heutigen Tag (Differenz zwischen aktuellem Wert und den Wert am Ende des vorherigen Tages) -> num \n + Berechnungszyklus = onchange' + - 'Verbrauch in der aktuellen Woche -> num \n + Berechnungszyklus = onchange' + - 'Verbrauch im aktuellen Monat -> num \n + Berechnungszyklus = onchange' + - 'Verbrauch im aktuellen Jahr -> num \n + Berechnungszyklus = onchange' + - 'Verbrauch gestern (heute -1 Tag) (Differenz zwischen Wert am Ende des gestrigen Tages und dem Wert am Ende des Tages danach) -> num \n + Berechnungszyklus = täglich' + - 'Verbrauch vorgestern (heute -2 Tage) -> num \n + Berechnungszyklus = täglich' + - 'Verbrauch heute -3 Tage -> num \n + Berechnungszyklus = täglich' + - 'Verbrauch heute -4 Tage -> num \n + Berechnungszyklus = täglich' + - 'Verbrauch heute -5 Tage -> num \n + Berechnungszyklus = täglich' + - 'Verbrauch heute -6 Tage -> num \n + Berechnungszyklus = täglich' + - 'Verbrauch heute -7 Tage -> num \n + Berechnungszyklus = täglich' + - 'Verbrauch Vorwoche (aktuelle Woche -1) -> num \n + Berechnungszyklus = wöchentlich' + - 'Verbrauch aktuelle Woche -2 Wochen) -> num \n + Berechnungszyklus = wöchentlich' + - 'Verbrauch aktuelle Woche -3 Wochen) -> num \n + Berechnungszyklus = wöchentlich' + - 'Verbrauch aktuelle Woche -4 Wochen) -> num \n + Berechnungszyklus = wöchentlich' + - 'Verbrauch Vormonat (aktueller Monat -1) -> num \n + Berechnungszyklus = monatlich' + - 'Verbrauch aktueller Monat -2 Monate -> num \n + Berechnungszyklus = monatlich' + - 'Verbrauch aktueller Monat -3 Monate -> num \n + Berechnungszyklus = monatlich' + - 'Verbrauch aktueller Monat -4 Monate -> num \n + Berechnungszyklus = monatlich' + - 'Verbrauch aktueller Monat -12 Monate -> num \n + Berechnungszyklus = monatlich' + - 'Verbrauch Vorjahr (aktuelles Jahr -1 Jahr) -> num \n + Berechnungszyklus = jährlich' + - 'Verbrauch aktuelles Jahr -2 Jahre) -> num \n + Berechnungszyklus = jährlich' + - 'Verbrauch der letzten 12 Monate ausgehend im Ende des letzten Tages -> num \n + Berechnungszyklus = täglich' + - 'Verbrauch der letzten 12 Monate ausgehend im Ende der letzten Woche -> num \n + Berechnungszyklus = wöchentlich' + - 'Verbrauch der letzten 12 Monate ausgehend im Ende des letzten Monats -> num \n + Berechnungszyklus = monatlich' + - 'Verbrauch der letzten 12 Monate ausgehend im Ende des letzten Jahres -> num \n + Berechnungszyklus = jährlich' + - 'Verbrauch seit dem 1.1. bis zum heutigen Tag des Vorjahres -> num \n + Berechnungszyklus = täglich' + - 'Verbrauch seit dem 1.1. bis zum heutigen Tag vor 2 Jahren -> num \n + Berechnungszyklus = täglich' + - 'Verbrauch seit dem 1.1. bis zum heutigen Tag vor 3 Jahren -> num \n + Berechnungszyklus = täglich' + # Zaehlerstand + - 'Zählerstand / Wert am Ende des letzten Tages (heute -1 Tag) -> num \n + Berechnungszyklus = täglich' + - 'Zählerstand / Wert am Ende der letzten Woche (aktuelle Woche -1 Woche) -> num \n + Berechnungszyklus = wöchentlich' + - 'Zählerstand / Wert am Ende der vorletzten Woche (aktuelle Woche -2 Woche) -> num \n + Berechnungszyklus = wöchentlich' + - 'Zählerstand / Wert am Ende der aktuellen Woche -3 Woche -> num \n + Berechnungszyklus = wöchentlich' + - 'Zählerstand / Wert am Ende des letzten Monates (aktueller Monat -1 Monat)) -> num \n + Berechnungszyklus = monatlich' + - 'Zählerstand / Wert am Ende des vorletzten Monates (aktueller Monat -2 Monate)) -> num \n + Berechnungszyklus = monatlich' + - 'Zählerstand / Wert am Ende des aktuellen Monats -3 Monate) -> num \n + Berechnungszyklus = monatlich' + - 'Zählerstand / Wert am Ende des letzten Jahres (aktuelles Jahr -1 Jahr) -> num \n + Berechnungszyklus = jährlich' + - 'Zählerstand / Wert am Ende des vorletzten Jahres (aktuelles Jahr -2 Jahre) -> num \n + Berechnungszyklus = jährlich' + - 'Zählerstand / Wert am Ende des aktuellen Jahres -3 Jahre -> num \n + Berechnungszyklus = jährlich' + # Wertehistorie min/max + - 'minimaler Wert der letzten 24h -> num \n + Berechnungszyklus = täglich' + - 'maximaler Wert der letzten 24h -> num \n + Berechnungszyklus = täglich' + - 'durchschnittlicher Wert der letzten 24h -> num \n + Berechnungszyklus = täglich' + - 'minimaler Wert der letzten 7 Tage -> num \n + Berechnungszyklus = täglich' + - 'maximaler Wert der letzten 7 Tage -> num \n + Berechnungszyklus = täglich' + - 'durchschnittlicher Wert der letzten 7 Tage -> num \n + Berechnungszyklus = täglich' + - 'Minimalwert seit Tagesbeginn' + - 'Maximalwert seit Tagesbeginn' + - 'Minimalwert gestern (heute -1 Tag) -> num \n + Berechnungszyklus = täglich' + - 'Maximalwert gestern (heute -1 Tag) -> num \n + Berechnungszyklus = täglich' + - 'Durchschnittswert gestern (heute -1 Tag) -> num \n + Berechnungszyklus = täglich' + - 'Minimalwert vorgestern (heute -2 Tage) -> num \n + Berechnungszyklus = täglich' + - 'Maximalwert vorgestern (heute -2 Tage) -> num \n + Berechnungszyklus = täglich' + - 'Durchschnittswert vorgestern (heute -2 Tage) -> num \n + Berechnungszyklus = täglich' + - 'Minimalwert heute vor 3 Tagen -> num \n + Berechnungszyklus = täglich' + - 'Maximalwert heute vor 3 Tagen -> num \n + Berechnungszyklus = täglich' + - 'Durchschnittswert heute vor 3 Tagen -> num \n + Berechnungszyklus = täglich' + - 'Minimalwert seit Wochenbeginn -> num \n + Berechnungszyklus = onchange' + - 'Maximalwert seit Wochenbeginn -> num \n + Berechnungszyklus = onchange' + - 'Minimalwert Vorwoche (aktuelle Woche -1) -> num \n + Berechnungszyklus = wöchentlich' + - 'Maximalwert Vorwoche (aktuelle Woche -1) -> num \n + Berechnungszyklus = wöchentlich' + - 'Durchschnittswert Vorwoche (aktuelle Woche -1) -> num \n + Berechnungszyklus = wöchentlich' + - 'Minimalwert aktuelle Woche -2 Wochen -> num \n + Berechnungszyklus = wöchentlich' + - 'Maximalwert aktuelle Woche -2 Wochen -> num \n + Berechnungszyklus = wöchentlich' + - 'Durchschnittswert aktuelle Woche -2 Wochen -> num \n + Berechnungszyklus = wöchentlich' + - 'Minimalwert seit Monatsbeginn -> num \n + Berechnungszyklus = onchange' + - 'Maximalwert seit Monatsbeginn -> num \n + Berechnungszyklus = onchange' + - 'Minimalwert Vormonat (aktueller Monat -1) -> num \n + Berechnungszyklus = monatlich' + - 'Maximalwert Vormonat (aktueller Monat -1) -> num \n + Berechnungszyklus = monatlich' + - 'Durchschnittswert Vormonat (aktueller Monat -1) -> num \n + Berechnungszyklus = monatlich' + - 'Minimalwert aktueller Monat -2 Monate -> num \n + Berechnungszyklus = monatlich' + - 'Maximalwert aktueller Monat -2 Monate -> num \n + Berechnungszyklus = monatlich' + - 'Durchschnittswert aktueller Monat -2 Monate -> num \n + Berechnungszyklus = monatlich' + - 'Minimalwert seit Jahresbeginn -> num \n + Berechnungszyklus = onchange' + - 'Maximalwert seit Jahresbeginn -> num \n + Berechnungszyklus = onchange' + - 'Minimalwert Vorjahr (aktuelles Jahr -1 Jahr) -> num \n + Berechnungszyklus = jährlich' + - 'Maximalwert Vorjahr (aktuelles Jahr -1 Jahr) -> num \n + Berechnungszyklus = jährlich' + - 'Durchschnittswert Vorjahr (aktuelles Jahr -1 Jahr) -> num \n + Berechnungszyklus = jährlich' + # Serie + - 'monatlicher Minimalwert der letzten 15 Monate (gleitend) -> list \n + Berechnungszyklus = monatlich' + - 'monatlicher Maximalwert der letzten 15 Monate (gleitend) -> list \n + Berechnungszyklus = monatlich' + - 'monatlicher Mittelwert der letzten 15 Monate (gleitend) -> list \n + Berechnungszyklus = monatlich' + - 'wöchentlicher Minimalwert der letzten 30 Wochen (gleitend) -> list \n + Berechnungszyklus = wöchentlich' + - 'wöchentlicher Maximalwert der letzten 30 Wochen (gleitend) -> list \n + Berechnungszyklus = wöchentlich' + - 'wöchentlicher Mittelwert der letzten 30 Wochen (gleitend) -> list \n + Berechnungszyklus = wöchentlich' + - 'täglicher Minimalwert der letzten 30 Tage (gleitend) -> list \n + Berechnungszyklus = täglich' + - 'täglicher Maximalwert der letzten 30 Tage (gleitend)) -> list \n + Berechnungszyklus = täglich' + - 'täglicher Mittelwert der letzten 30 Tage (gleitend)) -> list \n + Berechnungszyklus = täglich' + - 'Verbrauch pro Tag der letzten 30 Tage) -> list \n + Berechnungszyklus = täglich' + - 'Verbrauch pro Woche der letzten 30 Wochen) -> list \n + Berechnungszyklus = wöchentlich' + - 'Verbrauch pro Monat der letzten 18 Monate) -> list \n + Berechnungszyklus = monatlich' + - 'Zählerstand am Tagesende der letzten 30 Tage) -> list \n + Berechnungszyklus = täglich' + - 'Zählerstand am Wochenende der letzten 30 Wochen) -> list \n + Berechnungszyklus = wöchentlich' + - 'Zählerstand am Monatsende der letzten 18 Monate) -> list \n + Berechnungszyklus = monatlich' + - 'monatliche Wärmesumme der letzten 24 Monate) -> list \n + Berechnungszyklus = monatlich' + - 'monatliche Kältesumme der letzten 24 Monate) -> list \n + Berechnungszyklus = monatlich' + - 'Stundenmittelwert für den aktuellen Tag) -> list \n + Berechnungszyklus = täglich' + - 'Stundenmittelwert pro Tag der letzten 30 Tage (bspw. zur Berechnung der Tagesmitteltemperatur basierend auf den Mittelwert der Temperatur pro Stunde) -> list \n + Berechnungszyklus = täglich' + # Allgemein + - 'Ausgabe des ältesten Wertes des entsprechenden "Parent-Items" mit database Attribut -> num' + - 'Ausgabe des Timestamp des ältesten Eintrages des entsprechenden "Parent-Items" mit database Attribut -> list' + # Komplex + - 'Berechnet die Kältesumme für einen Zeitraum, db_addon_params sind für die Definition des Zeitraums notwendig (year=optional, month=optional) -> num \n + Berechnungszyklus = täglich' + - 'Berechnet die Wärmesumme für einen Zeitraum, db_addon_params sind für die Definition des Zeitraums notwendig (year=optional, month=optional) -> num \n + Berechnungszyklus = täglich' + - 'Berechnet die Grünlandtemperatursumme für einen Zeitraum, db_addon_params sind für die Definition des Zeitraums notwendig (year=optional) siehe https://de.wikipedia.org/wiki/Gr%C3%BCnlandtemperatursumme -> num \n + Berechnungszyklus = täglich' + - 'Berechnet die Tagesmitteltemperatur auf basis der stündlichen Durchschnittswerte eines Tages für die angegebene Anzahl von Tagen (days=optional) -> num \n + Berechnungszyklus = täglich' + - 'Abfrage der DB mit db_addon_params (func=mandatory, item=mandatory, timespan=mandatory, start=optional, end=optional, count=optional, group=optional, group2=optional) -> foo \n + Berechnungszyklus = group' + + valid_list_item_type: + # Verbrauch + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + # Zaehlerstand + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + # Wertehistorie min/max + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + - num + # Serie + - list + - list + - list + - list + - list + - list + - list + - list + - list + - list + - list + - list + - list + - list + - list + - list + - list + - list + - list + # Allgemein + - num + - list + # Komplex + - num + - num + - num + - list + - list + + db_addon_info: + type: str + description: + de: 'Info-Funktion des DB-Addon Plugins' + en: 'Info-Function of DB-Addon Plugins' + valid_list: + - 'db_version' #str ------ Version der verbundenen Datenbank + valid_list_description: + - 'Version der verbundenen Datenbank -> str' + valid_list_item_type: + - str + + db_addon_admin: + type: str + description: + de: 'Admin-Funktion des DB-Addon Plugins' + en: 'Admin-Function of DB-Addon Plugins' + valid_list: + - 'suspend' #bool ------ unterbricht die Aktivitäten des Plugin + - 'recalc_all' #bool ------ Startet einen Neuberechnungslauf aller on-demand items + - 'clean_cache_values' #bool ------ Löscht Plugin-Cache und damit alle im Plugin zwischengespeicherten Werte + valid_list_description: + - 'unterbricht die Aktivitäten des Plugin -> bool' + - 'Startet einen Neuberechnungslauf aller on-demand items -> bool' + - 'Löscht Plugin-Cache und damit alle im Plugin zwischengespeicherten Werte -> bool' + valid_list_item_type: + - bool + - bool + - bool + + db_addon_params: + type: str + description: + de: "Parameter für eine Auswertefunktion des DB-Addon Plugins im Format 'kwargs' enclosed in quotes like 'keyword=argument, keyword=argument'" + en: "Parameters of a DB-Addon Plugin evaluation function. Need to have format of 'kwargs' enclosed in quotes like 'keyword=argument, keyword=argument'" + + db_addon_startup: + type: bool + description: + de: 'Ausführen der Berechnung bei Plugin Start (mit zeitlichem Abstand, wie in den Plugin Parametern definiert)' + en: 'Run function in startup of plugin (with delay, set in plugin parameters)' + + db_addon_ignore_value: + type: num + description: + de: 'Wert der bei Abfrage bzw. Auswertung der Datenbank für diese Item ignoriert werden soll' + en: 'Value which will be ignored at database query' + +item_structs: + verbrauch_1: + name: Struct für Verbrauchsauswertung bei Zählern mit stetig ansteigendem Zählerstand (Teil 1) + verbrauch_heute: + name: Verbrauch heute + db_addon_fct: verbrauch_heute + type: num + visu_acl: ro + # cache: yes + + verbrauch_woche: + name: Verbrauch seit Wochenbeginn + db_addon_fct: verbrauch_woche + type: num + visu_acl: ro + # cache: yes + + verbrauch_monat: + name: Verbrauch seit Monatsbeginn + db_addon_fct: verbrauch_monat + type: num + visu_acl: ro + # cache: yes + + verbrauch_jahr: + name: Verbrauch seit Jahresbeginn + db_addon_fct: verbrauch_jahr + type: num + visu_acl: ro + # cache: yes + + verbrauch_rolling_12m: + name: Verbrauch innerhalb der letzten 12 Monate ausgehend von gestern + db_addon_fct: verbrauch_rolling_12m_heute_minus1 + type: num + visu_acl: ro + # cache: yes + + verbrauch_gestern: + name: Verbrauch gestern + db_addon_fct: verbrauch_heute_minus1 + db_addon_startup: yes + type: num + visu_acl: ro + # cache: yes + + verbrauch_gestern_minus1: + name: Verbrauch vorgestern + db_addon_fct: verbrauch_heute_minus2 + db_addon_startup: yes + type: num + visu_acl: ro + # cache: yes + + verbrauch_gestern_minus2: + name: Verbrauch vor 3 Tagen + db_addon_fct: verbrauch_heute_minus3 + db_addon_startup: yes + type: num + visu_acl: ro + # cache: yes + + verbrauch_vorwoche: + name: Verbrauch in der Vorwoche + db_addon_fct: verbrauch_woche_minus1 + db_addon_startup: yes + type: num + visu_acl: ro + # cache: yes + + verbrauch_vorwoche_minus1: + name: Verbrauch vor 2 Wochen + db_addon_fct: verbrauch_woche_minus2 + db_addon_startup: yes + type: num + visu_acl: ro + # cache: yes + + verbrauch_vormonat: + name: Verbrauch im Vormonat + db_addon_fct: verbrauch_monat_minus1 + db_addon_startup: yes + type: num + visu_acl: ro + # cache: yes + + verbrauch_vormonat_minus12: + name: Verbrauch vor 12 Monaten + db_addon_fct: verbrauch_monat_minus12 + db_addon_startup: yes + type: num + visu_acl: ro + # cache: yes + + verbrauch_vorjahreszeitraum: + name: Verbrauch im Jahreszeitraum 1.1. bis jetzt vor einem Jahr + db_addon_fct: verbrauch_jahreszeitraum_minus1 + db_addon_startup: yes + type: num + visu_acl: ro + # cache: yes + + verbrauch_2: + name: Struct für Verbrauchsauswertung bei Zählern mit stetig ansteigendem Zählerstand (Teil 2) + verbrauch_gestern_minus3: + name: Verbrauch vor 3 Tagen + db_addon_fct: verbrauch_heute_minus3 + type: num + visu_acl: ro + # cache: yes + + verbrauch_gestern_minus4: + name: Verbrauch vor 4 Tagen + db_addon_fct: verbrauch_heute_minus4 + type: num + visu_acl: ro + # cache: yes + + verbrauch_gestern_minus5: + name: Verbrauch vor 5 Tagen + db_addon_fct: verbrauch_heute_minus5 + type: num + visu_acl: ro + # cache: yes + + verbrauch_gestern_minus6: + name: Verbrauch vor 6 Tagen + db_addon_fct: verbrauch_heute_minus6 + type: num + visu_acl: ro + # cache: yes + + verbrauch_gestern_minus7: + name: Verbrauch vor 7 Tagen + db_addon_fct: verbrauch_heute_minus7 + type: num + visu_acl: ro + # cache: yes + + verbrauch_vorwoche_minus2: + name: Verbrauch vor 3 Wochen + db_addon_fct: verbrauch_woche_minus3 + type: num + visu_acl: ro + # cache: yes + + verbrauch_vorwoche_minus3: + name: Verbrauch vor 4 Wochen + db_addon_fct: verbrauch_woche_minus4 + type: num + visu_acl: ro + # cache: yes + + verbrauch_vormonat_minus1: + name: Verbrauch vor 2 Monaten + db_addon_fct: verbrauch_monat_minus2 + type: num + visu_acl: ro + # cache: yes + + verbrauch_vormonat_minus2: + name: Verbrauch vor 3 Monaten + db_addon_fct: verbrauch_monat_minus3 + type: num + visu_acl: ro + # cache: yes + + verbrauch_vormonat_minus3: + name: Verbrauch vor 4 Monaten + db_addon_fct: verbrauch_monat_minus4 + type: num + visu_acl: ro + # cache: yes + + zaehlerstand_1: + name: Struct für die Erfassung von Zählerständen zu bestimmten Zeitpunkten bei Zählern mit stetig ansteigendem Zählerstand + zaehlerstand_gestern: + name: Zählerstand zum Ende des gestrigen Tages + db_addon_fct: zaehlerstand_heute_minus1 + type: num + visu_acl: ro + # cache: yes + + zaehlerstand_vorwoche: + name: Zählerstand zum Ende der vorigen Woche + db_addon_fct: zaehlerstand_woche_minus1 + db_addon_startup: yes + type: num + visu_acl: ro + # cache: yes + + zaehlerstand_vormonat: + name: Zählerstand zum Ende des Vormonates + db_addon_fct: zaehlerstand_monat_minus1 + db_addon_startup: yes + type: num + visu_acl: ro + # cache: yes + + zaehlerstand_vormonat_minus1: + name: Zählerstand zum Monatsende vor 2 Monaten + db_addon_fct: zaehlerstand_monat_minus2 + db_addon_startup: yes + type: num + visu_acl: ro + # cache: yes + + zaehlerstand_vormonat_minus2: + name: Zählerstand zum Monatsende vor 3 Monaten + db_addon_fct: zaehlerstand_monat_minus3 + db_addon_startup: yes + type: num + visu_acl: ro + # cache: yes + + zaehlerstand_vorjahr: + name: Zählerstand am Ende des vorigen Jahres + db_addon_fct: zaehlerstand_jahr_minus1 + db_addon_startup: yes + type: num + visu_acl: ro + # cache: yes + + minmax_1: + name: Struct für Auswertung der Wertehistorie bei schwankenden Werten wie bspw. Temperatur oder Leistung (Teil 1) + + heute_min: + name: Minimaler Wert seit Tagesbeginn + db_addon_fct: minmax_heute_min + db_addon_ignore_value: 0 + type: num + # cache: yes + + heute_max: + name: Maximaler Wert seit Tagesbeginn + db_addon_fct: minmax_heute_max + type: num + # cache: yes + + last24h_min: + name: Minimaler Wert in den letzten 24h (gleitend) + db_addon_fct: minmax_last_24h_min + type: num + # cache: yes + + last24h_max: + name: Maximaler Wert in den letzten 24h (gleitend) + db_addon_fct: minmax_last_24h_max + type: num + # cache: yes + + woche_min: + name: Minimaler Wert seit Wochenbeginn + db_addon_fct: minmax_woche_min + type: num + # cache: yes + + woche_max: + name: Maximaler Wert seit Wochenbeginn + db_addon_fct: minmax_woche_max + type: num + # cache: yes + + monat_min: + name: Minimaler Wert seit Monatsbeginn + db_addon_fct: minmax_monat_min + type: num + # cache: yes + + monat_max: + name: Maximaler Wert seit Monatsbeginn + db_addon_fct: minmax_monat_max + type: num + # cache: yes + + jahr_min: + name: Minimaler Wert seit Jahresbeginn + db_addon_fct: minmax_jahr_min + type: num + # cache: yes + + jahr_max: + name: Maximaler Wert seit Jahresbeginn + db_addon_fct: minmax_jahr_max + type: num + # cache: yes + + gestern_min: + name: Minimaler Wert gestern + db_addon_fct: minmax_heute_minus1_min + db_addon_startup: yes + type: num + # cache: yes + + gestern_max: + name: Maximaler Wert gestern + db_addon_fct: minmax_heute_minus1_max + db_addon_startup: yes + type: num + # cache: yes + + gestern_avg: + name: Durchschnittlicher Wert gestern + db_addon_fct: minmax_heute_minus1_avg + db_addon_startup: yes + type: num + # cache: yes + + vorwoche_min: + name: Minimaler Wert in der Vorwoche + db_addon_fct: minmax_woche_minus1_min + db_addon_startup: yes + type: num + # cache: yes + + vorwoche_max: + name: Maximaler Wert in der Vorwoche + db_addon_fct: minmax_woche_minus1_max + db_addon_startup: yes + type: num + # cache: yes + + vorwoche_avg: + name: Durchschnittlicher Wert in der Vorwoche + db_addon_fct: minmax_woche_minus1_avg + db_addon_startup: yes + type: num + # cache: yes + + vormonat_min: + name: Minimaler Wert im Vormonat + db_addon_fct: minmax_monat_minus1_min + db_addon_startup: yes + type: num + # cache: yes + + vormonat_max: + name: Maximaler Wert im Vormonat + db_addon_fct: minmax_monat_minus1_max + db_addon_startup: yes + type: num + # cache: yes + + vormonat_avg: + name: Durchschnittlicher Wert im Vormonat + db_addon_fct: minmax_monat_minus1_avg + db_addon_startup: yes + type: num + # cache: yes + + vorjahr_min: + name: Minimaler Wert im Vorjahr + db_addon_fct: minmax_jahr_minus1_min + db_addon_startup: yes + type: num + # cache: yes + + vorjahr_max: + name: Maximaler Wert im Vorjahr + db_addon_fct: minmax_jahr_minus1_max + db_addon_startup: yes + type: num + # cache: yes + + minmax_2: + name: Struct für Auswertung der Wertehistorie bei schwankenden Werten wie bspw. Temperatur oder Leistung (Teil 2) + + gestern_minus1_min: + name: Minimaler Wert vorgestern + db_addon_fct: minmax_heute_minus2_min + type: num + # cache: yes + + gestern_minus1_max: + name: Maximaler Wert vorgestern + db_addon_fct: minmax_heute_minus2_max + type: num + # cache: yes + + gestern_minus1_avg: + name: Durchschnittlicher Wert vorgestern + db_addon_fct: minmax_heute_minus2_avg + type: num + # cache: yes + + gestern_minus2_min: + name: Minimaler Wert vor 3 Tagen + db_addon_fct: minmax_heute_minus3_min + type: num + # cache: yes + + gestern_minus2_max: + name: Maximaler Wert vor 3 Tagen + db_addon_fct: minmax_heute_minus3_max + type: num + # cache: yes + + gestern_minus2_avg: + name: Durchschnittlicher Wert vor 3 Tagen + db_addon_fct: minmax_heute_minus3_avg + type: num + # cache: yes + + vorwoche_minus1_min: + name: Minimaler Wert in der Woche vor 2 Wochen + db_addon_fct: minmax_woche_minus2_min + type: num + # cache: yes + + vorwoche_minus1_max: + name: Maximaler Wert in der Woche vor 2 Wochen + db_addon_fct: minmax_woche_minus2_max + type: num + # cache: yes + + vorwoche_minus1_avg: + name: Durchschnittlicher Wert in der Woche vor 2 Wochen + db_addon_fct: minmax_woche_minus2_avg + type: num + # cache: yes + + vormonat_minus1_min: + name: Minimaler Wert im Monat vor 2 Monaten + db_addon_fct: minmax_monat_minus2_min + type: num + # cache: yes + + vormonat_minus1_max: + name: Maximaler Wert im Monat vor 2 Monaten + db_addon_fct: minmax_monat_minus2_max + type: num + # cache: yes + + vormonat_minus1_avg: + name: Durchschnittlicher Wert im Monat vor 2 Monaten + db_addon_fct: minmax_monat_minus2_avg + type: num + # cache: yes + +item_attribute_prefixes: NONE + +plugin_functions: + fetch_log: + type: list + description: + de: 'Liefert für das angegebene Item und die Parameter das Abfrageergebnis zurück' + en: 'Return the database request result for the given item and parameters' + # mit dieser Funktion ist es möglich, eine Liste der "func" Werte pro "group" / "group2" eines "item" von "start""timespan" bis "end""timespan" oder von "start""timespan" bis "count" ausgegeben zu lassen + # bspw. minimale Tagestemperatur vom Item "outdoor.temp" der letzten 10 Tage startend von gestern davor --> func=min, item=outdoor.temp, timespan=day, start=1, count=10, group=day + # bspw. maximal Tagestemperatur vom Item "outdoor.temp" von jetzt bis 2 Monate davor --> func=max, item=outdoor.temp, timeframe=month, start=0, end=2, group=day + parameters: + func: + type: str + description: + de: "zu verwendende Abfragefunktion" + en: "database function to be used" + mandatory: True + valid_list: + - min # Minimalwerte + - max # Maximalwerte + - sum # Summe + - on + - integrate + - sum_max + - sum_avg + - sum_min_neg + - diff_max + item: + type: foo + description: + de: "Das Item-Objekt oder die Item_ID der DB" + en: "An item object" + mandatory: True + timeframe: + type: str + description: + de: "Zeitinkrement für die DB-Abfrage" + en: "time increment for db-request" + mandatory: True + valid_list: + - day + - week + - month + - year + start: + type: int + description: + de: "Zeitlicher Beginn der DB-Abfrage: x Zeitinkrementen von jetzt in die Vergangenheit" + en: "start point in time for db-request; x time increments from now into the past" + end: + type: int + description: + de: "Zeitliches Ende der DB-Abfrage: x Zeitinkrementen von jetzt in die Vergangenheit" + en: "end point in time for db-request; x time increments from now into the past" + count: + type: int + description: + de: "Anzahl der Zeitinkremente, vom Start in die Vergangenheit abzufragen sind. Alternative zu 'end'" + en: "number of time increments from start point in time into the past. can be used alternativly to 'end'" + group: + type: str + description: + de: "erste Gruppierung der DB-Abfrage" + en: "first grouping for the db-request" + valid_list: + - day + - week + - month + - year + group2: + type: str + description: + de: "zweite Gruppierung der DB-Abfrage" + en: "second grouping for the db-request" + valid_list: + - day + - week + - month + - year + + db_version: + type: str + description: + de: 'Liefer die verwendete Version der Datenbank' + en: 'Return the database version' + + suspend: + type: bool + description: + de: 'Pausiert die Berechnungen des Plugins' + en: 'Suspends value evaluation of plugin' + +logic_parameters: NONE diff --git a/db_addon/requirements.txt b/db_addon/requirements.txt new file mode 100644 index 000000000..15d323b8c --- /dev/null +++ b/db_addon/requirements.txt @@ -0,0 +1,3 @@ +python-dateutil +sqlvalidator +pymysql \ No newline at end of file diff --git a/db_addon/user_doc.rst b/db_addon/user_doc.rst new file mode 100644 index 000000000..65ec9bfc4 --- /dev/null +++ b/db_addon/user_doc.rst @@ -0,0 +1,184 @@ + +.. index:: Plugins; db_addon (Datenbank Unterstützung) +.. index:: db_addon + +======== +db_addon +======== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + + +Das Plugin bietet eine Funktionserweiterung zum Database Plugin und ermöglicht die einfache Auswertung von Messdaten. +Basierend auf den Daten in der Datenbank können bspw. Auswertungen zu Verbrauch (heute, gestern, ...) oder zu Minimal- +und Maximalwerten gefahren werden. +Diese Auswertungen werden zyklisch zum Tageswechsel, Wochenwechsel, Monatswechsel oder Jahreswechsel, in Abhängigkeit +der Funktion erzeugt. +Um die Zugriffe auf die Datenbank zu minimieren, werden diverse Daten zwischengespeichert. + +Die Items mit einem DatabaseAddon-Attribut müssen im gleichen Pfad sein, wie das Item, für das das Database Attribut +konfiguriert ist. +Bedeutet: Die Items mit dem DatabaseAddon-Attribute müssen Kinder oder Kindeskinder oder Kindeskinderkinder des Items +sein, für das das Database Attribut konfiguriert ist + +Bsp: + + +.. code-block:: yaml + + temperatur: + type: bool + database: yes + + auswertung: + type: foo + + heute_min: + type: num + db_addon_fct: heute_min + + gestern_max: + type: num + db_addon_fct: heute_minus1_max + +| + +Anforderungen +============= + +Es muss das Database Plugin konfiguriert und aktiv sein. In den Plugin Parametern ist der Name der Konfiguration des +Database-Plugins anzugeben. Damit ist auch eine etwaig definierte Instanz des Database-Plugins definiert. +Die Konfiguration des DatabaseAddon-Plugin erfolgt automatisch bei Start. + + +Hinweis: Das Plugin selbst ist aktuell nicht multi-instance fähig. Das bedeutet, dass das Plugin aktuell nur eine Instanz +des Database-Plugin abgebunden werden kann. + +| + +Konfiguration +============= + +Diese Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/db_addon` beschrieben. + +mysql Datenbank +--------------- + +Bei Verwendung von mysql sollten einige Variablen der Datenbank angepasst werden, so dass die komplexeren Anfragen +ohne Fehler bearbeitet werden. + +Dazu folgenden Block am Ende der Datei */etc/mysql/my.cnf* einfügen bzw den existierenden ergänzen. + + +.. code-block:: bash + + [mysqld] + connect_timeout = 60 + net_read_timeout = 60 + wait_timeout = 28800 + interactive_timeout = 28800 + +| + +Hinweise +======== + + - Das Plugin startet die Berechnungen der Werte nach einer gewissen (konfigurierbaren) Zeit (Attribut `startup_run_delay`) nach dem Start von shNG, um den Startvorgang nicht zu beeinflussen. + - Bei Start werden automatisch nur die Items berechnet, für das das Attribute `db_addon_startup` gesetzt wurde. Alle anderen Items werden erst zu konfigurierten Zeit berechnet. Über das WebIF kann die Berechnung aller definierten Items ausgelöst werden. + - Für sogenannte `on_change` Items, also Items, deren Berechnung bis zum Jetzt (bspw. verbrauch-heute) gehen, wird die Berechnung immer bei eintreffen eines neuen Wertes gestartet. Zu Reduktion der Belastung auf die Datenbank werden die Werte für das Ende der letzten Periode gecached. + - Berechnungen werden nur ausgeführt, wenn für den kompletten abgefragten Zeitraum Werte in der Datenbank vorliegen. Wird bspw. der Verbrauch des letzten Monats abgefragt wobei erst Werte ab dem 3. des Monats in der Datenbank sind, wird die Berechnung abgebrochen. + Mit dem Attribut `use_oldest_entry` kann dieses Verhalten verändert werden. Ist das Attribut gesetzt, wird, wenn für den Beginn der Abfragezeitraums keinen Werte vorliegen, der älteste Eintrag der Datenbank genutzt. + - Für die Auswertung kann es nützlich sein, bestimmte Werte aus der Datenbank bei der Berechnung auszublenden. Hierfür stehen 2 Möglichkeiten zur Verfügung: + - Plugin-Attribut `ignore_0`: (list of strings) Bei Items, bei denen ein String aus der Liste im Pfadnamen vorkommt, werden 0-Werte (val_num = 0) bei Datenbankauswertungen ignoriert. Hat also das Attribut den Wert ['temp'] werden bei allen Items mit 'temp' im Pfadnamen die 0-Werte bei der Auswertung ignoriert. + - Item-Attribut `db_addon_ignore_value`: (num) Dieser Wert wird bei der Abfrage bzw. Auswertung der Datenbank für diese Item ignoriert. + - Das Plugin enthält sehr ausführliche Logginginformation. Bei unerwartetem Verhalten, den LogLevel entsprechend anpassen, um mehr information zu erhalten. + - Berechnungen des Plugins können im WebIF unterbrochen werden. Auch das gesamte Plugin kann pausiert werden. Dies kann be starker Systembelastung nützlich sein. + +| + +Beispiele +========= + +Verbrauch +--------- + +Soll bspw. der Verbrauch von Wasser ausgewertet werden, so ist dies wie folgt möglich: + +.. code-block:: yaml + + wasserzaehler: + zaehlerstand: + type: num + knx_dpt: 12 + knx_cache: 5/3/4 + eval: round(value/1000, 1) + database: init + struct: + - db_addon.verbrauch_1 + - db_addon.verbrauch_2 + - db_addon.zaehlerstand_1 + +Die Werte des Wasserzählerstandes werden in die Datenbank geschrieben und darauf basierend ausgewertet. Die structs +'db_addon.verbrauch_1' und 'db_addon.verbrauch_2' stellen entsprechende Items für die Verbrauchsauswerten zur Verfügung. + +minmax +------ + +Soll bspw. die minimalen und maximalen Temperaturen ausgewertet werden, kann dies so umgesetzt werden: + +.. code-block:: yaml + + temperature: + aussen: + nord: + name: Außentemp Nordseite + type: num + visu_acl: ro + knx_dpt: 9 + knx_cache: 6/5/1 + database: init + struct: + - db_addon.minmax_1 + - db_addon.minmax_2 + +Die Temperaturwerte werden in die Datenbank geschrieben und darauf basierend ausgewertet. Die structs +'db_addon.minmax_1' und 'db_addon.minmax_2' stellen entsprechende Items für die min/max Auswertung zur Verfügung. + +| + +Web Interface +============= + +Das WebIF stellt neben der Ansicht verbundener Items und deren Parameter und Werte auch Funktionen für die +Administration des Plugins bereit. + +Es stehen Button für: + +- Neuberechnung aller Items +- Abbruch eines aktiven Berechnungslaufes +- Pausieren des Plugins +- Wiederaufnahme des Plugins + +bereit. + +Achtung: Das Auslösen einer kompletten Neuberechnung aller Items kann zu einer starken Belastung der Datenbank +aufgrund vieler Leseanfragen führen. + + +db_addon Items +-------------- + +Dieser Reiter des Webinterface zeigt die Items an, für die ein DatabaseAddon Attribut konfiguriert ist. + + +db_addon Maintenance +-------------------- + +Das Webinterface zeigt detaillierte Informationen über die im Plugin verfügbaren Daten an. +Dies dient der Maintenance bzw. Fehlersuche. Dieser Tab ist nur bei Log-Level "Debug" verfügbar. diff --git a/db_addon/webif/__init__.py b/db_addon/webif/__init__.py new file mode 100644 index 000000000..0397f8ab9 --- /dev/null +++ b/db_addon/webif/__init__.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2022- Michael Wenzel wenzel_michael@web.de +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# This plugin provides additional functionality to mysql database +# connected via database plugin +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after being rendered + """ + + tmpl = self.tplenv.get_template('index.html') + + return tmpl.render(p=self.plugin, + webif_pagelength=self.plugin.get_parameter_value('webif_pagelength'), + suspended='true' if self.plugin.suspended else 'false', + items=self.plugin.get_item_list('db_addon', 'function'), + item_count=len(self.plugin.get_item_list('db_addon', 'function')), + plugin_shortname=self.plugin.get_shortname(), + plugin_version=self.plugin.get_version(), + plugin_info=self.plugin.get_info(), + maintenance=True if self.plugin.log_level == 10 else False, + ) + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + if dataSet is None: + # get the new data + data = dict() + data['items'] = {} + + for item in self.plugin.get_item_list('db_addon', 'function'): + data['items'][item.id()] = {} + data['items'][item.id()]['value'] = item.property.value + data['items'][item.id()]['last_update'] = item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') + data['items'][item.id()]['last_change'] = item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') + + data['plugin_suspended'] = self.plugin.suspended + data['maintenance'] = True if self.plugin.log_level == 10 else False + data['queue_length'] = self.plugin.queue_backlog + data['active_queue_item'] = self.plugin.active_queue_item + + try: + return json.dumps(data, default=str) + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") + + @cherrypy.expose + def recalc_all(self): + self.logger.debug(f"recalc_all called") + self.plugin.execute_all_items() + + @cherrypy.expose + def clean_cache_dicts(self): + self.logger.debug(f"_clean_cache_dicts called") + self.plugin._clean_cache_dicts() + + @cherrypy.expose + def clear_queue(self): + self.logger.debug(f"_clear_queue called") + self.plugin._clear_queue() + + @cherrypy.expose + def activate(self): + self.logger.debug(f"active called") + self.plugin.suspend(False) + + @cherrypy.expose + def suspend(self): + self.logger.debug(f"suspend called") + self.plugin.suspend(True) diff --git a/db_addon/webif/static/img/plugin_logo.png b/db_addon/webif/static/img/plugin_logo.png new file mode 100644 index 000000000..8498f745e Binary files /dev/null and b/db_addon/webif/static/img/plugin_logo.png differ diff --git a/db_addon/webif/templates/index.html b/db_addon/webif/templates/index.html new file mode 100644 index 000000000..37688339b --- /dev/null +++ b/db_addon/webif/templates/index.html @@ -0,0 +1,495 @@ +{% extends "base_plugin.html" %} + +{% set logo_frame = false %} +{% set row_count = true %} +{% set initial_update = true %} + +{% set update_interval = [(((10 * item_count) / 1000) | round | int) * 1000, 5000]|max %} + + +{% set language = p.get_sh().get_defaultlanguage() %} +{% if language not in ['en','de'] %} + {% set language = 'de' %} +{% endif %} + + +{% block pluginstyles %} + +{% endblock pluginstyles %} + + +{% block pluginscripts %} + + + + + +{% endblock pluginscripts %} + + +{% block headtable %} + + + + + + + + + + + + {% set first = True %} + {% for key, value in p._db._params.items() %} + {% if loop.index % 4 == 0 %} + + {% endif %} + {% if key != "passwd" %} + + {% else %} + + {% endif %} + {% if loop.index % 3 > 0 and loop.last %} + + {% endif %} + {% if loop.index % 4 == 0 and not first %} + + {% endif %} + {% endfor %} + + + + + + + +
{{ _('Verbunden') }}{% if p._db._connected %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %}{{ _('Treiber') }}{{ p.db_driver }}{{ _('Startup Delay') }}{{ (p.startup_run_delay) }}s
{{ key }}{{ value }}{{ key }}{% for letter in value %}*{% endfor %}
{{ _('Item in Berechnung') }}{{ p.active_queue_item }}{{ _('Arbeitsvorrat') }}{{ p.queue_backlog }} {{ _('Items') }}
+{% endblock headtable %} + + + +{% block buttons %} +
+ + + +
+ + +
+
+{% endblock %} + + +{% set tabcount = 3 %} + +{% set tab1title = "" ~ plugin_shortname ~ " Items (" ~ item_count ~ ")" %} +{% if maintenance %} + {% set tab2title = "" ~ plugin_shortname ~ " Maintenance" %} +{% else %} + {% set tab2title = "hidden" %} +{% endif %} +{% set tab3title = "" ~ plugin_shortname ~ " API/Doku" %} + + + +{% if item_count > 0 %} + {% set start_tab = 1 %} +{% else %} + {% set start_tab = 3 %} +{% endif %} + + + +{% block bodytab1 %} + +
+ + + {% for item in items %} + + + + + + + + + + + + {% endfor %} + +
{{ item._path }}{{ item._type }}{{ p.get_item_config(item._path)['attribute'] }}{{ _(p.get_item_config(item)['cycle']|string) }}{% if p.get_item_config(item)['startup'] %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %}.{{ item._value | float }}{{ item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') }}{{ item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') }}
+
+{% endblock bodytab1 %} + + + +{% block bodytab2 %} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ _('00_items') }}{{ len(p.get_item_path_list('database_addon', True)) }}{{ p.get_item_path_list('database_addon', True) }}
{{ _('02_admin_items') }}{{ len(p.get_item_path_list('database_addon', 'admin')) }}{{ p.get_item_path_list('database_addon', 'admin') }}
{{ _('10_daily_items') }}{{ len(p._daily_items) }}{{ p._daily_items }}
{{ _('11_weekly_items') }}{{ len(p._weekly_items) }}{{ p._weekly_items }}
{{ _('12_monthly_items') }}{{ len(p._monthly_items) }}{{ p._monthly_items }}
{{ _('13_yearly_items') }}{{ len(p._yearly_items) }}{{ p._yearly_items }}
{{ _('14_onchange_items') }}{{ len(p._onchange_items) }}{{ p._onchange_items }}
{{ _('15_startup_items') }}{{ len(p._startup_items) }}{{ p._startup_items }}
{{ _('17_database_items') }}{{ len(p._database_items) }}{{ p._database_items }}
{{ _('16_static_items') }}{{ len(p._static_items) }}{{ p._static_items }}
{{ _('32_item_cache') }}{{ len(p.item_cache) }}{{ p.item_cache }}
{{ _('20_tageswert_dict') }}{{ len(p.current_values['day']) }}{{ p.current_values['day'] }}
{{ _('21_wochenwert_dict') }}{{ len(p.current_values['week']) }}{{ p.current_values['week'] }}
{{ _('22_monatswert_dict') }}{{ len(p.current_values['month']) }}{{ p.current_values['month'] }}
{{ _('23_jahreswert_dict') }}{{ len(p.current_values['year']) }}{{ p.current_values['year'] }}
{{ _('24_vortagsendwert_dict') }}{{ len(p.previous_values['day']) }}{{ p.previous_values['day'] }}
{{ _('25_vorwochenendwert_dict') }}{{ len(p.previous_values['week']) }}{{ p.previous_values['week'] }}
{{ _('26_vormonatsendwert_dict') }}{{ len(p.previous_values['month']) }}{{ p.previous_values['month'] }}
{{ _('27_vorjahresendwert_dict') }}{{ len(p.previous_values['year']) }}{{ p.previous_values['year'] }}
{{ _('get_item_list') }}{{ len(p.get_item_list('database_addon', True)) }}{{ p.get_item_list('database_addon', True) }}
{{ _('_plg_item_dict') }}{{ len(p._plg_item_dict) }}{{ p._plg_item_dict }}
{{ _('work_item_queue_thread') }}{{ len(p._plg_item_dict) }}{{ p.work_item_queue_thread.is_alive() }}
+
+{% endblock bodytab2 %} + + + +{% block bodytab3 %} +
+

{{_('Item Attribute')}}

+ {% for function, itemdefinitions_dict in p.metadata.itemdefinitions.items() %} +
+
+ {{ function }}      {{('Beschreibung:')}} {{ itemdefinitions_dict['description'][language] }}       {{ ('Ergebnisdatentyp:')}} {{ itemdefinitions_dict['type'] }} +
+ {% if 'valid_list' in itemdefinitions_dict %} +
+ + + + + + + + + + {% for entry in itemdefinitions_dict['valid_list'] %} + + + + + + {% endfor %} + +
{{_('Item Attributwert')}}{{_('Item_Type')}}{{_('Beschreibung')}}
{{ entry }}{% if 'valid_list_item_type' in itemdefinitions_dict %} {{ itemdefinitions_dict.valid_list_item_type[loop.index0] }} {% else %} {{_('-')}} {% endif %}{% if 'valid_list_description' in itemdefinitions_dict %} {{ itemdefinitions_dict.valid_list_description[loop.index0] |string }} {% else %} {{_('-')}} {% endif %}
+
+ {% endif %} +
+ {% endfor %} + +
+

{{_('Plugin Funktionen')}}

+ {% for function, plugin_functions_dict in p.metadata.plugin_functions.items() %} +
+
+ {{ function }}      {{('Beschreibung:')}} {{ plugin_functions_dict['description'][language] }}       {{ ('Ergebnisdatentyp:')}} {{ plugin_functions_dict['type'] }} +
+ + {% if 'parameters' in plugin_functions_dict %} +
+
+ + + + + + + + + + + {% for entry in plugin_functions_dict['parameters'] %} + + + + + + + {% endfor %} + +
{{_('Parameter')}}{{_('Beschreibung')}}{{_('Type')}}{{_('zulässige Werte')}}
{{ entry }}{{ plugin_functions_dict['parameters'][entry]['description'][language] }}{{ plugin_functions_dict['parameters'][entry]['type'] }}{{ plugin_functions_dict['parameters'][entry]['valid_list'] }}
+
+ {% endif %} +
+ {% endfor %} +
+{% endblock bodytab3 %} diff --git a/dlms/user_doc.rst b/dlms/user_doc.rst index fc6d3eb92..8636d5a45 100755 --- a/dlms/user_doc.rst +++ b/dlms/user_doc.rst @@ -5,6 +5,13 @@ dlms ==== +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + Das Plugin dient zum Auslesen von Smartmetern die das DLMS Protokoll beherrschen. Anforderungen diff --git a/dmx/__init__.py b/dmx/__init__.py index 690304bb6..29a0ab224 100755 --- a/dmx/__init__.py +++ b/dmx/__init__.py @@ -62,9 +62,9 @@ def __init__(self, sh, *args, **kwargs): the configured (and checked) value for a parameter by calling self.get_parameter_value(parameter_name). It returns the value in the datatype that is defined in the metadata. """ - from bin.smarthome import VERSION - if '.'.join(VERSION.split('.', 2)[:2]) <= '1.5': - self.logger = logging.getLogger(__name__) + + # Call init code of parent class (SmartPlugin) + super().__init__() # If an package import with try/except is done, handle an import error like this: @@ -95,8 +95,8 @@ def __init__(self, sh, *args, **kwargs): return else: self._is_connected = True - - + + if self._interface == 'nanodmx': self.send = self.send_nanodmx if not self._send_nanodmx("C?"): diff --git a/dmx/user_doc.rst b/dmx/user_doc.rst index 0f0ac6850..12f3d9c3f 100755 --- a/dmx/user_doc.rst +++ b/dmx/user_doc.rst @@ -1,8 +1,13 @@ -DMX + +.. index:: Plugins; dmx +.. index:: dmx + +=== +dmx === Vorbedingungen --------------- +============== Dieses Plugin benötigt eine der folgenden unterstützten DMX-Schnittstellen: @@ -14,10 +19,13 @@ Daher ist auch ein serieller Python-Treiber erforderlich. Eine requirements Date bereitgestellt, um die Installation zu erleichtern. Konfiguration -------------- +============= + +Diese Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/dmx` beschrieben. plugin.yaml -~~~~~~~~~~~ +----------- .. code :: yaml @@ -42,16 +50,16 @@ notwendig, um eine udev-Regel zu erstellen. Für ein NanoDMX-Gerät bereitgestel In der Online-Hilfe für Linux kann die Erstellung von udev Regeln nachgelesen werden. items.yaml -~~~~~~~~~~ +---------- dmx_ch -^^^^^^ +~~~~~~ Mit diesem Attribut können ein oder mehrere DMX-Kanäle als Integer angegeben werden angegeben Beispiel -~~~~~~~~ +-------- .. code:: yaml @@ -72,8 +80,9 @@ In einer Logik führt ein Ausdruck wie ``sh.living_room.dimlight(80)`` dazu das Entsprechend sendet der Ausdruck ``sh.living_room.dimlightreading(50)`` eine `50`` an den Kanal ``23``, um das Leselicht im Wohnzimmer zu dimmen. -Methoden --------- + +Funktionen +---------- send(Kanal, Wert) ~~~~~~~~~~~~~~~~~ diff --git a/enocean/README.md b/enocean/README.md index fb943eb6f..1cfe385de 100755 --- a/enocean/README.md +++ b/enocean/README.md @@ -1,472 +1,12 @@ -# EnOcean -## Description -This plugin adds EnOcean support to SmarthomeNG. - -## Support -If you have special hardware not supported yet, please feel free to improve and contribute! - -## Version / Change History -Version: 1.3.6 - -Change History: currently not maintained. - -## Hardware Requirements -For use of this plugin you need an EnOcean radio transceiver module like: -- Fam4Pi -- USB 300 -- EnOcean PI 868 Funk Modul -- etc. - -## Prerequisites -Make sure that the user 'smarthome' belongs to the user group dialout to be able to access the linux devices via: - -sudo gpasswd --add smarthome dialout - -## Configuration - -### plugin.yaml - -Add the following lines to your `plugin.yaml`: - -#### Parameters - -##### serialport - -You have to specify the `serialport` to your port name of your EnOcean adapter. -UNder Linux, the creation of a specific **udev-rules** for the EnOcean adapter is recommended, when using different Uart devices. - -##### tx_id -The specification of the EnOcean `tx_id` is optional **but** mandatory for sending control commands from smarthomeNG to EnOcean devices. -It is defined as a 8-digit hexadecimal value. - -When controlling multiple devices, it is recommended to use the EnOcean adapter's Base-ID (not Unique-ID or Chip-ID) as transmitting ID. -For further information regarding the difference between Base-ID and Chip-ID, see -[Knowledge Base](https://www.enocean.com/en/knowledge-base-doku/enoceansystemspecification%3Aissue%3Awhat_is_a_base_id/) - -With the specification of the Base-ID, 128 different transmit ID's are available, ranging between Base-ID and Base-ID + 127. - -##### How-To Get the Base-ID of the EnOcean adapter -There are two different ways of reading the EnOcean adapter's Base ID: -a) Via the Enocean Plugin Webinterface -b) Via the logfiles created by the Enocean plugin. - -For a) -1. Configure Enocean plugin in plugin.yaml file with empty tx_id (or tx_id = 0). -2. Restart SmarthomeNG. -3. Open the plugin's webinterface under: http://localip:8383/enocean/ -4. Read the Transceiver's BaseID, which is displayed on the upper right side. -5. Insert the Base-ID in the plugin.yaml file as tx_id parameter. - -For b) -1. Configure Enocean plugin in plugin.yaml file with empty tx_id (or tx_id = 0). -2. Configure loglevel INFO in logger.yaml for enocean plugin. -3. Restart smarthomeNG -4. Wait until all plugins came up -5. Open the logfile (Enocean or general smarthomeNG logfile) and search for `enocean: Base ID = 0xYYYYZZZZ` -6. Insert the Base-ID in the plugin.yaml file as tx_id parameter.. - -#### Example plugin.yaml -```yaml -enocean: - class_name: EnOcean - class_path: plugins.enocean - serialport: /dev/ttyUSB0 - tx_id: FFFF4680 -``` ### Items -#### enocean_rx_id, enocean_rx_eep and enocean_tx_id_offset -An EnOcean item (sensor or actor) must specify at minimum an `enocean_rx_id` (EnOcean Identification Number (hex code)) and an `enocean_rx_eep` (EnOcean Equipment Profile). -Transmitting items additionally need an `enocean_tx_id_offset`. - -#### enocean_rx_eep -The EEP [EnOcean Equippment Profile] defines the message type that is broadcast by the Enocean device. EEPs are standardized by Enocean. More information can be found under http://www.enocean-alliance.org/eep/ - -#### enocean_rx_key -Generally, EnOcean devices broadcast more than just one information. These can be linked to different smarthomeNG items via so called shortcut key names (enocean_rx_key). See the list below for different examples of key names. - - -The following example outlines the available button shortcuts and their meaning for a rocker/switch with two rocker (EEP-Profile: F6_02_01 or F6_02_02). - -``` -AI = left rocker down -A0 = left rocker up -BI = right rocker down -B0 = right rocker up -``` -The following example outlines the button shortcuts and its meaning for a rocker/switch with two rocker and 6 available combinations (EEP F6_02_03). - -``` -AI = left rocker down -A0 = left rocker up -BI = right rocker down -B0 = right rocker up -A = last state of left rocker -B = last state of right rocker -``` -Example of a mechanical handle (F6_10_0): - -``` -STATUS = handle_status -``` ### items.yaml -#### Attributes -For attributes have a look at the examples. - -#### Example item.yaml -``` -EnOcean_Item: - Outside_Temperature: - type: num - enocean_rx_id: 0180924D - enocean_rx_eep: A5_02_05 - enocean_rx_key: TMP - - Door: - enocean_rx_id: 01234567 - enocean_rx_eep: D5_00_01 - status: - type: bool - enocean_rx_key: STATUS - - FT55switch: - enocean_rx_id: 012345AA - enocean_rx_eep: F6_02_03 - up: - type: bool - enocean_rx_key: BO - down: - type: bool - enocean_rx_key: BI - - Brightness_Sensor: - name: brightness_sensor_east - remark: Eltako FAH60 - type: num - enocean_rx_id: 01A51DE6 - enocean_rx_eep: A5_06_01 - enocean_rx_key: BRI - visu_acl: rw - sqlite: 'yes' - - dimmer1: - remark: Eltako FDG14 - Dimmer - enocean_rx_id: 00112233 - enocean_rx_eep: A5_11_04 - light: - type: bool - enocean_rx_key: STAT - enocean_tx_eep: A5_38_08_02 - enocean_tx_id_offset: 1 - level: - type: num - enocean_rx_key: D - enocean_tx_eep: A5_38_08_03 - enocean_tx_id_offset: 1 - ref_level: 80 - dim_speed: 100 - block_dim_value: 'False' - - handle: - enocean_rx_id: 01234567 - enocean_rx_eep: F6_10_00 - status: - type: num - enocean_rx_key: STATUS - - actor1: - enocean_rx_id: FFAABBCC - enocean_rx_eep: A5_12_01 - power: - type: num - enocean_rx_key: VALUE - - actor1B: - remark: Eltako FSR61, FSR61NP, FSR61G, FSR61LN, FLC61NP - Switch for Ligths - enocean_rx_id: 1A794D3 - enocean_rx_eep: F6_02_03 - light: - type: bool - enocean_tx_eep: A5_38_08_01 - enocean_tx_id_offset: 1 - enocean_rx_key: B - block_switch: 'False' - cache: 'True' - enforce_updates: 'True' - visu_acl: rw - - actor_D2: - remark: Actor with VLD Command - enocean_rx_id: FFDB7381 - enocean_rx_eep: D2_01_07 - move: - type: bool - enocean_rx_key: STAT - enocean_tx_eep: D2_01_07 - enocean_tx_id_offset: 1 - # pulsewith-attribute removed use autotimer functionality instead - autotimer: 1 = 0 - - actorD2_01_12: - enocean_rx_id: 050A2FF4 - enocean_rx_eep: D2_01_12 - switch: - cache: 'on' - type: bool - enocean_rx_key: STAT_A - enocean_channel: A - enocean_tx_eep: D2_01_12 - enocean_tx_id_offset: 2 - - awning: - name: Eltako FSB14, FSB61, FSB71 - remark: actor for Shutter - type: str - enocean_rx_id: 1A869C3 - enocean_rx_eep: F6_0G_03 - enocean_rx_key: STATUS - move: - type: num - enocean_tx_eep: A5_3F_7F - enocean_tx_id_offset: 0 - enocean_rx_key: B - enocean_rtime: 60 - block_switch: 'False' - enforce_updates: 'True' - cache: 'True' - visu_acl: rw - - rocker: - enocean_rx_id: 0029894A - enocean_rx_eep: F6_02_01 - short_800ms_directly_to_knx: - type: bool - enocean_rx_key: AI - enocean_rocker_action: **toggle** - enocean_rocker_sequence: released **within** 0.8 - knx_dpt: 1 - knx_send: 3/0/60 - - long_800ms_directly_to_knx: - type: bool - enocean_rx_key: AI - enocean_rocker_action: toggle - enocean_rocker_sequence: released **after** 0.8 - knx_dpt: 1 - knx_send: 3/0/61 - - rocker_double_800ms_to_knx_send_1: - type: bool - enforce_updates: true - enocean_rx_key: AI - enocean_rocker_action: **set** - enocean_rocker_sequence: **released within 0.4, pressed within 0.4** - knx_dpt: 1 - knx_send: 3/0/62 - - brightness_sensor: - enocean_rx_id: 01234567 - enocean_rx_eep: A5_08_01 - lux: - type: num - enocean_rx_key: BRI - - movement: - type: bool - enocean_rx_key: MOV - - occupancy_sensor: - enocean_rx_id: 01234567 - enocean_rx_eep: A5_07_03 - lux: - type: num - enocean_rx_key: ILL - - movement: - type: bool - enocean_rx_key: PIR - - voltage: - type: bool - enocean_rx_key: SVC - - temperature_sensor: - enocean_rx_id: 01234567 - enocean_rx_eep: A5_04_02 - temperature: - type: num - enocean_rx_key: TMP - humidity: - type: num - enocean_rx_key: HUM - - power_status: - type: num - enocean_rx_key: ENG - - sunblind: - name: Eltako FSB14, FSB61, FSB71 - remark: actor for Shutter - type: str - enocean_rx_id: 1A869C3 - enocean_rx_eep: F6_0G_03 - enocean_rx_key: STATUS - # runtime Range [0 - 255] s - enocean_rtime: 80 - Tgt_Position: - name: Eltako FSB14, FSB61, FSB71 - remark: Pos. 0...255 - type: num - enocean_rx_id: ..:. - enocean_rx_eep: ..:. - enforce_updates: 'True' - cache: 'True' - visu_acl: rw - Act_Position: - name: Eltako FSB14, FSB61, FSB71 - remark: Ist-Pos. 0...255 berechnet aus (letzer Pos. + Fahrzeit * 255/rtime) - type: num - enocean_rx_id: ..:. - enocean_rx_eep: ..:. - enocean_rx_key: POSITION - enforce_updates: 'True' - cache: 'True' - visu_acl: rw - eval: min(max(value, 0), 255) - on_update: - - EnOcean_Item.sunblind = 'stopped' - Run: - name: Eltako FSB14, FSB61, FSB71 - remark: Ansteuerbefehl 0x00, 0x01, 0x02 - type: num - enocean_rx_id: ..:. - enocean_rx_eep: ..:. - enocean_tx_eep: A5_3F_7F - enocean_tx_id_offset: 0 - enocean_rx_key: B - enocean_rtime: ..:. - # block actuator - block_switch: 'True' - enforce_updates: 'True' - cache: 'True' - visu_acl: rw - struct: uzsu.child - Movement: - name: Eltako FSB14, FSB61, FSB71 - remark: Wenn Rolladen gestoppt wurde steht hier die gefahrene Zeit in s und die Richtung - type: num - enocean_rx_id: ..:. - enocean_rx_eep: A5_0G_03 - enocean_rx_key: MOVE - cache: 'False' - enforce_updates: 'True' - eval: value * 255/int(sh.EnOcean_Item.sunblind.property.enocean_rtime) - on_update: - - EnOcean_Item.sunblind = 'stopped' - - EnOcean_Item.sunblind.Act_Position = EnOcean_Item.sunblind.Act_Position() + value - - RGBdimmer: - type: num - remark: Eltako FRGBW71L - RGB Dimmer - enocean_rx_id: 1A869C3 - enocean_rx_eep: A5_3F_7F - enocean_rx_key: DI_0 - red: - type: num - enocean_tx_eep: 07_3F_7F - enocean_tx_id_offset: 1 - enocean_rx_key: DI_0 - ref_level: 80 - dim_speed: 100 - color: red - green: - type: num - enocean_tx_eep: 07_3F_7F - enocean_tx_id_offset: 1 - enocean_rx_key: DI_1 - ref_level: 80 - dim_speed: 100 - color: green - blue: - type: num - enocean_tx_eep: 07_3F_7F - enocean_tx_id_offset: 1 - enocean_rx_key: DI_2 - ref_level: 80 - dim_speed: 100 - color: blue - white: - type: num - enocean_tx_eep: 07_3F_7F - enocean_tx_id_offset: 1 - enocean_rx_key: DI_3 - ref_level: 80 - dim_speed: 100 - color: white - water_sensor: - enocean_rx_id: 00000000 - enocean_rx_eep: A5_30_03 - - alarm: - type: bool - enocean_rx_key: ALARM - visu_acl: ro - - temperature: - type: num - enocean_rx_key: TEMP - visu_acl: ro - -``` - -### Add new listening EnOcean devices - -You have to know about the EnOcean RORG of your device (please search the internet or ask the vendor). - -Further the RORG must be declared in the plugin. - -The following status EEPs are supported: - -``` -* A5_02_01 - A5_02_0B Temperature Sensors (40°C overall range, various starting offsets, 1/6°C resolution) -* A5_02_10 - A5_02_1B Temperature Sensors (80°C overall range, various starting offsets, 1/3°C resolution) -* A5_02_20 High Precision Temperature Sensor (ranges -10*C to +41.2°C, 1/20°C resolution) -* A5_02_30 High Precision Temperature Sensor (ranges -40*C to +62.3°C, 1/10°C resolution) -* A5_04_02 Energy (optional), humidity and temperature sensor -* A5_07_03 Occupancy sensor, e.g. NodOn PIR-2-1-0x -* A5_08_01 Brightness and movement sensor -* A5_11_04 Dimmer status feedback -* A5_12_01 Power Measurement, e.g. Eltako FSVA-230V -* A5_0G_03 shutter feedback in s if actor is stopped before reaching his position (for calculation of new position) -* A5_30_01 Alarm sensor, e.g. Eltako FSM60B water leak sensor -* A5_30_03 Alarm sensor, e.g. Eltako FSM60B water leak sensor -* D2_01_07 Simple electronic switch -* D2_01_12 Simple electronic switch with 2 channels, like NodOn In-Wall module -* D5_00_01 Door/Window Contact, e.g. Eltako FTK, FTKB -* F6_02_01 2-Button-Rocker -* F6_02_02 2-Button-Rocker -* F6_02_03 2-Button-Rocker, Status feedback from manual buttons on different actors, e.g. Eltako FT55, FSUD-230, FSVA-230V, FSB61NP-230V or Gira switches. -* F6_10_00 Mechanical Handle (value: 0(closed), 1(open), 2(tilted) -* F6_0G_03 Feedback of shutter actor (Eltako FSB14, FSB61, FSB71 - actor for Shutter) if reaching the endposition and if motor is active -``` -A complete list of available EEPs is accessible at [EnOcean Alliance](http://www.enocean-alliance.org/eep/) - - -### Send commands: Tx EEPs - -``` -* A5_38_08_01 Regular switch actor command (on/off) -* A5_38_08_02 Dimmer command with fix on off command (on: 100, off:0) -* A5_38_08_03 Dimmer command with specified dim level (0-100) -* A5_3F_7F Universal actuator command, e.g. blind control -* D2_01_07 Simple electronic switch -* D2_01_12 Simple electronic switch with 2 channels ``` The optional ref_level parameter defines default dim value when dimmer is switched on via the regular "on"" command. diff --git a/enocean/__init__.py b/enocean/__init__.py index 570a875b5..178047b47 100755 --- a/enocean/__init__.py +++ b/enocean/__init__.py @@ -31,6 +31,7 @@ from . import eep_parser from . import prepare_packet_data from lib.model.smartplugin import * +from .webif import WebInterface FCSTAB = [ 0x00, 0x07, 0x0e, 0x09, 0x1c, 0x1b, 0x12, 0x15, @@ -165,7 +166,7 @@ class EnOcean(SmartPlugin): ALLOW_MULTIINSTANCE = False - PLUGIN_VERSION = "1.3.6" + PLUGIN_VERSION = "1.3.7" def __init__(self, sh, *args, **kwargs): @@ -202,8 +203,8 @@ def __init__(self, sh, *args, **kwargs): # call init of prepare_packet_data self.prepare_packet_data = prepare_packet_data.Prepare_Packet_Data(self) - if not self.init_webinterface(): - self._init_complete = False + self.init_webinterface(WebInterface) + def eval_telegram(self, sender_id, data, opt): logger_debug = self.logger.isEnabledFor(logging.DEBUG) @@ -833,102 +834,4 @@ def _calc_crc8(self, msg, crc=0): ### --- END - Calc CRC8 --- ### ############################### - def init_webinterface(self): - """" - Initialize the web interface for this plugin - - This method is only needed if the plugin is implementing a web interface - """ - try: - self.mod_http = Modules.get_instance().get_module( - 'http') # try/except to handle running in a core version that does not support modules - except: - self.mod_http = None - if self.mod_http == None: - self.logger.error(f"Plugin '{self.get_shortname()}': Not initializing the web interface") - return False - - # set application configuration for cherrypy - webif_dir = self.path_join(self.get_plugin_dir(), 'webif') - config = { - '/': { - 'tools.staticdir.root': webif_dir, - }, - '/static': { - 'tools.staticdir.on': True, - 'tools.staticdir.dir': 'static' - } - } - - # Register the web interface as a cherrypy app - self.mod_http.register_webif(WebInterface(webif_dir, self), - self.get_shortname(), - config, - self.get_classname(), self.get_instance_name(), - description='') - - return True - -# ------------------------------------------ -# Webinterface of the plugin -# ------------------------------------------ - -import cherrypy -import csv -from jinja2 import Environment, FileSystemLoader - - -class WebInterface(SmartPluginWebIf): - - def __init__(self, webif_dir, plugin): - """ - Initialization of instance of class WebInterface - - :param webif_dir: directory where the webinterface of the plugin resides - :param plugin: instance of the plugin - :type webif_dir: str - :type plugin: object - """ - self.logger = logging.getLogger(__name__) - self.webif_dir = webif_dir - self.plugin = plugin - self.items = Items.get_instance() - - self.tplenv = self.init_template_environment() - - - @cherrypy.expose - def index(self, reload=None, action=None, item_id=None, item_path=None, device_id=None, device_offset=None, - time_orig=None, changed_orig=None): - """ - Build index.html for cherrypy - - Render the template and return the html file to be delivered to the browser - - :return: contents of the template after beeing rendered - """ - learn_triggered = False - - if action is not None: - if action == "toggle_tx_blocking": - self.plugin.toggle_block_external_out_messages() - elif action == "toggle_UTE": - self.plugin.toggle_UTE_mode(device_offset) - self.logger.warning(f"UTE mode triggered via webinterface (Offset: {device_offset})") - elif action == "toggle_log_unknown": - self.plugin.toggle_log_unknown_msg() - self.logger.info(f"Toogle state of log unknown messages triggered via webinterface") - elif action == "send_learn" and (device_id is not None) and not (device_id=="") and (device_offset is not None) and not(device_offset==""): - self.logger.warning(f"Learn telegram triggered via webinterface (ID:{device_id} Offset:{device_offset})") - ret = self.plugin.send_learn_protocol(int(device_offset), int(device_id)) - if ret == True: - learn_triggered = True - else: - self.logger.error("Unknown comman received via webinterface") - - tmpl = self.tplenv.get_template('index.html') - return tmpl.render(p=self.plugin, - items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path']), reverse=False), - tabcount=1, item_id=item_id, learn_triggered=learn_triggered, action='') - diff --git a/enocean/eep_parser.py b/enocean/eep_parser.py index 3d1544af4..6d959ef1a 100755 --- a/enocean/eep_parser.py +++ b/enocean/eep_parser.py @@ -426,14 +426,16 @@ def _parse_eep_F6_02_03(self, payload, status): return results def _parse_eep_F6_10_00(self, payload, status): - self.logger.debug("Processing F6_10_00: Mechanical Handle") + self.logger.debug(f"Processing F6_10_00: Mechanical Handle sends payload {payload[0]}") results = {} - if (payload[0] == 0xF0): + # Eltako defines 0xF0 for closed status. Enocean spec defines masking of lower 4 bit: + if (payload[0] & 0b11110000) == 0b11110000: results['STATUS'] = 0 - elif ((payload[0]) == 0xE0) or ((payload[0]) == 0xC0): + # Eltako defines 0xE0 for window open (horizontal) up status. Enocean spec defines the following masking: + elif (payload[0] & 0b11010000) == 0b11000000: results['STATUS'] = 1 - # Typo error in older Eltako Datasheet for 0x0D instead of the right 0xD0 - elif (payload[0] == 0xD0): + # Eltako defines 0xD0 for open/right up status. Enocean spec defines masking of lower 4 bit: + elif (payload[0] & 0b11110000) == 0b11010000: results['STATUS'] = 2 else: self.logger.error(f"Error in F6_10_00 handle status, payload: {payload[0]} unknown") diff --git a/enocean/locale.yaml b/enocean/locale.yaml index b89649b65..724fcfa2d 100755 --- a/enocean/locale.yaml +++ b/enocean/locale.yaml @@ -31,17 +31,17 @@ plugin_translations: de: 'Wollen Sie das Senden wirklich umschalten?' en: 'Do you really want to change the sending state?' fr: "Changer le stade de l'emitteur?" - 'Uebersicht': {'de': 'Uebersicht', 'en': 'Overview', 'fr': "vue d'ensemble"} + 'Uebersicht': {'de': 'Übersicht', 'en': 'Overview', 'fr': "vue d'ensemble"} 'Wollen Sie ein Lerntelegramm schicken?': de: 'Wollen Sie ein Lerntelegramm schicken?' en: 'Do you really want to send a lern telegram?' - fr: "Volez-vous emettre un message à programmer?" + fr: "Voulez-vous emettre un message à programmer?" 'Wollen Sie den UTE Modus umschalten?': de: 'Wollen Sie den UTE Modus umschalten?' en: 'Do you really want to toggle the UTE mode?' fr: "Confirmez de changer le mode UTE" 'Neu hinzu': - de: 'Neu hinzufuegen' + de: 'Neu hinzufügen' en: 'Add devie' fr: 'Ajouter' 'Unbekannte Sensor ID': @@ -49,11 +49,11 @@ plugin_translations: en: 'Unknown Sensor ID' fr: 'Capteur ID inconnu' 'Naechster freier TX-Offset': - de: 'Naechster freier TX-Offset' + de: 'Nächster freier TX-Offset' en: 'Next free tx offset' fr: 'Prochain tx offset libre' 'Enocean Geraete anlernen': - de: 'Enocean Geraete anlernen' + de: 'Enocean Geräte anlernen' en: 'Teach-in new Enocean devices' fr: 'Ajouter nouveau acteur' 'Anlerninformationen': @@ -61,15 +61,15 @@ plugin_translations: en: 'Teach-in informations' fr: 'Ajouter informations' 'Choose sensor...': - de: 'Sensor waehlen...' + de: 'Sensor wählen...' en: 'Choose sensor...' - fr: 'Selectez capteuer' + fr: 'Choisissez capteuer' 'Logging von unbekannten Enocean Nachrichten ins Logfile An/Aus': de: 'Logging von unbekannten Enocean Nachrichten ins Logfile An/Aus' en: 'Log unknown Enocean messages to logfile on/off' fr: 'Log unknown Enocean messages to logfile on/off' - 'Device Type': {'de': 'Geraetetyp', 'en': 'Device Type', 'fr': 'Type'} + 'Device Type': {'de': 'Gerätetyp', 'en': 'Device Type', 'fr': 'Type'} 'ID Offset': {'de': 'ID Offset', 'en': 'ID Offset'} 'Anlernen': {'de': 'Anlernen', 'en': 'Teach-in', 'fr': 'Programmer'} 'UTE An/Aus': {'de': 'UTE An/Aus', 'en': 'UTE On/Off', 'fr': 'UTE On/Off'} @@ -78,7 +78,7 @@ plugin_translations: 'Die folgenden Items sind dieser Instanz des Enocean Plugins zugewiesen': de: 'Die folgenden Items sind dieser Instanz des Enocean Plugins zugewiesen' en: 'The following items are assigned to this instance of the Enocean plugin' - fr: "Les object suivants sont liés à cette instance de l'extension Enocean" + fr: "Les object suivants sont liés à cette instance de l'extension Enocean" diff --git a/enocean/plugin.yaml b/enocean/plugin.yaml index 369a899cc..2d56f16cd 100755 --- a/enocean/plugin.yaml +++ b/enocean/plugin.yaml @@ -6,9 +6,9 @@ plugin: # Alternative: description in multiple languages de: 'Anbindung von EnOcean' en: 'EnOcean Interface' - maintainer: Robert Budde / A. Schwithal + maintainer: Robert Budde / aschwith # Who tests this plugin? - tester: + tester: unknown state: ready keywords: EnOcean, Eltako # url of documentation (wiki) page @@ -16,7 +16,7 @@ plugin: # url of the support thread support: https://knx-user-forum.de/forum/supportforen/smarthome-py/26542-featurewunsch-enocean-plugin/page13 - version: 1.3.6 # Plugin version + version: 1.3.7 # Plugin version sh_minversion: 1.3 # minimum shNG version to use this plugin #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance diff --git a/enocean/user_doc.rst b/enocean/user_doc.rst new file mode 100755 index 000000000..18889ba24 --- /dev/null +++ b/enocean/user_doc.rst @@ -0,0 +1,547 @@ +.. index:: Plugins; enocean +.. index:: enocean + +======= +enocean +======= + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + + +Allgemein +========= + +Enocean plugin zur Integration von Enocean Funksensoren und -aktoren, z.B. von Eltako, Gira, Hoppe, Peha. + +Anforderungen +============= +Es wird ein Hardware Radio Transceiver Modul benötigt, z.B.: + + * USB 300 + * Fam4Pi + * EnOcean PI 868 Funk Modul + + +.. important:: + + Der user `smarthome`, unter dem smarthomeNG ausgeführt wird, muss die nötigen Zugriffsrechte + auf die Linux Gruppe `dialout` besitzen, damit die Hardware über Linux devices angesprochen und konfiguriert werden kann. + +Hierzu folgendes in der Linux Konsole ausführen: + +.. code-block:: bash + + sudo gpasswd --add smarthome dialout + +Aktoren, Schalter oder Stellglieder, die vom Enocean plugin gesteuert werden sollen, müssen vorher einmalig angelert werden. Der Anlernvorgang wird unten im Kaptiel Webinterface beschrieben. +Für das Auslesen von Statusinfromationen von Enocean Sensoren ist kein Anlernvorgang nötig. + + +Konfiguration +============= + +Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/enocean` beschrieben. + +plugin.yaml +----------- + +**serialport** + +Hier wird der `serialport` zum Enocean Hardwareadapter angegeben werden. +Unter Linux wird empfohlen, das entsprechende Linux Uart device über eine Udev-Regel auf einen Link zu mappen und diesen Link dann als `serialport` anzugeben. + +**tx_id** + +Die tx_id ist die Transmitter ID der Enocean Hardware und als achtstelliger Hexadezimalwert definiert. Die Angabe ist erstmal optional und muss nur zwingend angegeben werden, +falls Enocean Aktoren geschaltet werden sollen, d.h. der Hardware Kontroller auch Senebefehle absetzen muss. + +Werden mehrere Aktuatoren betrieben, sollte die Base-ID (**not Unique-ID or Chip-ID**) der Enocean Hardware als Transmitter ID angegeben werden. +Weitere Information zum Unterschied zwischen Base-ID und Chip-ID finden sich unter: + +https://www.enocean.com/en/knowledge-base-doku/enoceansystemspecification%3Aissue%3Awhat_is_a_base_id/ + +Über die Angabe der Base-ID können für die Kommunikation mit den Aktuatoren 128 verschiedene Sende-IDs im Wertebereich von (Base-ID und Base-ID + 127) vergeben werden. + +**Wie bekommt man die Base-ID des Enocean Adapters?** + +Es gibt zwei verschiedene Wege, um die Base ID der Enocean Hardware auszulesen: + + a) Über das Enocean Plugin Webinterface + + b) Über die smarthomeNG Logdateien, die durch das Enocean plugin erzeugt werden. + + +Zu a) + +1. Konfiguriere das Enocean plugin in der plugin.yaml mit leerer tx_id (oder tx_id = 0). + +2. Starte SmarthomeNG neu. + +3. Öffne das Enocean webinterface des Plugins unter: http://smarthome.local:8383/enocean + +4. Ablesen der Transceiver's BaseID, welches auf der oberen recten Seite angezeigt wird. + +5. Übernahme der im Webinterface angezeigten Base-ID in die plugin.yaml als Parameter `tx_id`. + + +Zu b) + +1. Konfiguriere das Enocean plugin in der plugin.yaml mit leerer tx_id (oder tx_id = 0). + +2. Konfiguriere das Loglevel INFO in logger.yaml für das Enocean Plugin. Alternativ über das Admin Interface unter Logger + +3. Starte SmarthomeNG neu. + +4. Nach dem Neustart das Logfile öffnen und nach dem Eintrag ``enocean: Base ID = 0xYYYYZZZZ`` suchen. + +6. Übernahme dieser im Log angezeigten Base-ID in die plugin.yaml als Parameter `tx_id`. + + +item.yaml +--------- + +#### enocean_rx_id, enocean_rx_eep and enocean_tx_id_offset +Ein EnOcean Item (Sensor oder Aktor) muss mindestens ein ``enocean_rx_id`` und ein ``enocean_rx_eep`` Attribut definieren. +Der Attribut ``enocean_rx_id'' gibt dabei die eindeutige ID (EnOcean Identification Number) als hexadezimaler String an. Dieser ist auf dem Gerät vermerkt. +Alternativ kann über das Webinterface unbekannte IDs von sendeden Enocean Geräten in der Nähe angezeigt werden. + +Die Enocean EEP gibt das EnOcean Equipment Profile an. Das Datenblatt des Enocean Geräts verrät, welche EEPs unterstützt werden. +In einer Nachricht einer bestimmten Nachricht können sich verschiedene Signale wie z.B. Batteriestatus, Schaltstatus (an/aus) etc. befinden. Um diese Signale den einzelnen smarthomeNG +Items zuzuordnen, werden abgekürzte `Keys` verwendet und als Attribut ``enocean_rx_key angegeben. + +Im Beispiel sind die verschiedenen `Keys` ersichtlich. Folgende `Keys` werden vom Plugin unterstützt: + + **Schalter mit zwei Tastern**, (EEP F6_02_01 oder F6_02_02) + + AI = left rocker down + A0 = left rocker up + BI = right rocker down + B0 = right rocker up + + + **Schalter mit zwei Tastern), (EEP F6_02_03) + + AI = left rocker down + A0 = left rocker up + BI = right rocker down + B0 = right rocker up + A = last state of left rocker + B = last state of right rocker + + + **Fenstergriff**, (EEP F6_10_0) + + STATUS = handle_status + + + +Sendende Items, z.B. um Schaltaktoren zu schalten, benötigen weiterhin eine Transmitting ID. Diese wird im Attribut ``enocean_tx_id_offset`` definiert. + + +Enocean Equipment Profiles +========================== + +Das Encoean Protokoll basiert auf sogenannten EnOcean Equipment Profilen (EEPs). Sie definieren den Nachrichtentyp der vm EnOcean Gerät gesendet wird. +EEPs sind standardisiert. Informationen dazu können unter http://www.enocean-alliance.org/eep/ gefunden werden. + + +Status EEPs +----------- + +Die folgenden Status EEPs werden vom Plugin aktuell unterstützt: + + * A5_02_01 - A5_02_0B Temperature Sensors (40°C overall range, various starting offsets, 1/6°C resolution) + * A5_02_10 - A5_02_1B Temperature Sensors (80°C overall range, various starting offsets, 1/3°C resolution) + * A5_02_20 High Precision Temperature Sensor (ranges -10*C to +41.2°C, 1/20°C resolution) + * A5_02_30 High Precision Temperature Sensor (ranges -40*C to +62.3°C, 1/10°C resolution) + * A5_04_02 Energy (optional), humidity and temperature sensor + * A5_07_03 Occupancy sensor, e.g. NodOn PIR-2-1-0x + * A5_08_01 Brightness and movement sensor + * A5_11_04 Dimmer status feedback + * A5_12_01 Power Measurement, e.g. Eltako FSVA-230V + * A5_0G_03 shutter feedback in s if actor is stopped before reaching his position (for calculation of new position) + * A5_30_01 Alarm sensor, e.g. Eltako FSM60B water leak sensor + * A5_30_03 Alarm sensor, e.g. Eltako FSM60B water leak sensor + * D2_01_07 Simple electronic switch + * D2_01_12 Simple electronic switch with 2 channels, like NodOn In-Wall module + * D5_00_01 Door/Window Contact, e.g. Eltako FTK, FTKB + * F6_02_01 2-Button-Rocker + * F6_02_02 2-Button-Rocker + * F6_02_03 2-Button-Rocker, Status feedback from manual buttons on different actors, e.g. Eltako FT55, FSUD-230, FSVA-230V, FSB61NP-230V or Gira switches. + * F6_10_00 Mechanical Handle (value: 0(closed), 1(open), 2(tilted) + * F6_0G_03 Feedback of shutter actor (Eltako FSB14, FSB61, FSB71 - actor for Shutter) if reaching the endposition and if motor is active + +Eine vollständige Liste aller EEPs mit detallierten Informationen findet sich unter [EnOcean Alliance](http://www.enocean-alliance.org/eep/) + + +Steuer EEPs +----------- + +Die folgenden Sende EEPs werden vom Plugin aktuell unterstützt: + + * A5_38_08_01 Regular switch actor command (on/off) + * A5_38_08_02 Dimmer command with fix on off command (on: 100, off:0) + * A5_38_08_03 Dimmer command with specified dim level (0-100) + * A5_3F_7F Universal actuator command, e.g. blind control + * D2_01_07 Simple electronic switch + * D2_01_12 Simple electronic switch with 2 channels + + +Web Interface +============= + +Das enocean Plugin verfügt über ein Webinterface. + + +Aufruf des Webinterfaces +------------------------ + +Das Plugin kann aus dem Admin Interface aufgerufen werden. Dazu auf der Seite Plugins in der entsprechenden +Zeile das Icon in der Spalte **Web Interface** anklicken. + +Außerdem kann das Webinterface direkt über http://smarthome.local:8383/enocean aufgerufen werden. + +Das Webinterface zeigt oben rechts allgemeine Informationen, wie + + * die BaseID der verwendeten Hardware + * ob der Sendemodus aktiviert ist + * ob empfangene Enocean Nachrichten von unbekannten (nicht konfigurierten) Geräten in die Konsole geloggt werden sollen + * ob der UTE Anlernmodus aktiviert ist. + +Weiterhin können über Schaltflächen + + * der Sendemodus aktiviert und deaktiviert werden + * der UTE Anlernmodus aktiviert und deaktiviert werden + * das Logging von empfangenen Nachrichten unbekannten Enocean Geräte aktiviert und deaktiviert werden. + +Unter dem TAB `Übersicht` werden alle konfigurierten Enocean items angezeigt. + +Unter dem TAB 'Neu anlerenen' können neue Enocean Aktuatoren angelernt werden. Hierzu wird + + a) Der entsprechende Aktor/Stellglied in den Anlernmodus gebracht (siehe jeweilige Bedienungsanleitung) + b) Eine Transmit ID ausgewählt (TX ID). Enocean unterstützt bis zu 127 verschiedene IDs. + c) Als Hinweis bzw. Vorschlag wird die erste freie ID auf der linken Seite angezeigt. + c) Der Aktortyp ausgwählt. Im Plugin wird anhand des Typs das Lerntelegram ausgewählt. + d) Auf die Schaltfläche ``Anlernen`` klicken. Das Anlerntelegram wird gesendet und der Aktor sollte den Anlernvorgang quittieren (siehe jeweilige Bedienungsanleitung). + + + + +Beispiele +========= + +Beispiele für eine Item.yaml mit verschiedenen Enocean Sensoren und Aktoren: + +.. code-block:: yaml + + EnOcean_Item: + Outside_Temperature: + type: num + enocean_rx_id: 0180924D + enocean_rx_eep: A5_02_05 + enocean_rx_key: TMP + + Door: + enocean_rx_id: 01234567 + enocean_rx_eep: D5_00_01 + status: + type: bool + enocean_rx_key: STATUS + + FT55switch: + enocean_rx_id: 012345AA + enocean_rx_eep: F6_02_03 + up: + type: bool + enocean_rx_key: BO + down: + type: bool + enocean_rx_key: BI + + Brightness_Sensor: + name: brightness_sensor_east + remark: Eltako FAH60 + type: num + enocean_rx_id: 01A51DE6 + enocean_rx_eep: A5_06_01 + enocean_rx_key: BRI + visu_acl: rw + sqlite: 'yes' + + dimmer1: + remark: Eltako FDG14 - Dimmer + enocean_rx_id: 00112233 + enocean_rx_eep: A5_11_04 + light: + type: bool + enocean_rx_key: STAT + enocean_tx_eep: A5_38_08_02 + enocean_tx_id_offset: 1 + level: + type: num + enocean_rx_key: D + enocean_tx_eep: A5_38_08_03 + enocean_tx_id_offset: 1 + ref_level: 80 + dim_speed: 100 + block_dim_value: 'False' + + handle: + enocean_rx_id: 01234567 + enocean_rx_eep: F6_10_00 + status: + type: num + enocean_rx_key: STATUS + + actor1: + enocean_rx_id: FFAABBCC + enocean_rx_eep: A5_12_01 + power: + type: num + enocean_rx_key: VALUE + + actor1B: + remark: Eltako FSR61, FSR61NP, FSR61G, FSR61LN, FLC61NP - Switch for Ligths + enocean_rx_id: 1A794D3 + enocean_rx_eep: F6_02_03 + light: + type: bool + enocean_tx_eep: A5_38_08_01 + enocean_tx_id_offset: 1 + enocean_rx_key: B + block_switch: 'False' + cache: 'True' + enforce_updates: 'True' + visu_acl: rw + + actor_D2: + remark: Actor with VLD Command + enocean_rx_id: FFDB7381 + enocean_rx_eep: D2_01_07 + move: + type: bool + enocean_rx_key: STAT + enocean_tx_eep: D2_01_07 + enocean_tx_id_offset: 1 + # pulsewith-attribute removed use autotimer functionality instead + autotimer: 1 = 0 + + actorD2_01_12: + enocean_rx_id: 050A2FF4 + enocean_rx_eep: D2_01_12 + switch: + cache: 'on' + type: bool + enocean_rx_key: STAT_A + enocean_channel: A + enocean_tx_eep: D2_01_12 + enocean_tx_id_offset: 2 + + awning: + name: Eltako FSB14, FSB61, FSB71 + remark: actor for Shutter + type: str + enocean_rx_id: 1A869C3 + enocean_rx_eep: F6_0G_03 + enocean_rx_key: STATUS + move: + type: num + enocean_tx_eep: A5_3F_7F + enocean_tx_id_offset: 0 + enocean_rx_key: B + enocean_rtime: 60 + block_switch: 'False' + enforce_updates: 'True' + cache: 'True' + visu_acl: rw + + rocker: + enocean_rx_id: 0029894A + enocean_rx_eep: F6_02_01 + short_800ms_directly_to_knx: + type: bool + enocean_rx_key: AI + enocean_rocker_action: **toggle** + enocean_rocker_sequence: released **within** 0.8 + knx_dpt: 1 + knx_send: 3/0/60 + + long_800ms_directly_to_knx: + type: bool + enocean_rx_key: AI + enocean_rocker_action: toggle + enocean_rocker_sequence: released **after** 0.8 + knx_dpt: 1 + knx_send: 3/0/61 + + rocker_double_800ms_to_knx_send_1: + type: bool + enforce_updates: true + enocean_rx_key: AI + enocean_rocker_action: **set** + enocean_rocker_sequence: **released within 0.4, pressed within 0.4** + knx_dpt: 1 + knx_send: 3/0/62 + + brightness_sensor: + enocean_rx_id: 01234567 + enocean_rx_eep: A5_08_01 + lux: + type: num + enocean_rx_key: BRI + + movement: + type: bool + enocean_rx_key: MOV + + occupancy_sensor: + enocean_rx_id: 01234567 + enocean_rx_eep: A5_07_03 + lux: + type: num + enocean_rx_key: ILL + + movement: + type: bool + enocean_rx_key: PIR + + voltage: + type: bool + enocean_rx_key: SVC + + temperature_sensor: + enocean_rx_id: 01234567 + enocean_rx_eep: A5_04_02 + temperature: + type: num + enocean_rx_key: TMP + + humidity: + type: num + enocean_rx_key: HUM + + power_status: + type: num + enocean_rx_key: ENG + + sunblind: + name: Eltako FSB14, FSB61, FSB71 + remark: actor for Shutter + type: str + enocean_rx_id: 1A869C3 + enocean_rx_eep: F6_0G_03 + enocean_rx_key: STATUS + # runtime Range [0 - 255] s + enocean_rtime: 80 + Tgt_Position: + name: Eltako FSB14, FSB61, FSB71 + remark: Pos. 0...255 + type: num + enocean_rx_id: ..:. + enocean_rx_eep: ..:. + enforce_updates: 'True' + cache: 'True' + visu_acl: rw + Act_Position: + name: Eltako FSB14, FSB61, FSB71 + remark: Ist-Pos. 0...255 berechnet aus (letzer Pos. + Fahrzeit * 255/rtime) + type: num + enocean_rx_id: ..:. + enocean_rx_eep: ..:. + enocean_rx_key: POSITION + enforce_updates: 'True' + cache: 'True' + visu_acl: rw + eval: min(max(value, 0), 255) + on_update: + - EnOcean_Item.sunblind = 'stopped' + Run: + name: Eltako FSB14, FSB61, FSB71 + remark: Ansteuerbefehl 0x00, 0x01, 0x02 + type: num + enocean_rx_id: ..:. + enocean_rx_eep: ..:. + enocean_tx_eep: A5_3F_7F + enocean_tx_id_offset: 0 + enocean_rx_key: B + enocean_rtime: ..:. + # block actuator + block_switch: 'True' + enforce_updates: 'True' + cache: 'True' + visu_acl: rw + struct: uzsu.child + Movement: + name: Eltako FSB14, FSB61, FSB71 + remark: Wenn Rolladen gestoppt wurde steht hier die gefahrene Zeit in s und die Richtung + type: num + enocean_rx_id: ..:. + enocean_rx_eep: A5_0G_03 + enocean_rx_key: MOVE + cache: 'False' + enforce_updates: 'True' + eval: value * 255/int(sh.EnOcean_Item.sunblind.property.enocean_rtime) + on_update: + - EnOcean_Item.sunblind = 'stopped' + - EnOcean_Item.sunblind.Act_Position = EnOcean_Item.sunblind.Act_Position() + value + + RGBdimmer: + type: num + remark: Eltako FRGBW71L - RGB Dimmer + enocean_rx_id: 1A869C3 + enocean_rx_eep: A5_3F_7F + enocean_rx_key: DI_0 + red: + type: num + enocean_tx_eep: 07_3F_7F + enocean_tx_id_offset: 1 + enocean_rx_key: DI_0 + ref_level: 80 + dim_speed: 100 + color: red + green: + type: num + enocean_tx_eep: 07_3F_7F + enocean_tx_id_offset: 1 + enocean_rx_key: DI_1 + ref_level: 80 + dim_speed: 100 + color: green + blue: + type: num + enocean_tx_eep: 07_3F_7F + enocean_tx_id_offset: 1 + enocean_rx_key: DI_2 + ref_level: 80 + dim_speed: 100 + color: blue + white: + type: num + enocean_tx_eep: 07_3F_7F + enocean_tx_id_offset: 1 + enocean_rx_key: DI_3 + ref_level: 80 + dim_speed: 100 + color: white + water_sensor: + enocean_rx_id: 00000000 + enocean_rx_eep: A5_30_03 + + alarm: + type: bool + enocean_rx_key: ALARM + visu_acl: ro + + temperature: + type: num + enocean_rx_key: TEMP + visu_acl: ro + + + + diff --git a/enocean/webif/__init__.py b/enocean/webif/__init__.py new file mode 100755 index 000000000..2651c2f11 --- /dev/null +++ b/enocean/webif/__init__.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2020- +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.5 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import datetime +import time +import os +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + + @cherrypy.expose + def index(self, reload=None, action=None, item_id=None, item_path=None, device_id=None, device_offset=None, + time_orig=None, changed_orig=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + learn_triggered = False + + if action is not None: + if action == "toggle_tx_blocking": + self.plugin.toggle_block_external_out_messages() + elif action == "toggle_UTE": + self.plugin.toggle_UTE_mode(device_offset) + self.logger.warning(f"UTE mode triggered via webinterface (Offset: {device_offset})") + elif action == "toggle_log_unknown": + self.plugin.toggle_log_unknown_msg() + self.logger.info(f"Toogle state of log unknown messages triggered via webinterface") + elif action == "send_learn" and (device_id is not None) and not (device_id=="") and (device_offset is not None) and not(device_offset==""): + self.logger.warning(f"Learn telegram triggered via webinterface (ID:{device_id} Offset:{device_offset})") + ret = self.plugin.send_learn_protocol(int(device_offset), int(device_id)) + if ret == True: + learn_triggered = True + else: + self.logger.error("Unknown comman received via webinterface") + + tmpl = self.tplenv.get_template('index.html') + return tmpl.render(p=self.plugin, + items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path']), reverse=False), + tabcount=1, item_id=item_id, learn_triggered=learn_triggered, action='') + + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + # if dataSets are used, define them here + if dataSet == 'overview': + # get the new data from the plugin variable called _webdata + data = self.plugin._webdata + try: + data = json.dumps(data) + return data + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") + if dataSet is None: + # get the new data + data = {} + + # data['item'] = {} + # for i in self.plugin.items: + # data['item'][i]['value'] = self.plugin.getitemvalue(i) + # + # return it as json the the web page + # try: + # return json.dumps(data) + # except Exception as e: + # self.logger.error("get_data_html exception: {}".format(e)) + return {} diff --git a/executor/__init__.py b/executor/__init__.py index c617727d5..c4f70e5b8 100755 --- a/executor/__init__.py +++ b/executor/__init__.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 # vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab ######################################################################### -# Copyright 2019 Bernd Meiners Bernd.Meiners@mail.de +# Copyright 2019-2022 Bernd Meiners Bernd.Meiners@mail.de ######################################################################### # This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py # -# This is the executor plugin to run with SmartHomeNG version 1.4 and +# This is the executor plugin to run with SmartHomeNG version 1.9 and # upwards. # # SmartHomeNG is free software: you can redistribute it and/or modify @@ -23,18 +25,13 @@ # ######################################################################### +import os + from lib.module import Modules from lib.item import Items -from lib.model.smartplugin import * -import urllib -import time -import datetime -import random -import pprint -import json +from lib.model.smartplugin import SmartPlugin -# If a needed package is imported, which might be not installed in the Python environment, -# add it to a requirements.txt file within the plugin's directory +from .webif import WebInterface class Executor(SmartPlugin): @@ -43,32 +40,38 @@ class Executor(SmartPlugin): the update functions for the items """ - PLUGIN_VERSION = '1.0.4' + PLUGIN_VERSION = '1.1.1' def __init__(self, sh): """ Initalizes the plugin. The parameters describe for this method are pulled from the entry in plugin.conf. """ - from bin.smarthome import VERSION - if '.'.join(VERSION.split('.', 2)[:2]) <= '1.5': - self.logger = logging.getLogger(__name__) + # Call init code of parent class (SmartPlugin) + super().__init__() - # If an package import with try/except is done, handle an import error like this: + self._init_complete = False + # If an package import with try/except is done, handle an import error like this: self.logger.debug("init {}".format(__name__)) - self._init_complete = False - - # Exit if the required package(s) could not be imported - # if not REQUIRED_PACKAGE_IMPORTED: - # self.logger.error("Unable to import Python package ''") - # self._init_complete = False - # return + self._scripts = self.get_parameter_value('scripts') #default is *executor_scripts* + self._script_entries = self.get_parameter_value('script_entries') #default is 6 + self.logger.debug(f"{self._scripts=}, {self._script_entries=}") + try: + vardir = sh.get_vardir() + self.logger.debug(f"{vardir=}") + self.executor_scripts = os.path.join(vardir, self._scripts) + self.logger.debug(f"{self.executor_scripts=}") + os.makedirs(self.executor_scripts, exist_ok=True) + except Exception as e: + self.logger.warning(f"Exception {e}: could not access {self._scripts}, executor plugin will not be able to load or save scripts") + self._scripts = None + self.executor_scripts = None - # if plugin should not start without web interface + # no start without web interface if not self.init_webinterface(): - self._init_complete = False + self.logger.warning(f"could not init webinterface") return self.logger.debug("init done") @@ -78,7 +81,7 @@ def run(self): """ Run method for the plugin """ - self.logger.debug("Plugin '{}': run method called".format(self.get_fullname())) + self.logger.debug(f"Plugin '{self.get_fullname()}': run method called") self.alive = True def stop(self): @@ -86,7 +89,7 @@ def stop(self): Stop method for the plugin """ self.alive = False - self.logger.debug("Plugin '{}': stop method called".format(self.get_fullname())) + self.logger.debug(f"Plugin '{self.get_fullname()}': stop method called") def parse_item(self, item): pass @@ -116,7 +119,7 @@ def init_webinterface(self): import sys if not "SmartPluginWebIf" in list(sys.modules['lib.model.smartplugin'].__dict__): - self.logger.warning("Web interface needs SmartHomeNG v1.5 and up. Not initializing the web interface") + self.logger.warning("Web interface needs SmartHomeNG v1.9 and up. Not initializing the web interface") return False # set application configuration for cherrypy @@ -140,151 +143,3 @@ def init_webinterface(self): return True - -# ------------------------------------------ -# Webinterface of the plugin -# ------------------------------------------ - -import cherrypy -from jinja2 import Environment, FileSystemLoader - -import sys - -class PrintCapture: - def __init__(self): - self.data = [] - def write(self, s): - self.data.append(s) - def __enter__(self): - sys.stdout = self - return self - def __exit__(self, ext_type, exc_value, traceback): - sys.stdout = sys.__stdout__ - -class Stub(): - def __init__(self, *args, **kwargs): - print(args) - print(kwargs) - for k,v in kwargs.items(): - setattr(self, k, v) - -# e.g. logger = Stub(warning=print, info=print, debug=print) - - -class WebInterface(SmartPluginWebIf): - - def __init__(self, webif_dir, plugin): - """ - Initialization of instance of class WebInterface - - :param webif_dir: directory where the webinterface of the plugin resides - :param plugin: instance of the plugin - :type webif_dir: str - :type plugin: object - """ - self.logger = logging.getLogger(__name__) - self.webif_dir = webif_dir - self.plugin = plugin - self.itemsApi = Items.get_instance() - self.tplenv = self.init_template_environment() - - @cherrypy.expose - def index(self, reload=None): - """ - Build index.html for cherrypy - - Render the template and return the html file to be delivered to the browser - - :return: contents of the template after beeing rendered - """ - tmpl = self.tplenv.get_template('index.html') - # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) - return tmpl.render(p=self.plugin) - - """ - According to SmartHomeNG documentation the following modules are loaded for the - logic environment: - - sys - - os - - time - - datetime - - random - - ephem - - Queue - - subprocess - - The usage of sys and os is a potential risk for eval and exec as they can remove files in reach of SmartHomeNG user. - There should be however no need for ephem, Queue or subprocess - """ - - - @cherrypy.expose - def evaluate(self, eline, path, reload=None): - """ - evaluate expression in eline and return the result - - :return: result of the evaluation - """ - result = "" - - g = {} - l = { 'sh': self.plugin.get_sh() } - self.logger.debug("Got request to evaluate {} (raw) for item path {}".format(eline, path)) - eline = urllib.parse.unquote(eline) - if path != '': - try: - path = self.itemsApi.return_item(path) - eline = path.get_stringwithabsolutepathes(eline, 'sh.', '(') - eline = path.get_stringwithabsolutepathes(eline, 'sh.', '.property') - self.logger.debug("Got request to evaluate {} (unquoted)".format(eline)) - except Exception as e: - res = "Error '{}' while evaluating".format(e) - result = "{}".format(res) - return result - - try: - if eline: - res = eval(eline,g,l) - else: - res = "Nothing to do" - except Exception as e: - res = "Error '{}' while evaluating".format(e) - - #result = "{} returns {}".format(eline,res) - result = "{}".format(res) - return result - - @cherrypy.expose - def evaluatetext(self, eline, reload=None): - """ - evaluate a whole python block in eline - - :return: result of the evaluation - """ - result = "" - import json - import pprint - stub_logger = Stub(warning=print, info=print, debug=print, error=print) - - g = {} - l = { 'sh': self.plugin.get_sh(), - 'time': time, - 'datetime': datetime, - 'random': random, - 'json': json, - 'pprint': pprint, - 'logger': stub_logger, - 'logging': logging - } - self.logger.warning("Got request to evaluate {} (raw)".format(eline)) - eline = urllib.parse.unquote(eline) - self.logger.warning("Got request to evaluate {} (unquoted)".format(eline)) - with PrintCapture() as p: - try: - if eline: - exec(eline,g,l) - res = "" - except Exception as e: - res = "Error '{}' while evaluating".format(e) - - return ''.join(p.data) + res diff --git a/executor/_pv_1_0_4/__init__.py b/executor/_pv_1_0_4/__init__.py new file mode 100755 index 000000000..c617727d5 --- /dev/null +++ b/executor/_pv_1_0_4/__init__.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2019 Bernd Meiners Bernd.Meiners@mail.de +######################################################################### +# This file is part of SmartHomeNG. +# +# This is the executor plugin to run with SmartHomeNG version 1.4 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +from lib.module import Modules +from lib.item import Items +from lib.model.smartplugin import * +import urllib +import time +import datetime +import random +import pprint +import json + +# If a needed package is imported, which might be not installed in the Python environment, +# add it to a requirements.txt file within the plugin's directory + + +class Executor(SmartPlugin): + """ + Main class of the Plugin. Does all plugin specific stuff and provides + the update functions for the items + """ + + PLUGIN_VERSION = '1.0.4' + + def __init__(self, sh): + """ + Initalizes the plugin. The parameters describe for this method are pulled from the entry in plugin.conf. + + """ + from bin.smarthome import VERSION + if '.'.join(VERSION.split('.', 2)[:2]) <= '1.5': + self.logger = logging.getLogger(__name__) + + # If an package import with try/except is done, handle an import error like this: + + self.logger.debug("init {}".format(__name__)) + + self._init_complete = False + + # Exit if the required package(s) could not be imported + # if not REQUIRED_PACKAGE_IMPORTED: + # self.logger.error("Unable to import Python package ''") + # self._init_complete = False + # return + + # if plugin should not start without web interface + if not self.init_webinterface(): + self._init_complete = False + return + + self.logger.debug("init done") + self._init_complete = True + + def run(self): + """ + Run method for the plugin + """ + self.logger.debug("Plugin '{}': run method called".format(self.get_fullname())) + self.alive = True + + def stop(self): + """ + Stop method for the plugin + """ + self.alive = False + self.logger.debug("Plugin '{}': stop method called".format(self.get_fullname())) + + def parse_item(self, item): + pass + + def parse_logic(self, logic): + pass + + def update_item(self, item, caller=None, source=None, dest=None): + pass + + def poll_device(self): + pass + + def init_webinterface(self): + """" + Initialize the web interface for this plugin + + This method is only needed if the plugin is implementing a web interface + """ + try: + self.mod_http = Modules.get_instance().get_module('http') + except: + self.mod_http = None + if self.mod_http == None: + self.logger.error("Not initializing the web interface") + return False + + import sys + if not "SmartPluginWebIf" in list(sys.modules['lib.model.smartplugin'].__dict__): + self.logger.warning("Web interface needs SmartHomeNG v1.5 and up. Not initializing the web interface") + return False + + # set application configuration for cherrypy + webif_dir = self.path_join(self.get_plugin_dir(), 'webif') + config = { + '/': { + 'tools.staticdir.root': webif_dir, + }, + '/static': { + 'tools.staticdir.on': True, + 'tools.staticdir.dir': 'static' + } + } + + # Register the web interface as a cherrypy app + self.mod_http.register_webif(WebInterface(webif_dir, self), + self.get_shortname(), + config, + self.get_classname(), self.get_instance_name(), + description='') + + return True + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +from jinja2 import Environment, FileSystemLoader + +import sys + +class PrintCapture: + def __init__(self): + self.data = [] + def write(self, s): + self.data.append(s) + def __enter__(self): + sys.stdout = self + return self + def __exit__(self, ext_type, exc_value, traceback): + sys.stdout = sys.__stdout__ + +class Stub(): + def __init__(self, *args, **kwargs): + print(args) + print(kwargs) + for k,v in kwargs.items(): + setattr(self, k, v) + +# e.g. logger = Stub(warning=print, info=print, debug=print) + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = logging.getLogger(__name__) + self.webif_dir = webif_dir + self.plugin = plugin + self.itemsApi = Items.get_instance() + self.tplenv = self.init_template_environment() + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + tmpl = self.tplenv.get_template('index.html') + # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin) + + """ + According to SmartHomeNG documentation the following modules are loaded for the + logic environment: + - sys + - os + - time + - datetime + - random + - ephem + - Queue + - subprocess + + The usage of sys and os is a potential risk for eval and exec as they can remove files in reach of SmartHomeNG user. + There should be however no need for ephem, Queue or subprocess + """ + + + @cherrypy.expose + def evaluate(self, eline, path, reload=None): + """ + evaluate expression in eline and return the result + + :return: result of the evaluation + """ + result = "" + + g = {} + l = { 'sh': self.plugin.get_sh() } + self.logger.debug("Got request to evaluate {} (raw) for item path {}".format(eline, path)) + eline = urllib.parse.unquote(eline) + if path != '': + try: + path = self.itemsApi.return_item(path) + eline = path.get_stringwithabsolutepathes(eline, 'sh.', '(') + eline = path.get_stringwithabsolutepathes(eline, 'sh.', '.property') + self.logger.debug("Got request to evaluate {} (unquoted)".format(eline)) + except Exception as e: + res = "Error '{}' while evaluating".format(e) + result = "{}".format(res) + return result + + try: + if eline: + res = eval(eline,g,l) + else: + res = "Nothing to do" + except Exception as e: + res = "Error '{}' while evaluating".format(e) + + #result = "{} returns {}".format(eline,res) + result = "{}".format(res) + return result + + @cherrypy.expose + def evaluatetext(self, eline, reload=None): + """ + evaluate a whole python block in eline + + :return: result of the evaluation + """ + result = "" + import json + import pprint + stub_logger = Stub(warning=print, info=print, debug=print, error=print) + + g = {} + l = { 'sh': self.plugin.get_sh(), + 'time': time, + 'datetime': datetime, + 'random': random, + 'json': json, + 'pprint': pprint, + 'logger': stub_logger, + 'logging': logging + } + self.logger.warning("Got request to evaluate {} (raw)".format(eline)) + eline = urllib.parse.unquote(eline) + self.logger.warning("Got request to evaluate {} (unquoted)".format(eline)) + with PrintCapture() as p: + try: + if eline: + exec(eline,g,l) + res = "" + except Exception as e: + res = "Error '{}' while evaluating".format(e) + + return ''.join(p.data) + res diff --git a/executor/_pv_1_0_4/locale.yaml b/executor/_pv_1_0_4/locale.yaml new file mode 100755 index 000000000..743ef959c --- /dev/null +++ b/executor/_pv_1_0_4/locale.yaml @@ -0,0 +1,10 @@ +# translations for the web interface +plugin_translations: + # Translations for the plugin specially for the web interface + 'Zeile zum Ausführen in Python eingeben:': {'de': '=', 'en': 'Command to execute in python'} + 'Ausführen!': {'de': '=', 'en': 'Execute!'} + 'Ergebnis': {'de': '=', 'en': 'Result'} + 'Python Code': {'de': '=', 'en': '='} + 'Evalausdruck eingeben:': {'de': '=', 'en': 'Eval term to execute:'} + 'Eval Ausdruck': {'de': '=', 'en': 'Eval term'} + 'Itempfad angeben (optional zum Testen relativer Itemangaben):': {'de': '=', 'en': 'Item path (optional to check relative item evaluation)'} diff --git a/executor/_pv_1_0_4/plugin.yaml b/executor/_pv_1_0_4/plugin.yaml new file mode 100755 index 000000000..509ecc6a2 --- /dev/null +++ b/executor/_pv_1_0_4/plugin.yaml @@ -0,0 +1,35 @@ +# Metadata for the plugin +plugin: + # Global plugin attributes + type: system # plugin type (gateway, interface, protocol, system, web) + description: + de: 'Ausführen von Python Statements im Kontext von SmartHomeNG v1.5 und höher' + en: 'Execute Python statements in the context of SmartHomeNG v1.5 and up' + maintainer: bmxp + tester: nobody # Who tests this plugin? + state: ready # change to ready when done with development + keywords: Python eval exec code test + documentation: https://www.smarthomeng.de/user/plugins/executor/user_doc.html + support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1425152-support-thread-plugin-executor + + version: 1.0.4 # Plugin version + sh_minversion: 1.4 # minimum shNG version to use this plugin +# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) + multi_instance: False # plugin supports multi instance + restartable: True + classname: Executor # class containing the plugin + +parameters: NONE + # Definition of parameters to be configured in etc/plugin.yaml (enter 'parameters: NONE', if section should be empty) + +item_attributes: NONE + # Definition of item attributes defined by this plugin (enter 'item_attributes: NONE', if section should be empty) + +item_structs: NONE + # Definition of item-structure templates for this plugin (enter 'item_structs: NONE', if section should be empty) + +plugin_functions: NONE + # Definition of plugin functions defined by this plugin (enter 'plugin_functions: NONE', if section should be empty) + +logic_parameters: NONE + # Definition of logic parameters defined by this plugin (enter 'logic_parameters: NONE', if section should be empty) diff --git a/executor/_pv_1_0_4/user_doc.rst b/executor/_pv_1_0_4/user_doc.rst new file mode 100755 index 000000000..b70aa29e2 --- /dev/null +++ b/executor/_pv_1_0_4/user_doc.rst @@ -0,0 +1,97 @@ +.. index:: Plugins; executor +.. index:: executor + +executor +######## + +Einführung +========== + +Das executor plugin kann genutzt werden, um **eval Ausdrücke** und **Python Code** (z.B. für **Logiken**) zu testen. + +.. important:: + + Seien Sie sich bewusst, dass die Aktivierung dieses Plugins ein Sicherheitsrisiko darstellen könnte. Wenn andere Personen Zugriff auf die Web-Schnittstelle erhalten, + kann man das ganze SmartHomeNG-Biest kontrollieren. Seien Sie also vorsichtig!!! + + +Konfiguration +============= + +Das Aktivieren des Plugins ist ausreichend. + +Beispiele für Eval +================== + +.. code-block:: python + + 12 if 23 % 3 == 5 else None + +Würde im Ergebnis **None** resultieren. + +.. code-block:: python + + sh..child() + 4 + +Da sich im Evalausdruck ein relatives Item befindet, ist es notwendig im Feld "Itempfad" das Item anzugeben, dem das im Code angegebene Unteritem zugewiesen ist. + +Beispiel Python Code +==================== + +Sowohl``logger`` als auch ``print`` funktionieren für die Ausgabe von Ergebnissen. Die Idee ist, dass Logiken mehr oder weniger 1:1 kopiert und getestet werden können. + +Loggertest + +.. code-block:: python + + logger.warning("Eine Warnung") + logger.info("Eine Info") + logger.debug("Eine Debugmeldung") + logger.error("Eine Debugmeldung") + + +Datenserien für ein Item ausgeben + +Abfragen von Daten aus dem database plugin für ein spezifisches Item: + +.. code-block:: python + + import json + + def myconverter(o): + import datetime + if isinstance(o, datetime.datetime): + return o.__str__() + data = sh..series('max','1d','now') + pretty = json.dumps(data, default = myconverter, indent = 2, separators=(',', ': ')) + print(pretty) + + +würde in folgendem Ergebnis münden. + +.. code-block:: json + + { + "sid": "ArbeitszimmerOG.Raumtemperatur|max|1d|now|100", + "cmd": "series", + "update": "2019-11-09 17:54:22.205668+01:00", + "params": { + "sid": "ArbeitszimmerOG.Raumtemperatur|max|1d|now|100", + "update": true, + "start": 1573317598203, + "end": "now", + "func": "max", + "item": "ArbeitszimmerOG.Raumtemperatur", + "step": 864000 + }, + "series": [ + [ + 1573231198203, + 21.0 + ], + [ + 1573232535421, + 21.2 + ] + ] + } diff --git a/executor/user_doc_en.rst b/executor/_pv_1_0_4/user_doc_en.rst similarity index 100% rename from executor/user_doc_en.rst rename to executor/_pv_1_0_4/user_doc_en.rst diff --git a/executor/_pv_1_0_4/webif/static/img/readme.txt b/executor/_pv_1_0_4/webif/static/img/readme.txt new file mode 100755 index 000000000..1a7c55eef --- /dev/null +++ b/executor/_pv_1_0_4/webif/static/img/readme.txt @@ -0,0 +1,6 @@ +This directory is for storing images that are used by the web interface. + +If you want to have your own logo on the top of the web interface, store it here and name it plugin_logo.. + +Extension can be png, svg or jpg + diff --git a/executor/_pv_1_0_4/webif/templates/index.html b/executor/_pv_1_0_4/webif/templates/index.html new file mode 100755 index 000000000..bd32e36be --- /dev/null +++ b/executor/_pv_1_0_4/webif/templates/index.html @@ -0,0 +1,181 @@ +{% extends "base_plugin.html" %} + +{% set logo_frame = false %} + +{% block headtable %} +{% endblock headtable %} + + + + +{% set tabcount = 2 %} + + +{% if item_count==0 %} + {% set start_tab = 1 %} +{% endif %} + + + + +{% set tab1title = "" ~ _('Eval Ausdruck') ~ "" %} +{% block bodytab1 %} + + + + + +
+
+

+
+ +

+
+ +

+ +
+ +
+
+{% endblock bodytab1 %} + +{% set tab2title = "" ~ _('Python Code') ~ "" %} +{% block bodytab2 %} + + + + +
+
+
+ +
+
+ +
+
+{% endblock bodytab2 %} diff --git a/executor/plugin.yaml b/executor/plugin.yaml index 509ecc6a2..3933b87be 100755 --- a/executor/plugin.yaml +++ b/executor/plugin.yaml @@ -12,15 +12,30 @@ plugin: documentation: https://www.smarthomeng.de/user/plugins/executor/user_doc.html support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1425152-support-thread-plugin-executor - version: 1.0.4 # Plugin version - sh_minversion: 1.4 # minimum shNG version to use this plugin -# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) + version: 1.1.1 # Plugin version + sh_minversion: 1.9 # minimum shNG version to use this plugin + #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) + py_minversion: 3.8 # minimum Python version to use for this plugin, use f-strings for debug + #py_maxversion: # maximum Python version to use for this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance restartable: True classname: Executor # class containing the plugin -parameters: NONE - # Definition of parameters to be configured in etc/plugin.yaml (enter 'parameters: NONE', if section should be empty) +parameters: + scripts: + type: str + default: executor_scripts + description: + de: 'relative Pfadangabe unterhalb *var* wo im Bedarfsfall die Skripte für das Executor Plugin abgelegt werden' + en: 'relative path below *var* to script files for executor plugin if needed' + script_entries: + type: int + default: 6 + valid_min: 0 + valid_max: 50 + description: + de: 'Maximale Anzahl der Zeilen in der Listbox für Skripte. Bei Angabe von 0 wird die Listbox zur Dropdown Liste.' + en: 'Maximum number of lines in listbox für scripts. If 0 is given the listbox will turn into a dropdown list.' item_attributes: NONE # Definition of item attributes defined by this plugin (enter 'item_attributes: NONE', if section should be empty) diff --git a/executor/user_doc.rst b/executor/user_doc.rst index b70aa29e2..7c749a259 100755 --- a/executor/user_doc.rst +++ b/executor/user_doc.rst @@ -1,44 +1,59 @@ .. index:: Plugins; executor .. index:: executor +======== executor -######## +======== + + +.. image:: webif/static/img/plugin_logo.svg + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left Einführung -========== +~~~~~~~~~~ -Das executor plugin kann genutzt werden, um **eval Ausdrücke** und **Python Code** (z.B. für **Logiken**) zu testen. +Das executor plugin kann genutzt werden, um **Python Code** (z.B. für **Logiken**) und **eval Ausdrücke** zu testen. .. important:: - Seien Sie sich bewusst, dass die Aktivierung dieses Plugins ein Sicherheitsrisiko darstellen könnte. Wenn andere Personen Zugriff auf die Web-Schnittstelle erhalten, + Seien Sie sich bewusst, dass die Aktivierung dieses Plugins ein Sicherheitsrisiko darstellen könnte. + Wenn andere Personen Zugriff auf die Web-Schnittstelle erhalten, kann man das ganze SmartHomeNG-Biest kontrollieren. Seien Sie also vorsichtig!!! Konfiguration ============= -Das Aktivieren des Plugins ist ausreichend. +Das Aktivieren des Plugins ist ausreichend. Optional kann noch ein Verzeichnis für Skripte konfiguriert werden +über das Attribut ``executor_scripts`` in der ``plugin.yaml``. +Damit wird dem Plugin eine relative Pfadangabe unterhalb *var* angegeben wo Skripte für das Executor Plugin abgelegt werden. -Beispiele für Eval -================== +Webinterface +============ -.. code-block:: python +Im Webinterface findet sich eine Listbox mit den auf dem Rechner gespeicherten Skripten. +Um das Skript in den Editor zu laden entweder ein Skript in der Liste einfach anklicken und auf *aus Datei laden* klicken oder +direkt in der Liste einen Doppelklick auf die gewünschte Datei ausführen. - 12 if 23 % 3 == 5 else None +Der Dateiname wird entsprechend der gewählten Datei gesetzt. Mit Klick auf *aktuellen Code speichern* wird der Code im konfigurierten +Skript Verzeichnis unter dem aktuell in der Eingabebox vorgegebenem Dateinamen abgespeichert. -Würde im Ergebnis **None** resultieren. +Mit einem Klick auf *Code ausführen!* oder der Kombination Ctrl+Return wird der Code an SmartHomeNG gesendet und ausgeführt. +Das kann gerade bei Datenbank Abfragen recht lange dauern. Es kann keine Rückmeldung von SmartHomeNG abgefragt werden wie weit der Code derzeit ist. +Das Ergebnis wird unten angezeigt. Solange kein Ergebnis vorliegt, steht im Ergebniskasten **... processing ...** -.. code-block:: python - - sh..child() + 4 - -Da sich im Evalausdruck ein relatives Item befindet, ist es notwendig im Feld "Itempfad" das Item anzugeben, dem das im Code angegebene Unteritem zugewiesen ist. +Mit einem Klick auf Datei löschen wird versucht die unter Dateiname angezeigte Datei ohne Rückfrage zu löschen. +Anschliessend wird die Liste der Skripte aktualisiert. Beispiel Python Code ==================== -Sowohl``logger`` als auch ``print`` funktionieren für die Ausgabe von Ergebnissen. Die Idee ist, dass Logiken mehr oder weniger 1:1 kopiert und getestet werden können. +Sowohl ``logger`` als auch ``print`` funktionieren für die Ausgabe von Ergebnissen. +Die Idee ist, dass Logiken mehr oder weniger 1:1 kopiert und getestet werden können. Loggertest @@ -67,7 +82,7 @@ Abfragen von Daten aus dem database plugin für ein spezifisches Item: print(pretty) -würde in folgendem Ergebnis münden. +würde in folgendem Ergebnis münden: .. code-block:: json @@ -95,3 +110,5 @@ würde in folgendem Ergebnis münden. ] ] } + +Damit die Nutzung diff --git a/executor/webif/__init__.py b/executor/webif/__init__.py new file mode 100755 index 000000000..a74caa780 --- /dev/null +++ b/executor/webif/__init__.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2019-2022 Bernd Meiners Bernd.Meiners@mail.de +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# This is the executor plugin to run with SmartHomeNG version 1.9 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + + +import urllib +import time +import datetime +import random +import pprint +import json +import logging +import os + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf +from lib.model.smartplugin import SmartPlugin + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + +import sys + +class PrintCapture: + """this class overwrites stdout and stderr temporarily to capture output""" + def __init__(self): + self.data = [] + def write(self, s): + self.data.append(s) + def __enter__(self): + sys.stdout = self + sys.stderr = self + return self + def __exit__(self, ext_type, exc_value, traceback): + sys.stdout = sys.__stdout__ + sys.stderr = sys.__stderr__ + +class Stub(): + def __init__(self, *args, **kwargs): + print(args) + print(kwargs) + for k,v in kwargs.items(): + setattr(self, k, v) + +# e.g. logger = Stub(warning=print, info=print, debug=print) + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + tmpl = self.tplenv.get_template('index.html') + # Setting pagelength (max. number of table entries per page) for web interface + try: + pagelength = self.plugin.webif_pagelength + except Exception: + pagelength = 100 + # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, + items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path'])), + item_count=0) + + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + if dataSet is None: + # get the new data + data = {} + + # data['item'] = {} + # for i in self.plugin.items: + # data['item'][i]['value'] = self.plugin.getitemvalue(i) + # + # return it as json the the web page + # try: + # return json.dumps(data) + # except Exception as e: + # self.logger.error("get_data_html exception: {}".format(e)) + return {} + + + """ + According to SmartHomeNG documentation the following modules are loaded for the + logic environment: + - sys + - os + - time + - datetime + - random + - ephem + - Queue + - subprocess + + The usage of sys and os is a potential risk for eval and exec as they can remove files in reach of SmartHomeNG user. + There should be however no need for ephem, Queue or subprocess + """ + + @cherrypy.expose + def eval_statement(self, eline, path, reload=None): + """ + evaluate expression in eline and return the result + + :return: result of the evaluation + """ + result = "" + + g = {} + l = { 'sh': self.plugin.get_sh() } + self.logger.debug(f"eval {eline} (raw) for item path {path}") + eline = urllib.parse.unquote(eline) + if path != '': + try: + path = self.items.return_item(path) + eline = path.get_stringwithabsolutepathes(eline, 'sh.', '(') + eline = path.get_stringwithabsolutepathes(eline, 'sh.', '.property') + self.logger.debug(f"eval {eline} (unquoted) for item path {path}") + except Exception as e: + res = f"Error '{e}' while evaluating" + result = f"{res}" + return result + + try: + if eline: + res = eval(eline,g,l) + else: + res = "Nothing to do" + except Exception as e: + res = f"Error '{e}' while evaluating" + + result = f"{res}" + self.logger.debug(f"{result=}") + return result + + @cherrypy.expose + def exec_code(self, eline, reload=None): + """ + evaluate a whole python block in eline + + :return: result of the evaluation + """ + result = "" + stub_logger = Stub(warning=print, info=print, debug=print, error=print) + + g = {} + l = { 'sh': self.plugin.get_sh(), + 'time': time, + 'datetime': datetime, + 'random': random, + 'json': json, + 'pprint': pprint, + 'logger': stub_logger, + 'logging': logging + } + self.logger.debug(f"Got request to evaluate {eline} (raw)") + eline = urllib.parse.unquote(eline) + self.logger.debug(f"Got request to evaluate {eline} (unquoted)") + with PrintCapture() as p: + try: + if eline: + exec(eline,g,l) + res = "" + except Exception as e: + res = f"Error '{e}' while evaluating" + + result = ''.join(p.data) + res + self.logger.debug(f"{result=}") + return result + + @cherrypy.expose + def get_code(self, filename=''): + """loads and returns the given filename from the defined script path""" + self.logger.debug(f"get_code called with {filename=}") + try: + if self.plugin.executor_scripts is not None and filename != '': + filepath = os.path.join(self.plugin.executor_scripts,filename) + self.logger.debug(f"{filepath=}") + code_file = open(filepath) + data = code_file.read() + code_file.close() + return data + except: + self.logger.error(f"{filepath} could not be read") + return f"### {filename} could not be read ###" + + @cherrypy.expose + def save_code(self, filename='', code=''): + """save the given code at filename from the defined script path""" + self.logger.debug(f"save_code called with {filename=}") + try: + if self.plugin.executor_scripts is not None and filename != '' and code != '': + if '/' in filename or '\\' in filename or '..' in filename: + raise ValueError("Special Characters not allowed in filename") + if (filename[-3:] != '.py'): + filename += '.py' + filepath = os.path.join(self.plugin.executor_scripts,filename) + self.logger.debug(f"{filepath=}") + with open(filepath, "w") as code_file: + code_file.write(code) + return f"{filename} was saved" + except Exception as e: + self.logger.error(f"{filepath} could not be saved, {e}") + return f"{filename} could not be saved" + + @cherrypy.expose + def delete_file(self, filename=''): + """deletes the file with given filename from the defined script path""" + self.logger.debug(f"delete_file called with {filename=}") + try: + if self.plugin.executor_scripts is not None and filename != '': + filepath = os.path.join(self.plugin.executor_scripts,filename) + if os.path.exists(filepath) and os.path.isfile(filepath): + os.remove(filepath) + self.logger.debug(f"{filepath} successfully deleted") + return f"{filepath} successfully deleted" + else: + self.logger.debug(f"{filepath} was not deleted") + except Exception as e: + self.logger.error(f"{e}: {filepath} could not be deleted") + return f"### {filename} could not be deleted ###" + + + @cherrypy.expose + def get_filelist(self): + """returns all filenames from the defined script path with suffix ``.py``""" + + if self.plugin.executor_scripts is not None: + subdir = self.plugin.executor_scripts + self.logger.debug(f"list files in {subdir}") + files = os.listdir(subdir) + files = [f for f in files if os.path.isfile(os.path.join(subdir,f))] + files = [f for f in files if f.endswith(".py")] + files = '\n'.join(f for f in files) + self.logger.debug(f"{files=}\n\n") + return files + + return '' + + @cherrypy.expose + def get_autocomplete(self): + _sh = self.plugin.get_sh() + plugins = _sh.plugins.get_instance() + plugin_list = [] + for x in plugins.return_plugins(): + if isinstance(x, SmartPlugin): + plugin_config_name = x.get_configname() + if x.metadata is not None: + api = x.metadata.get_plugin_function_defstrings(with_type=True, with_default=True) + if api is not None: + for function in api: + plugin_list.append("sh."+plugin_config_name + "." + function) + + + myItems = _sh.return_items() + itemList = [] + for item in myItems: + itemList.append("sh."+str(item)+"()") + retValue = {'items':itemList,'plugins':plugin_list} + return (json.dumps(retValue)) \ No newline at end of file diff --git a/executor/webif/static/img/plugin_logo.svg b/executor/webif/static/img/plugin_logo.svg new file mode 100755 index 000000000..467b07b26 --- /dev/null +++ b/executor/webif/static/img/plugin_logo.svg @@ -0,0 +1,265 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/executor/webif/templates/index.html b/executor/webif/templates/index.html index bd32e36be..20bc2ca95 100755 --- a/executor/webif/templates/index.html +++ b/executor/webif/templates/index.html @@ -1,130 +1,206 @@ -{% extends "base_plugin.html" %} +{% extends "base.html" %} {% set logo_frame = false %} -{% block headtable %} -{% endblock headtable %} +{% block pluginscripts %} + +{% endblock pluginscripts %} - + - +// ************************************************************************ +// registerAutocompleteHelper - seen at Web-Interface of logics from shNG +// ************************************************************************ +function registerAutocompleteHelper(name, curDict) { + CodeMirror.registerHelper('hint', name, function(editor) { + cur = editor.getCursor(); + curLine = editor.getLine(cur.line); + var start = cur.ch, + end = start; + console.log('Autocomplete called - autocompleteHint') + var charexp = /[\w\.$]+/; + while (end < curLine.length && charexp.test(curLine.charAt(end))) ++end; + while (start && charexp.test(curLine.charAt(start - 1))) --start; + var curWord = start != end && curLine.slice(start, end); + if (curWord.length > 1) { + curWord = curWord.trim(); + } -
-
-

-
- -

-
- -

- -
- -
-
-{% endblock bodytab1 %} - -{% set tab2title = "" ~ _('Python Code') ~ "" %} -{% block bodytab2 %} + var regex = new RegExp('^' + curWord, 'i'); - -
-
-
- -
-
- -
-
-{% endblock bodytab2 %} +
+
+
+
+ +
+ +
+
+
+
{{ _('Plugin') }} : {{ p.get_shortname() }} v{{ p.get_version() }}
+ {% if p.get_instance_name() != '' %} +
{{ _('Instanz') }}: {{ p.get_instance_name() }}
+ {% else %} +
+ {% endif %} +
{{ _('Plugin') }}     : {% if p.alive %}{{ _('Aktiv') }}{% else %}{{ _('Gestoppt') }}{% endif %}
+
+
+ + + +
+
+ + +
+ + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{%- endblock content %} diff --git a/gpio/README.md b/gpio/README.md deleted file mode 100755 index 0491dfc62..000000000 --- a/gpio/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# GPIO - -## Configuration - -Information can be found at the [Configuration Documentation](https://www.smarthomeng.de/user/plugins_doc/config/gpio.html) - -## Changelog -1.0 -- initial release - -1.0.1 -- Changed event detection from constant polling to GPIO.add_event_detect - -1.4.2 -- added parameter support for GPIO pull-up/pull-down configuration (global / per - item) -- changed startup code to prevent unwanted output changes -- cleaned up code, removed unnecessary parts - diff --git a/gpio/__init__.py b/gpio/__init__.py index 635fe53dc..2444343ef 100755 --- a/gpio/__init__.py +++ b/gpio/__init__.py @@ -35,7 +35,7 @@ class GPIO(SmartPlugin, Utils): Main class of the plugin. ''' - PLUGIN_VERSION = '1.5.2' + PLUGIN_VERSION = '1.5.4' ALLOW_MULTIINSTANCE = False def __init__(self, sh): diff --git a/gpio/assets/webif-gpio.png b/gpio/assets/webif-gpio.png new file mode 100755 index 000000000..5ae4060dc Binary files /dev/null and b/gpio/assets/webif-gpio.png differ diff --git a/gpio/plugin.yaml b/gpio/plugin.yaml index b99b2572e..a222fe667 100755 --- a/gpio/plugin.yaml +++ b/gpio/plugin.yaml @@ -6,7 +6,7 @@ plugin: de: 'GPIO-Unterstützung für Rasberry Pi, **seit SmartHomeNG v1.3**' en: 'GPIO support for Raspberry Pi, **since SmarthomeNG v1.3**' description_long: - de: 'GPIO-Unterstützung für den Rasberry Pi.\n + de: 'GPIO-Unterstützung für den Raspberry Pi.\n Dieses Plugin unterstützt über das RPi.GPIO-Modul das Einbinden von externen Sensoren und Aktoren, die direkt am Raspberry Pi angeschlossen werden. Damit ist es beispielsweise möglich, den Zustand von Reedkontakten einzulesen oder LEDs zu aktivieren und beispielsweise eine @@ -24,12 +24,13 @@ plugin: maintainer: onkelandy tester: cmalo, ohinckel, morg state: ready + support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1842450-support-thread-f%C3%BCr-das-gpio-plugin requirements: de: 'RPi.GPIO Python Modul' en: 'RPi.GPIO python module' keywords: iot gpio raspberrypi - version: 1.5.2 # Plugin version + version: 1.5.4 # Plugin version sh_minversion: 1.4 # minimum shNG version to use this plugin multi_instance: false # plugin supports multi instance restartable: unknown @@ -93,6 +94,7 @@ parameters: Define the global preset for pullup/pulldown settings. This can be overridden individually by the corresponding item option. ' + item_attributes: # Definition of item attributes defined by this plugin gpio_in: diff --git a/gpio/user_doc.rst b/gpio/user_doc.rst new file mode 100755 index 000000000..b04e129fc --- /dev/null +++ b/gpio/user_doc.rst @@ -0,0 +1,40 @@ +.. index:: Plugins; gpio +.. index:: gpio + +==== +gpio +==== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 1000px + :height: 472px + :scale: 30 % + :align: left + +Konfiguration +============= + +Die Informationen zur Konfiguration des Plugins sind unter :doc:`/user/plugins_doc/config/gpio` beschrieben. + +Beschreibung +============ + +GPIO-Unterstützung für den Raspberry Pi. Dieses Plugin unterstützt über das RPi.GPIO-Modul das Einbinden von externen Sensoren und Aktoren, die direkt am Raspberry Pi angeschlossen werden. Damit ist es beispielsweise möglich, den Zustand von Reedkontakten einzulesen oder LEDs zu aktivieren. + + +Web Interface +============= + +Das Plugin Webinterface kann aus dem Admin Interface aufgerufen werden. Dazu auf der Seite Plugins in der entsprechenden +Zeile das Icon in der Spalte **Web Interface** anklicken. + +Darunter gibt es für Ein- und Ausgänge je einen Tab mit Informationen zu den Items, die das GPIO Plugin +implementiert haben, den Pin, Wert und die Initialisierungszeit (nur bei IN Pins). + +.. image:: assets/webif-gpio.png + :class: screenshot + :width: 2022px + :height: 984px + :scale: 40 % + :align: center diff --git a/gpio/webif/__init__.py b/gpio/webif/__init__.py index 93feaad06..356e02a23 100755 --- a/gpio/webif/__init__.py +++ b/gpio/webif/__init__.py @@ -70,11 +70,12 @@ def index(self, action=None, item_id=None, item_path=None, reload=None): :return: contents of the template after beeing rendered ''' - # item = self.plugin.get_sh().return_item(item_path) + pagelength = self.plugin.get_parameter_value('webif_pagelength') tmpl = self.tplenv.get_template('index.html') # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, language=self.plugin._sh.get_defaultlanguage(), now=self.plugin.shtime.now()) @cherrypy.expose diff --git a/gpio/webif/templates/index.html b/gpio/webif/templates/index.html index e320daf81..ecf7ac3d6 100755 --- a/gpio/webif/templates/index.html +++ b/gpio/webif/templates/index.html @@ -15,10 +15,27 @@ + + +
+ + +{% endmacro %} + + +/********************************************* +* +* Widget for the alerts +* +*********************************************/ + +{% macro alerts(id, item,_backend ) %} + +{% import "basic.html" as basic %} +
+{% endmacro %} + + +/********************************************* +* +* Widget for the params +* +*********************************************/ + +{% macro params(id, item,_backend ) %} +
+{% endmacro %} + +/********************************************* +* +* Widget for the garden-map +* +*********************************************/ +{% macro garden_map(id, item,_backend) %} + +{% endmacro %} + + + + +/********************************************* +* +* Widget for the Indego-Symbols +* +*********************************************/ +/** +* @param unique id for this widget +* @param one or more item(s). More items in array form: [ item1 , item2 ] +* @param the pic, shown when item has value val +* @param value (default 1) +* @param the mode, 'or', 'and' (default 'or') +* @param the text, shown above the picture + +*/ + +{% macro symbol(id, items, pic, val, mode,text, ignore_text) %} + {% import "basic.html" as basic %} +
+ {% if not text is empty %}{{ text }}

{% endif %} + +
+{% endmacro %} + + + +/********************************************* +* +* Widget for the SmartMowCalendar +* +*********************************************/ +{% macro smartmow_calendar(id, item,_backend ) %} + +{% import "basic.html" as basic %} + +{% endmacro %} + + +/********************************************* +* +* Widget for the Mow Calendar +* +*********************************************/ +{% macro mow_calendar(id, item,_backend ) %} + +{% import "basic.html" as basic %} + +{% endmacro %} + + + +/********************************************* +* +* Widget for the predictive Calendar +* +*********************************************/ + + +{% macro predictive_calendar(id, item,_backend ) %} + +{% import "basic.html" as basic %} + +{% endmacro %} + + +/********************************************* +* +* Widget for the Weather-Pictures +* +*********************************************/ +/** +* Displays an image witch is been reloaded after a given time +* +* @param {id} unique id for this widget +* @param {image} the path/url to the image + +*/ +{% macro image(id, item) %} + + indego.image + +{% endmacro %} + +/********************************************* +* +* Widget for the Mode +* +*********************************************/ +{% macro mode(id, item,_backend ) %} + +{% import "basic.html" as basic %} + +{% endmacro %} diff --git a/indego4shng/user_doc.rst b/indego4shng/user_doc.rst new file mode 100755 index 000000000..782219041 --- /dev/null +++ b/indego4shng/user_doc.rst @@ -0,0 +1,75 @@ +.. index:: Plugins; Indego (Anbindung der Bosch-Indego Connect Mäher) +.. index:: Indego4shNG + +=========== +indego4shng +=========== + +Das Indego4shNG-Plugin ermöglicht den Zugriff auf einen Bosch-Indego Rasenmäher. Es werden alle Funktionen der Bosch-App abgebildet. Lediglich die Einrichtung des Mähers +muss über die App erfolgen. Es werden Kalender- sowie Smart-Mow-Funktionen unterstützt. Es werden die Gartenkarte sowie zusätzliche Vektoren dargestellt. Alarme werden in einem Popup-Window angezeigt. Die Wetterinformationen, Akku-Stand, Mäheffizienz, Mäh- und Ladezeiten werden über die SmartVISU dargestellt. Eine fertige smartVISU-Raumseite wird im Ordner ``"/pages"`` mitgeliefert. + +Konfiguration +============= + +Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/indego4shng` beschrieben. + + +Konfiguration von SmarthoneNG +============================= + +Die Konfiguration ist in der README-Datei zum Plugin beschrieben + + +Web Interface +============= + +Das indego Plugin verfügt über ein Webinterface, mit dessen Hilfe die Items die das Plugin nutzt übersichtlich dargestellt werden. Es können Trigger für die Stati des Mähers und für Meldungen konfiguriert werden. Es kann die Farbe des Mähers in der Gartenkarte konfiguriert werden. +Das encodieren des Users/Passwort mit Speicherung in der ./etc/plugin.yaml wird im Web-Interface unterstützt. Nach dem Encodieren mit speichern in der Konfiguration wird automatisch eingeloggt und alle Daten werden aktualisiert. +Es wird ein kurzes Protokoll zum Einloggen dargestellt. +10 Minuten vor Ablauf der Gültigkeit der aktuell genutzten Session-ID wird am Bosch-Server abgemeldet und im Anschluss wieder neu angemeldet. Die Zeiten für das letzte Login und den Ablauf der Session-ID werden angezeigt. +Das Web-Interface enthält ein selbst rotierendes Protokoll für die Stati-Wechsel des Mähers sowie für die Kommunikation mit dem Bosch-Server + +.. important:: + + Das Plugin kann mit SmartHomeNG v1.6 und höher genutzt werden. Versionen kleiner v1.6 ** werden nicht unterstützt** da STRUCT-Vorlagen genutzt werden. + Es wird ab der VISU v2.9 unterstützt da DROPINS genutzt werden. Für die Nutzung von SmartVISU v2.8 müssen manuell Anpassungen vorgenommen werden. + + +Aufruf des Webinterfaces +------------------------ + +Das Plugin kann aus dem backend aufgerufen werden. Dazu auf der Seite Plugins in der entsprechenden +Zeile das Icon in der Spalte **Web Interface** anklicken. + +Außerdem kann das Webinterface direkt über ``http://smarthome.local:8383/plugin/indego4shng/`` + + + +Web Interface +------------- + +Folgende Informationen können im Webinterface angezeigt werden: + +Oben rechts werden allgemeine Parameter zum Plugin angezeigt. + +Im ersten Tab werden die Items angezeigt, die das indego Plugin nutzt: + +.. image:: assets/webif1.jpg + :class: screenshot + +Im zweiten Tab werden die Original-Kartenkarte sowie Login-Informationen und Settings für Trigger/Farbe angezeigt: + +.. image:: assets/webif2.jpg + :class: screenshot + +Im dritten Tab wird das Protokoll für die Stati-Wechsel des Mähers angezeigt: + +.. image:: assets/webif3.jpg + :class: screenshot + +Im vierten Tab wird das Protokoll für die Kommunikation mit dem Bosch-Server angezeigt: + +.. image:: assets/webif4.jpg + :class: screenshot + + diff --git a/indego4shng/webif/static/img/garden.svg b/indego4shng/webif/static/img/garden.svg new file mode 100755 index 000000000..203ee06b7 --- /dev/null +++ b/indego4shng/webif/static/img/garden.svg @@ -0,0 +1,5 @@ +XSym +0045 +b7d40008153fcf068cc1330d47e0b2b6 +/var/www/html/smartVISU2.9/dropins/garden.svg + \ No newline at end of file diff --git a/indego4shng/webif/static/img/lamp_green.png b/indego4shng/webif/static/img/lamp_green.png new file mode 100755 index 000000000..fb130568b Binary files /dev/null and b/indego4shng/webif/static/img/lamp_green.png differ diff --git a/indego4shng/webif/static/img/lamp_red.png b/indego4shng/webif/static/img/lamp_red.png new file mode 100755 index 000000000..00fc04c90 Binary files /dev/null and b/indego4shng/webif/static/img/lamp_red.png differ diff --git a/indego4shng/webif/static/img/readme.txt b/indego4shng/webif/static/img/readme.txt new file mode 100755 index 000000000..1a7c55eef --- /dev/null +++ b/indego4shng/webif/static/img/readme.txt @@ -0,0 +1,6 @@ +This directory is for storing images that are used by the web interface. + +If you want to have your own logo on the top of the web interface, store it here and name it plugin_logo.. + +Extension can be png, svg or jpg + diff --git a/indego4shng/webif/static/js/handler.js b/indego4shng/webif/static/js/handler.js new file mode 100755 index 000000000..5321b2944 --- /dev/null +++ b/indego4shng/webif/static/js/handler.js @@ -0,0 +1,307 @@ +//************************************************************* +// check Auto-Updates for protocols +//************************************************************* +setInterval(Checkupdate4Protocolls, 5000); + +//************************************************************* +// set Location +//************************************************************* + +function BtnStoreLocation() +{ + myLongitude = document.getElementById("txtlongitude").value + myLatitude = document.getElementById("txtlatitude").value + $.ajax({ + url: "set_location.html", + type: "GET", + data: { longitude : myLongitude, + latitude : myLatitude + }, + contentType: "application/json; charset=utf-8", + success: function (response) { + document.getElementById("txt_LocationResult").innerHTML = response; + }, + error: function () { + document.getElementById("txt_LocationResult").innerHTML = "Error while communication"; + console.log("Error - while setting location :") + } + }); +}; + +//************************************************************* +// delete Protocols +//************************************************************* + +function DeleteProto(btn_Name) +{ + if (btn_Name =="btn_clear_proto_commun") + { proto_Name = "webif.communication_protocoll"} + else if (btn_Name == "btn_clear_proto_states") + { proto_Name = "webif.state_protocoll"} + + $.ajax({ + url: "clear_proto.html", + type: "GET", + data: { proto_Name : proto_Name + }, + contentType: "application/json; charset=utf-8", + success: function (response) { + ClearProto(proto_Name); + }, + error: function () { + console.log("Error - while clearing Protocol :"+proto_Name) + } + }); +}; + +//************************************************************* +// clear Protocol +//************************************************************* +function ClearProto(proto_Name) +{ + + if (proto_Name == 'webif.communication_protocoll') + { + logCodeMirror.setValue("") + } + if (proto_Name == 'webif.state_protocoll') + { + statelogCodeMirror.setValue("") + } +} + + +//************************************************************* +// check Auto-Updates for protocols +//************************************************************* +function Checkupdate4Protocolls() +{ + states_checked = document.getElementById("proto_states_check").checked + commun_checked = document.getElementById("proto_commun_check").checked + if (states_checked == true) + { + UpdateProto('state_log_file') + } + if (commun_checked == true) + { + UpdateProto('Com_log_file') + } +} + + +//************************************************************* +// actualisation of Protocol +//************************************************************* +function actProto(response,proto_Name) +{ + myProto = document.getElementById(proto_Name) + myProto.value = "" + myText = "" + var objResponse = JSON.parse(response) + for (x in objResponse) + { + myText += objResponse[x]+"\n" + } + myProto.value = myText + if (proto_Name == 'Com_log_file') + { + logCodeMirror.setValue(myText) + } + if (proto_Name == 'state_log_file') + { + statelogCodeMirror.setValue(myText) + } +} + +//************************************************************* +// Auto-Update-Timer for protocol - States +//************************************************************* + +function UpdateProto(proto_Name) +{ + $.ajax({ + url: "get_proto.html", + type: "GET", + data: { proto_Name : proto_Name + }, + contentType: "application/json; charset=utf-8", + success: function (response) { + actProto(response,proto_Name); + }, + error: function () { + console.log("Error - while updating Protocol :"+proto_Name) + } + }); +}; + + + + +//************************************************************* +// ValidateEncodeResponse -checks the login-button +//************************************************************* + +function ValidateEncodeResponse(response) +{ +var myResult = "" +var temp = "" +var objResponse = JSON.parse(response) +for (x in objResponse.Proto) + { + temp = temp + objResponse.Proto[x]+"\n"; + } + +document.getElementById("txt_Result").value = temp; +document.getElementById("txtEncoded").innerHTML = objResponse.Params.encoded +document.getElementById("text_session_id").innerHTML = objResponse.Params.SessionID +document.getElementById("text_experitation").innerHTML = objResponse.Params.timeStamp + +if (document.getElementById("store_2_config").checked = true) +{ + if (objResponse.Params.logged_in == true) + { + document.getElementById("grafic_logged_in").src = "static/img/lamp_green.png" + document.getElementById("text_logged_in").innerHTML = "logged in" + + } + else + { + document.getElementById("grafic_logged_in").src = "static/img/lamp_red.png" + document.getElementById("text_logged_in").innerHTML = "logged off" + } + } +} + +//******************************************* +// Button Handler for Encoding credentials +//******************************************* + +function BtnEncode(result) +{ + user = document.getElementById("txtUser").value; + pwd = document.getElementById("txtPwd").value; + store2config = document.getElementById("store_2_config").checked; + encoded=user+":"+pwd; + encoded=btoa(encoded); + $.ajax({ + url: "store_credentials.html", + type: "GET", + data: { encoded : encoded, + user : user, + pwd : pwd, + store_2_config : store2config + }, + contentType: "application/json; charset=utf-8", + success: function (response) { + ValidateEncodeResponse(response); + }, + error: function () { + document.getElementById("txt_Result").innerHTML = "Error while Communication !"; + } + }); + return +} + +//******************************************* +// Function to Store Color +//******************************************* + +function StoreColor(Color) { + $.ajax({ + url: "store_color.html", + type: "GET", + data: { newColor : Color, + } , + contentType: "application/json; charset=utf-8", + success: function (response) {console.log('OK-setting Colour-Code')}, + error: function () {console.log('error-setting Colour-Code')} + }); + return +} + +//******************************************* +// Function to add_svg_images +//******************************************* + +function Store_add_svg(Value) { + $.ajax({ + url: "store_add_svg.html", + type: "GET", + data: { add_svg_str : Value, + } , + contentType: "application/json; charset=utf-8", + success: function (response) {console.log('OK add_svg_image stored')}, + error: function () {console.log('error-add_svg_image stored')} + }); + return +} + +//******************************************* +// Function to Store State-Trigger-Events +//******************************************* + +function StoreStateTrigger(TriggerItem, Value) { + $.ajax({ + url: "store_state_trigger.html", + type: "GET", + data: { Trigger_State_Item : TriggerItem, + newState : Value + } , + contentType: "application/json; charset=utf-8", + success: function (response) {console.log('OK-setting Trigger-State')}, + error: function () {console.log('error-setting Trigger-State')} + }); + return +} + +//******************************************* +// Function to Store Alarm-Trigger-Events +//******************************************* + +function StoreAlarmTrigger(TriggerItem, Value) { + $.ajax({ + url: "store_alarm_trigger.html", + type: "GET", + data: { Trigger_Alarm_Item : TriggerItem, + newAlarm : Value + } , + contentType: "application/json; charset=utf-8", + success: function (response) {console.log('OK-setting Trigger-Alarm')}, + error: function () {console.log('error-setting Trigger-Alarm')} + }); + return +} + +//******************************************* +// Handler for Selecting State-Triggers +//******************************************* + +function selectStateTrigger(SelectID) +{ + mySelect = document.getElementById(SelectID) + myValue = mySelect.options[mySelect.options.selectedIndex].text + StoreStateTrigger(SelectID, myValue) +} + +//******************************************* +// Handler for Selecting Alarm-Triggers +//******************************************* + +function selectAlarmTrigger(SelectID) +{ + mySelect = document.getElementById(SelectID) + myValue = mySelect.value + StoreAlarmTrigger(SelectID, myValue) +} + + +//******************************************* +// Button Handler for saving Colour +//******************************************* + +function SaveColor(picker) +{ + newColor = picker.toHEXString() + StoreColor(newColor) +} + diff --git a/indego4shng/webif/static/js/jscolor.js b/indego4shng/webif/static/js/jscolor.js new file mode 100755 index 000000000..5c77177d2 --- /dev/null +++ b/indego4shng/webif/static/js/jscolor.js @@ -0,0 +1,1855 @@ +/** + * jscolor - JavaScript Color Picker + * + * @link http://jscolor.com + * @license For open source use: GPLv3 + * For commercial use: JSColor Commercial License + * @author Jan Odvarko + * @version 2.0.5 + * + * See usage examples at http://jscolor.com/examples/ + */ + + +"use strict"; + + +if (!window.jscolor) { window.jscolor = (function () { + + +var jsc = { + + + register : function () { + jsc.attachDOMReadyEvent(jsc.init); + jsc.attachEvent(document, 'mousedown', jsc.onDocumentMouseDown); + jsc.attachEvent(document, 'touchstart', jsc.onDocumentTouchStart); + jsc.attachEvent(window, 'resize', jsc.onWindowResize); + }, + + + init : function () { + if (jsc.jscolor.lookupClass) { + jsc.jscolor.installByClassName(jsc.jscolor.lookupClass); + } + }, + + + tryInstallOnElements : function (elms, className) { + var matchClass = new RegExp('(^|\\s)(' + className + ')(\\s*(\\{[^}]*\\})|\\s|$)', 'i'); + + for (var i = 0; i < elms.length; i += 1) { + if (elms[i].type !== undefined && elms[i].type.toLowerCase() == 'color') { + if (jsc.isColorAttrSupported) { + // skip inputs of type 'color' if supported by the browser + continue; + } + } + var m; + if (!elms[i].jscolor && elms[i].className && (m = elms[i].className.match(matchClass))) { + var targetElm = elms[i]; + var optsStr = null; + + var dataOptions = jsc.getDataAttr(targetElm, 'jscolor'); + if (dataOptions !== null) { + optsStr = dataOptions; + } else if (m[4]) { + optsStr = m[4]; + } + + var opts = {}; + if (optsStr) { + try { + opts = (new Function ('return (' + optsStr + ')'))(); + } catch(eParseError) { + jsc.warn('Error parsing jscolor options: ' + eParseError + ':\n' + optsStr); + } + } + targetElm.jscolor = new jsc.jscolor(targetElm, opts); + } + } + }, + + + isColorAttrSupported : (function () { + var elm = document.createElement('input'); + if (elm.setAttribute) { + elm.setAttribute('type', 'color'); + if (elm.type.toLowerCase() == 'color') { + return true; + } + } + return false; + })(), + + + isCanvasSupported : (function () { + var elm = document.createElement('canvas'); + return !!(elm.getContext && elm.getContext('2d')); + })(), + + + fetchElement : function (mixed) { + return typeof mixed === 'string' ? document.getElementById(mixed) : mixed; + }, + + + isElementType : function (elm, type) { + return elm.nodeName.toLowerCase() === type.toLowerCase(); + }, + + + getDataAttr : function (el, name) { + var attrName = 'data-' + name; + var attrValue = el.getAttribute(attrName); + if (attrValue !== null) { + return attrValue; + } + return null; + }, + + + attachEvent : function (el, evnt, func) { + if (el.addEventListener) { + el.addEventListener(evnt, func, false); + } else if (el.attachEvent) { + el.attachEvent('on' + evnt, func); + } + }, + + + detachEvent : function (el, evnt, func) { + if (el.removeEventListener) { + el.removeEventListener(evnt, func, false); + } else if (el.detachEvent) { + el.detachEvent('on' + evnt, func); + } + }, + + + _attachedGroupEvents : {}, + + + attachGroupEvent : function (groupName, el, evnt, func) { + if (!jsc._attachedGroupEvents.hasOwnProperty(groupName)) { + jsc._attachedGroupEvents[groupName] = []; + } + jsc._attachedGroupEvents[groupName].push([el, evnt, func]); + jsc.attachEvent(el, evnt, func); + }, + + + detachGroupEvents : function (groupName) { + if (jsc._attachedGroupEvents.hasOwnProperty(groupName)) { + for (var i = 0; i < jsc._attachedGroupEvents[groupName].length; i += 1) { + var evt = jsc._attachedGroupEvents[groupName][i]; + jsc.detachEvent(evt[0], evt[1], evt[2]); + } + delete jsc._attachedGroupEvents[groupName]; + } + }, + + + attachDOMReadyEvent : function (func) { + var fired = false; + var fireOnce = function () { + if (!fired) { + fired = true; + func(); + } + }; + + if (document.readyState === 'complete') { + setTimeout(fireOnce, 1); // async + return; + } + + if (document.addEventListener) { + document.addEventListener('DOMContentLoaded', fireOnce, false); + + // Fallback + window.addEventListener('load', fireOnce, false); + + } else if (document.attachEvent) { + // IE + document.attachEvent('onreadystatechange', function () { + if (document.readyState === 'complete') { + document.detachEvent('onreadystatechange', arguments.callee); + fireOnce(); + } + }) + + // Fallback + window.attachEvent('onload', fireOnce); + + // IE7/8 + if (document.documentElement.doScroll && window == window.top) { + var tryScroll = function () { + if (!document.body) { return; } + try { + document.documentElement.doScroll('left'); + fireOnce(); + } catch (e) { + setTimeout(tryScroll, 1); + } + }; + tryScroll(); + } + } + }, + + + warn : function (msg) { + if (window.console && window.console.warn) { + window.console.warn(msg); + } + }, + + + preventDefault : function (e) { + if (e.preventDefault) { e.preventDefault(); } + e.returnValue = false; + }, + + + captureTarget : function (target) { + // IE + if (target.setCapture) { + jsc._capturedTarget = target; + jsc._capturedTarget.setCapture(); + } + }, + + + releaseTarget : function () { + // IE + if (jsc._capturedTarget) { + jsc._capturedTarget.releaseCapture(); + jsc._capturedTarget = null; + } + }, + + + fireEvent : function (el, evnt) { + if (!el) { + return; + } + if (document.createEvent) { + var ev = document.createEvent('HTMLEvents'); + ev.initEvent(evnt, true, true); + el.dispatchEvent(ev); + } else if (document.createEventObject) { + var ev = document.createEventObject(); + el.fireEvent('on' + evnt, ev); + } else if (el['on' + evnt]) { // alternatively use the traditional event model + el['on' + evnt](); + } + }, + + + classNameToList : function (className) { + return className.replace(/^\s+|\s+$/g, '').split(/\s+/); + }, + + + // The className parameter (str) can only contain a single class name + hasClass : function (elm, className) { + if (!className) { + return false; + } + return -1 != (' ' + elm.className.replace(/\s+/g, ' ') + ' ').indexOf(' ' + className + ' '); + }, + + + // The className parameter (str) can contain multiple class names separated by whitespace + setClass : function (elm, className) { + var classList = jsc.classNameToList(className); + for (var i = 0; i < classList.length; i += 1) { + if (!jsc.hasClass(elm, classList[i])) { + elm.className += (elm.className ? ' ' : '') + classList[i]; + } + } + }, + + + // The className parameter (str) can contain multiple class names separated by whitespace + unsetClass : function (elm, className) { + var classList = jsc.classNameToList(className); + for (var i = 0; i < classList.length; i += 1) { + var repl = new RegExp( + '^\\s*' + classList[i] + '\\s*|' + + '\\s*' + classList[i] + '\\s*$|' + + '\\s+' + classList[i] + '(\\s+)', + 'g' + ); + elm.className = elm.className.replace(repl, '$1'); + } + }, + + + getStyle : function (elm) { + return window.getComputedStyle ? window.getComputedStyle(elm) : elm.currentStyle; + }, + + + setStyle : (function () { + var helper = document.createElement('div'); + var getSupportedProp = function (names) { + for (var i = 0; i < names.length; i += 1) { + if (names[i] in helper.style) { + return names[i]; + } + } + }; + var props = { + borderRadius: getSupportedProp(['borderRadius', 'MozBorderRadius', 'webkitBorderRadius']), + boxShadow: getSupportedProp(['boxShadow', 'MozBoxShadow', 'webkitBoxShadow']) + }; + return function (elm, prop, value) { + switch (prop.toLowerCase()) { + case 'opacity': + var alphaOpacity = Math.round(parseFloat(value) * 100); + elm.style.opacity = value; + elm.style.filter = 'alpha(opacity=' + alphaOpacity + ')'; + break; + default: + elm.style[props[prop]] = value; + break; + } + }; + })(), + + + setBorderRadius : function (elm, value) { + jsc.setStyle(elm, 'borderRadius', value || '0'); + }, + + + setBoxShadow : function (elm, value) { + jsc.setStyle(elm, 'boxShadow', value || 'none'); + }, + + + getElementPos : function (e, relativeToViewport) { + var x=0, y=0; + var rect = e.getBoundingClientRect(); + x = rect.left; + y = rect.top; + if (!relativeToViewport) { + var viewPos = jsc.getViewPos(); + x += viewPos[0]; + y += viewPos[1]; + } + return [x, y]; + }, + + + getElementSize : function (e) { + return [e.offsetWidth, e.offsetHeight]; + }, + + + // get pointer's X/Y coordinates relative to viewport + getAbsPointerPos : function (e) { + if (!e) { e = window.event; } + var x = 0, y = 0; + if (typeof e.changedTouches !== 'undefined' && e.changedTouches.length) { + // touch devices + x = e.changedTouches[0].clientX; + y = e.changedTouches[0].clientY; + } else if (typeof e.clientX === 'number') { + x = e.clientX; + y = e.clientY; + } + return { x: x, y: y }; + }, + + + // get pointer's X/Y coordinates relative to target element + getRelPointerPos : function (e) { + if (!e) { e = window.event; } + var target = e.target || e.srcElement; + var targetRect = target.getBoundingClientRect(); + + var x = 0, y = 0; + + var clientX = 0, clientY = 0; + if (typeof e.changedTouches !== 'undefined' && e.changedTouches.length) { + // touch devices + clientX = e.changedTouches[0].clientX; + clientY = e.changedTouches[0].clientY; + } else if (typeof e.clientX === 'number') { + clientX = e.clientX; + clientY = e.clientY; + } + + x = clientX - targetRect.left; + y = clientY - targetRect.top; + return { x: x, y: y }; + }, + + + getViewPos : function () { + var doc = document.documentElement; + return [ + (window.pageXOffset || doc.scrollLeft) - (doc.clientLeft || 0), + (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0) + ]; + }, + + + getViewSize : function () { + var doc = document.documentElement; + return [ + (window.innerWidth || doc.clientWidth), + (window.innerHeight || doc.clientHeight), + ]; + }, + + + redrawPosition : function () { + + if (jsc.picker && jsc.picker.owner) { + var thisObj = jsc.picker.owner; + + var tp, vp; + + if (thisObj.fixed) { + // Fixed elements are positioned relative to viewport, + // therefore we can ignore the scroll offset + tp = jsc.getElementPos(thisObj.targetElement, true); // target pos + vp = [0, 0]; // view pos + } else { + tp = jsc.getElementPos(thisObj.targetElement); // target pos + vp = jsc.getViewPos(); // view pos + } + + var ts = jsc.getElementSize(thisObj.targetElement); // target size + var vs = jsc.getViewSize(); // view size + var ps = jsc.getPickerOuterDims(thisObj); // picker size + var a, b, c; + switch (thisObj.position.toLowerCase()) { + case 'left': a=1; b=0; c=-1; break; + case 'right':a=1; b=0; c=1; break; + case 'top': a=0; b=1; c=-1; break; + default: a=0; b=1; c=1; break; + } + var l = (ts[b]+ps[b])/2; + + // compute picker position + if (!thisObj.smartPosition) { + var pp = [ + tp[a], + tp[b]+ts[b]-l+l*c + ]; + } else { + var pp = [ + -vp[a]+tp[a]+ps[a] > vs[a] ? + (-vp[a]+tp[a]+ts[a]/2 > vs[a]/2 && tp[a]+ts[a]-ps[a] >= 0 ? tp[a]+ts[a]-ps[a] : tp[a]) : + tp[a], + -vp[b]+tp[b]+ts[b]+ps[b]-l+l*c > vs[b] ? + (-vp[b]+tp[b]+ts[b]/2 > vs[b]/2 && tp[b]+ts[b]-l-l*c >= 0 ? tp[b]+ts[b]-l-l*c : tp[b]+ts[b]-l+l*c) : + (tp[b]+ts[b]-l+l*c >= 0 ? tp[b]+ts[b]-l+l*c : tp[b]+ts[b]-l-l*c) + ]; + } + + var x = pp[a]; + var y = pp[b]; + var positionValue = thisObj.fixed ? 'fixed' : 'absolute'; + var contractShadow = + (pp[0] + ps[0] > tp[0] || pp[0] < tp[0] + ts[0]) && + (pp[1] + ps[1] < tp[1] + ts[1]); + + jsc._drawPosition(thisObj, x, y, positionValue, contractShadow); + } + }, + + + _drawPosition : function (thisObj, x, y, positionValue, contractShadow) { + var vShadow = contractShadow ? 0 : thisObj.shadowBlur; // px + + jsc.picker.wrap.style.position = positionValue; + jsc.picker.wrap.style.left = x + 'px'; + jsc.picker.wrap.style.top = y + 'px'; + + jsc.setBoxShadow( + jsc.picker.boxS, + thisObj.shadow ? + new jsc.BoxShadow(0, vShadow, thisObj.shadowBlur, 0, thisObj.shadowColor) : + null); + }, + + + getPickerDims : function (thisObj) { + var displaySlider = !!jsc.getSliderComponent(thisObj); + var dims = [ + 2 * thisObj.insetWidth + 2 * thisObj.padding + thisObj.width + + (displaySlider ? 2 * thisObj.insetWidth + jsc.getPadToSliderPadding(thisObj) + thisObj.sliderSize : 0), + 2 * thisObj.insetWidth + 2 * thisObj.padding + thisObj.height + + (thisObj.closable ? 2 * thisObj.insetWidth + thisObj.padding + thisObj.buttonHeight : 0) + ]; + return dims; + }, + + + getPickerOuterDims : function (thisObj) { + var dims = jsc.getPickerDims(thisObj); + return [ + dims[0] + 2 * thisObj.borderWidth, + dims[1] + 2 * thisObj.borderWidth + ]; + }, + + + getPadToSliderPadding : function (thisObj) { + return Math.max(thisObj.padding, 1.5 * (2 * thisObj.pointerBorderWidth + thisObj.pointerThickness)); + }, + + + getPadYComponent : function (thisObj) { + switch (thisObj.mode.charAt(1).toLowerCase()) { + case 'v': return 'v'; break; + } + return 's'; + }, + + + getSliderComponent : function (thisObj) { + if (thisObj.mode.length > 2) { + switch (thisObj.mode.charAt(2).toLowerCase()) { + case 's': return 's'; break; + case 'v': return 'v'; break; + } + } + return null; + }, + + + onDocumentMouseDown : function (e) { + if (!e) { e = window.event; } + var target = e.target || e.srcElement; + + if (target._jscLinkedInstance) { + if (target._jscLinkedInstance.showOnClick) { + target._jscLinkedInstance.show(); + } + } else if (target._jscControlName) { + jsc.onControlPointerStart(e, target, target._jscControlName, 'mouse'); + } else { + // Mouse is outside the picker controls -> hide the color picker! + if (jsc.picker && jsc.picker.owner) { + jsc.picker.owner.hide(); + } + } + }, + + + onDocumentTouchStart : function (e) { + if (!e) { e = window.event; } + var target = e.target || e.srcElement; + + if (target._jscLinkedInstance) { + if (target._jscLinkedInstance.showOnClick) { + target._jscLinkedInstance.show(); + } + } else if (target._jscControlName) { + jsc.onControlPointerStart(e, target, target._jscControlName, 'touch'); + } else { + if (jsc.picker && jsc.picker.owner) { + jsc.picker.owner.hide(); + } + } + }, + + + onWindowResize : function (e) { + jsc.redrawPosition(); + }, + + + onParentScroll : function (e) { + // hide the picker when one of the parent elements is scrolled + if (jsc.picker && jsc.picker.owner) { + jsc.picker.owner.hide(); + } + }, + + + _pointerMoveEvent : { + mouse: 'mousemove', + touch: 'touchmove' + }, + _pointerEndEvent : { + mouse: 'mouseup', + touch: 'touchend' + }, + + + _pointerOrigin : null, + _capturedTarget : null, + + + onControlPointerStart : function (e, target, controlName, pointerType) { + var thisObj = target._jscInstance; + + jsc.preventDefault(e); + jsc.captureTarget(target); + + var registerDragEvents = function (doc, offset) { + jsc.attachGroupEvent('drag', doc, jsc._pointerMoveEvent[pointerType], + jsc.onDocumentPointerMove(e, target, controlName, pointerType, offset)); + jsc.attachGroupEvent('drag', doc, jsc._pointerEndEvent[pointerType], + jsc.onDocumentPointerEnd(e, target, controlName, pointerType)); + }; + + registerDragEvents(document, [0, 0]); + + if (window.parent && window.frameElement) { + var rect = window.frameElement.getBoundingClientRect(); + var ofs = [-rect.left, -rect.top]; + registerDragEvents(window.parent.window.document, ofs); + } + + var abs = jsc.getAbsPointerPos(e); + var rel = jsc.getRelPointerPos(e); + jsc._pointerOrigin = { + x: abs.x - rel.x, + y: abs.y - rel.y + }; + + switch (controlName) { + case 'pad': + // if the slider is at the bottom, move it up + switch (jsc.getSliderComponent(thisObj)) { + case 's': if (thisObj.hsv[1] === 0) { thisObj.fromHSV(null, 100, null); }; break; + case 'v': if (thisObj.hsv[2] === 0) { thisObj.fromHSV(null, null, 100); }; break; + } + jsc.setPad(thisObj, e, 0, 0); + break; + + case 'sld': + jsc.setSld(thisObj, e, 0); + break; + } + + jsc.dispatchFineChange(thisObj); + }, + + + onDocumentPointerMove : function (e, target, controlName, pointerType, offset) { + return function (e) { + var thisObj = target._jscInstance; + switch (controlName) { + case 'pad': + if (!e) { e = window.event; } + jsc.setPad(thisObj, e, offset[0], offset[1]); + jsc.dispatchFineChange(thisObj); + break; + + case 'sld': + if (!e) { e = window.event; } + jsc.setSld(thisObj, e, offset[1]); + jsc.dispatchFineChange(thisObj); + break; + } + } + }, + + + onDocumentPointerEnd : function (e, target, controlName, pointerType) { + return function (e) { + var thisObj = target._jscInstance; + jsc.detachGroupEvents('drag'); + jsc.releaseTarget(); + // Always dispatch changes after detaching outstanding mouse handlers, + // in case some user interaction will occur in user's onchange callback + // that would intrude with current mouse events + jsc.dispatchChange(thisObj); + }; + }, + + + dispatchChange : function (thisObj) { + if (thisObj.valueElement) { + if (jsc.isElementType(thisObj.valueElement, 'input')) { + jsc.fireEvent(thisObj.valueElement, 'change'); + } + } + }, + + + dispatchFineChange : function (thisObj) { + if (thisObj.onFineChange) { + var callback; + if (typeof thisObj.onFineChange === 'string') { + callback = new Function (thisObj.onFineChange); + } else { + callback = thisObj.onFineChange; + } + callback.call(thisObj); + } + }, + + + setPad : function (thisObj, e, ofsX, ofsY) { + var pointerAbs = jsc.getAbsPointerPos(e); + var x = ofsX + pointerAbs.x - jsc._pointerOrigin.x - thisObj.padding - thisObj.insetWidth; + var y = ofsY + pointerAbs.y - jsc._pointerOrigin.y - thisObj.padding - thisObj.insetWidth; + + var xVal = x * (360 / (thisObj.width - 1)); + var yVal = 100 - (y * (100 / (thisObj.height - 1))); + + switch (jsc.getPadYComponent(thisObj)) { + case 's': thisObj.fromHSV(xVal, yVal, null, jsc.leaveSld); break; + case 'v': thisObj.fromHSV(xVal, null, yVal, jsc.leaveSld); break; + } + }, + + + setSld : function (thisObj, e, ofsY) { + var pointerAbs = jsc.getAbsPointerPos(e); + var y = ofsY + pointerAbs.y - jsc._pointerOrigin.y - thisObj.padding - thisObj.insetWidth; + + var yVal = 100 - (y * (100 / (thisObj.height - 1))); + + switch (jsc.getSliderComponent(thisObj)) { + case 's': thisObj.fromHSV(null, yVal, null, jsc.leavePad); break; + case 'v': thisObj.fromHSV(null, null, yVal, jsc.leavePad); break; + } + }, + + + _vmlNS : 'jsc_vml_', + _vmlCSS : 'jsc_vml_css_', + _vmlReady : false, + + + initVML : function () { + if (!jsc._vmlReady) { + // init VML namespace + var doc = document; + if (!doc.namespaces[jsc._vmlNS]) { + doc.namespaces.add(jsc._vmlNS, 'urn:schemas-microsoft-com:vml'); + } + if (!doc.styleSheets[jsc._vmlCSS]) { + var tags = ['shape', 'shapetype', 'group', 'background', 'path', 'formulas', 'handles', 'fill', 'stroke', 'shadow', 'textbox', 'textpath', 'imagedata', 'line', 'polyline', 'curve', 'rect', 'roundrect', 'oval', 'arc', 'image']; + var ss = doc.createStyleSheet(); + ss.owningElement.id = jsc._vmlCSS; + for (var i = 0; i < tags.length; i += 1) { + ss.addRule(jsc._vmlNS + '\\:' + tags[i], 'behavior:url(#default#VML);'); + } + } + jsc._vmlReady = true; + } + }, + + + createPalette : function () { + + var paletteObj = { + elm: null, + draw: null + }; + + if (jsc.isCanvasSupported) { + // Canvas implementation for modern browsers + + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + + var drawFunc = function (width, height, type) { + canvas.width = width; + canvas.height = height; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + var hGrad = ctx.createLinearGradient(0, 0, canvas.width, 0); + hGrad.addColorStop(0 / 6, '#F00'); + hGrad.addColorStop(1 / 6, '#FF0'); + hGrad.addColorStop(2 / 6, '#0F0'); + hGrad.addColorStop(3 / 6, '#0FF'); + hGrad.addColorStop(4 / 6, '#00F'); + hGrad.addColorStop(5 / 6, '#F0F'); + hGrad.addColorStop(6 / 6, '#F00'); + + ctx.fillStyle = hGrad; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + var vGrad = ctx.createLinearGradient(0, 0, 0, canvas.height); + switch (type.toLowerCase()) { + case 's': + vGrad.addColorStop(0, 'rgba(255,255,255,0)'); + vGrad.addColorStop(1, 'rgba(255,255,255,1)'); + break; + case 'v': + vGrad.addColorStop(0, 'rgba(0,0,0,0)'); + vGrad.addColorStop(1, 'rgba(0,0,0,1)'); + break; + } + ctx.fillStyle = vGrad; + ctx.fillRect(0, 0, canvas.width, canvas.height); + }; + + paletteObj.elm = canvas; + paletteObj.draw = drawFunc; + + } else { + // VML fallback for IE 7 and 8 + + jsc.initVML(); + + var vmlContainer = document.createElement('div'); + vmlContainer.style.position = 'relative'; + vmlContainer.style.overflow = 'hidden'; + + var hGrad = document.createElement(jsc._vmlNS + ':fill'); + hGrad.type = 'gradient'; + hGrad.method = 'linear'; + hGrad.angle = '90'; + hGrad.colors = '16.67% #F0F, 33.33% #00F, 50% #0FF, 66.67% #0F0, 83.33% #FF0' + + var hRect = document.createElement(jsc._vmlNS + ':rect'); + hRect.style.position = 'absolute'; + hRect.style.left = -1 + 'px'; + hRect.style.top = -1 + 'px'; + hRect.stroked = false; + hRect.appendChild(hGrad); + vmlContainer.appendChild(hRect); + + var vGrad = document.createElement(jsc._vmlNS + ':fill'); + vGrad.type = 'gradient'; + vGrad.method = 'linear'; + vGrad.angle = '180'; + vGrad.opacity = '0'; + + var vRect = document.createElement(jsc._vmlNS + ':rect'); + vRect.style.position = 'absolute'; + vRect.style.left = -1 + 'px'; + vRect.style.top = -1 + 'px'; + vRect.stroked = false; + vRect.appendChild(vGrad); + vmlContainer.appendChild(vRect); + + var drawFunc = function (width, height, type) { + vmlContainer.style.width = width + 'px'; + vmlContainer.style.height = height + 'px'; + + hRect.style.width = + vRect.style.width = + (width + 1) + 'px'; + hRect.style.height = + vRect.style.height = + (height + 1) + 'px'; + + // Colors must be specified during every redraw, otherwise IE won't display + // a full gradient during a subsequential redraw + hGrad.color = '#F00'; + hGrad.color2 = '#F00'; + + switch (type.toLowerCase()) { + case 's': + vGrad.color = vGrad.color2 = '#FFF'; + break; + case 'v': + vGrad.color = vGrad.color2 = '#000'; + break; + } + }; + + paletteObj.elm = vmlContainer; + paletteObj.draw = drawFunc; + } + + return paletteObj; + }, + + + createSliderGradient : function () { + + var sliderObj = { + elm: null, + draw: null + }; + + if (jsc.isCanvasSupported) { + // Canvas implementation for modern browsers + + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + + var drawFunc = function (width, height, color1, color2) { + canvas.width = width; + canvas.height = height; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + var grad = ctx.createLinearGradient(0, 0, 0, canvas.height); + grad.addColorStop(0, color1); + grad.addColorStop(1, color2); + + ctx.fillStyle = grad; + ctx.fillRect(0, 0, canvas.width, canvas.height); + }; + + sliderObj.elm = canvas; + sliderObj.draw = drawFunc; + + } else { + // VML fallback for IE 7 and 8 + + jsc.initVML(); + + var vmlContainer = document.createElement('div'); + vmlContainer.style.position = 'relative'; + vmlContainer.style.overflow = 'hidden'; + + var grad = document.createElement(jsc._vmlNS + ':fill'); + grad.type = 'gradient'; + grad.method = 'linear'; + grad.angle = '180'; + + var rect = document.createElement(jsc._vmlNS + ':rect'); + rect.style.position = 'absolute'; + rect.style.left = -1 + 'px'; + rect.style.top = -1 + 'px'; + rect.stroked = false; + rect.appendChild(grad); + vmlContainer.appendChild(rect); + + var drawFunc = function (width, height, color1, color2) { + vmlContainer.style.width = width + 'px'; + vmlContainer.style.height = height + 'px'; + + rect.style.width = (width + 1) + 'px'; + rect.style.height = (height + 1) + 'px'; + + grad.color = color1; + grad.color2 = color2; + }; + + sliderObj.elm = vmlContainer; + sliderObj.draw = drawFunc; + } + + return sliderObj; + }, + + + leaveValue : 1<<0, + leaveStyle : 1<<1, + leavePad : 1<<2, + leaveSld : 1<<3, + + + BoxShadow : (function () { + var BoxShadow = function (hShadow, vShadow, blur, spread, color, inset) { + this.hShadow = hShadow; + this.vShadow = vShadow; + this.blur = blur; + this.spread = spread; + this.color = color; + this.inset = !!inset; + }; + + BoxShadow.prototype.toString = function () { + var vals = [ + Math.round(this.hShadow) + 'px', + Math.round(this.vShadow) + 'px', + Math.round(this.blur) + 'px', + Math.round(this.spread) + 'px', + this.color + ]; + if (this.inset) { + vals.push('inset'); + } + return vals.join(' '); + }; + + return BoxShadow; + })(), + + + // + // Usage: + // var myColor = new jscolor( [, ]) + // + + jscolor : function (targetElement, options) { + + // General options + // + this.value = null; // initial HEX color. To change it later, use methods fromString(), fromHSV() and fromRGB() + this.valueElement = targetElement; // element that will be used to display and input the color code + this.styleElement = targetElement; // element that will preview the picked color using CSS backgroundColor + this.required = true; // whether the associated text can be left empty + this.refine = true; // whether to refine the entered color code (e.g. uppercase it and remove whitespace) + this.hash = false; // whether to prefix the HEX color code with # symbol + this.uppercase = true; // whether to show the color code in upper case + this.onFineChange = null; // called instantly every time the color changes (value can be either a function or a string with javascript code) + this.activeClass = 'jscolor-active'; // class to be set to the target element when a picker window is open on it + this.overwriteImportant = false; // whether to overwrite colors of styleElement using !important + this.minS = 0; // min allowed saturation (0 - 100) + this.maxS = 100; // max allowed saturation (0 - 100) + this.minV = 0; // min allowed value (brightness) (0 - 100) + this.maxV = 100; // max allowed value (brightness) (0 - 100) + + // Accessing the picked color + // + this.hsv = [0, 0, 100]; // read-only [0-360, 0-100, 0-100] + this.rgb = [255, 255, 255]; // read-only [0-255, 0-255, 0-255] + + // Color Picker options + // + this.width = 181; // width of color palette (in px) + this.height = 101; // height of color palette (in px) + this.showOnClick = true; // whether to display the color picker when user clicks on its target element + this.mode = 'HSV'; // HSV | HVS | HS | HV - layout of the color picker controls + this.position = 'bottom'; // left | right | top | bottom - position relative to the target element + this.smartPosition = true; // automatically change picker position when there is not enough space for it + this.sliderSize = 16; // px + this.crossSize = 8; // px + this.closable = false; // whether to display the Close button + this.closeText = 'Close'; + this.buttonColor = '#000000'; // CSS color + this.buttonHeight = 18; // px + this.padding = 12; // px + this.backgroundColor = '#FFFFFF'; // CSS color + this.borderWidth = 1; // px + this.borderColor = '#BBBBBB'; // CSS color + this.borderRadius = 8; // px + this.insetWidth = 1; // px + this.insetColor = '#BBBBBB'; // CSS color + this.shadow = true; // whether to display shadow + this.shadowBlur = 15; // px + this.shadowColor = 'rgba(0,0,0,0.2)'; // CSS color + this.pointerColor = '#4C4C4C'; // px + this.pointerBorderColor = '#FFFFFF'; // px + this.pointerBorderWidth = 1; // px + this.pointerThickness = 2; // px + this.zIndex = 1000; + this.container = null; // where to append the color picker (BODY element by default) + + + for (var opt in options) { + if (options.hasOwnProperty(opt)) { + this[opt] = options[opt]; + } + } + + + this.hide = function () { + if (isPickerOwner()) { + detachPicker(); + } + }; + + + this.show = function () { + drawPicker(); + }; + + + this.redraw = function () { + if (isPickerOwner()) { + drawPicker(); + } + }; + + + this.importColor = function () { + if (!this.valueElement) { + this.exportColor(); + } else { + if (jsc.isElementType(this.valueElement, 'input')) { + if (!this.refine) { + if (!this.fromString(this.valueElement.value, jsc.leaveValue)) { + if (this.styleElement) { + this.styleElement.style.backgroundImage = this.styleElement._jscOrigStyle.backgroundImage; + this.styleElement.style.backgroundColor = this.styleElement._jscOrigStyle.backgroundColor; + this.styleElement.style.color = this.styleElement._jscOrigStyle.color; + } + this.exportColor(jsc.leaveValue | jsc.leaveStyle); + } + } else if (!this.required && /^\s*$/.test(this.valueElement.value)) { + this.valueElement.value = ''; + if (this.styleElement) { + this.styleElement.style.backgroundImage = this.styleElement._jscOrigStyle.backgroundImage; + this.styleElement.style.backgroundColor = this.styleElement._jscOrigStyle.backgroundColor; + this.styleElement.style.color = this.styleElement._jscOrigStyle.color; + } + this.exportColor(jsc.leaveValue | jsc.leaveStyle); + + } else if (this.fromString(this.valueElement.value)) { + // managed to import color successfully from the value -> OK, don't do anything + } else { + this.exportColor(); + } + } else { + // not an input element -> doesn't have any value + this.exportColor(); + } + } + }; + + + this.exportColor = function (flags) { + if (!(flags & jsc.leaveValue) && this.valueElement) { + var value = this.toString(); + if (this.uppercase) { value = value.toUpperCase(); } + if (this.hash) { value = '#' + value; } + + if (jsc.isElementType(this.valueElement, 'input')) { + this.valueElement.value = value; + } else { + this.valueElement.innerHTML = value; + } + } + if (!(flags & jsc.leaveStyle)) { + if (this.styleElement) { + var bgColor = '#' + this.toString(); + var fgColor = this.isLight() ? '#000' : '#FFF'; + + this.styleElement.style.backgroundImage = 'none'; + this.styleElement.style.backgroundColor = bgColor; + this.styleElement.style.color = fgColor; + + if (this.overwriteImportant) { + this.styleElement.setAttribute('style', + 'background: ' + bgColor + ' !important; ' + + 'color: ' + fgColor + ' !important;' + ); + } + } + } + if (!(flags & jsc.leavePad) && isPickerOwner()) { + redrawPad(); + } + if (!(flags & jsc.leaveSld) && isPickerOwner()) { + redrawSld(); + } + }; + + + // h: 0-360 + // s: 0-100 + // v: 0-100 + // + this.fromHSV = function (h, s, v, flags) { // null = don't change + if (h !== null) { + if (isNaN(h)) { return false; } + h = Math.max(0, Math.min(360, h)); + } + if (s !== null) { + if (isNaN(s)) { return false; } + s = Math.max(0, Math.min(100, this.maxS, s), this.minS); + } + if (v !== null) { + if (isNaN(v)) { return false; } + v = Math.max(0, Math.min(100, this.maxV, v), this.minV); + } + + this.rgb = HSV_RGB( + h===null ? this.hsv[0] : (this.hsv[0]=h), + s===null ? this.hsv[1] : (this.hsv[1]=s), + v===null ? this.hsv[2] : (this.hsv[2]=v) + ); + + this.exportColor(flags); + }; + + + // r: 0-255 + // g: 0-255 + // b: 0-255 + // + this.fromRGB = function (r, g, b, flags) { // null = don't change + if (r !== null) { + if (isNaN(r)) { return false; } + r = Math.max(0, Math.min(255, r)); + } + if (g !== null) { + if (isNaN(g)) { return false; } + g = Math.max(0, Math.min(255, g)); + } + if (b !== null) { + if (isNaN(b)) { return false; } + b = Math.max(0, Math.min(255, b)); + } + + var hsv = RGB_HSV( + r===null ? this.rgb[0] : r, + g===null ? this.rgb[1] : g, + b===null ? this.rgb[2] : b + ); + if (hsv[0] !== null) { + this.hsv[0] = Math.max(0, Math.min(360, hsv[0])); + } + if (hsv[2] !== 0) { + this.hsv[1] = hsv[1]===null ? null : Math.max(0, this.minS, Math.min(100, this.maxS, hsv[1])); + } + this.hsv[2] = hsv[2]===null ? null : Math.max(0, this.minV, Math.min(100, this.maxV, hsv[2])); + + // update RGB according to final HSV, as some values might be trimmed + var rgb = HSV_RGB(this.hsv[0], this.hsv[1], this.hsv[2]); + this.rgb[0] = rgb[0]; + this.rgb[1] = rgb[1]; + this.rgb[2] = rgb[2]; + + this.exportColor(flags); + }; + + + this.fromString = function (str, flags) { + var m; + if (m = str.match(/^\W*([0-9A-F]{3}([0-9A-F]{3})?)\W*$/i)) { + // HEX notation + // + + if (m[1].length === 6) { + // 6-char notation + this.fromRGB( + parseInt(m[1].substr(0,2),16), + parseInt(m[1].substr(2,2),16), + parseInt(m[1].substr(4,2),16), + flags + ); + } else { + // 3-char notation + this.fromRGB( + parseInt(m[1].charAt(0) + m[1].charAt(0),16), + parseInt(m[1].charAt(1) + m[1].charAt(1),16), + parseInt(m[1].charAt(2) + m[1].charAt(2),16), + flags + ); + } + return true; + + } else if (m = str.match(/^\W*rgba?\(([^)]*)\)\W*$/i)) { + var params = m[1].split(','); + var re = /^\s*(\d*)(\.\d+)?\s*$/; + var mR, mG, mB; + if ( + params.length >= 3 && + (mR = params[0].match(re)) && + (mG = params[1].match(re)) && + (mB = params[2].match(re)) + ) { + var r = parseFloat((mR[1] || '0') + (mR[2] || '')); + var g = parseFloat((mG[1] || '0') + (mG[2] || '')); + var b = parseFloat((mB[1] || '0') + (mB[2] || '')); + this.fromRGB(r, g, b, flags); + return true; + } + } + return false; + }; + + + this.toString = function () { + return ( + (0x100 | Math.round(this.rgb[0])).toString(16).substr(1) + + (0x100 | Math.round(this.rgb[1])).toString(16).substr(1) + + (0x100 | Math.round(this.rgb[2])).toString(16).substr(1) + ); + }; + + + this.toHEXString = function () { + return '#' + this.toString().toUpperCase(); + }; + + + this.toRGBString = function () { + return ('rgb(' + + Math.round(this.rgb[0]) + ',' + + Math.round(this.rgb[1]) + ',' + + Math.round(this.rgb[2]) + ')' + ); + }; + + + this.isLight = function () { + return ( + 0.213 * this.rgb[0] + + 0.715 * this.rgb[1] + + 0.072 * this.rgb[2] > + 255 / 2 + ); + }; + + + this._processParentElementsInDOM = function () { + if (this._linkedElementsProcessed) { return; } + this._linkedElementsProcessed = true; + + var elm = this.targetElement; + do { + // If the target element or one of its parent nodes has fixed position, + // then use fixed positioning instead + // + // Note: In Firefox, getComputedStyle returns null in a hidden iframe, + // that's why we need to check if the returned style object is non-empty + var currStyle = jsc.getStyle(elm); + if (currStyle && currStyle.position.toLowerCase() === 'fixed') { + this.fixed = true; + } + + if (elm !== this.targetElement) { + // Ensure to attach onParentScroll only once to each parent element + // (multiple targetElements can share the same parent nodes) + // + // Note: It's not just offsetParents that can be scrollable, + // that's why we loop through all parent nodes + if (!elm._jscEventsAttached) { + jsc.attachEvent(elm, 'scroll', jsc.onParentScroll); + elm._jscEventsAttached = true; + } + } + } while ((elm = elm.parentNode) && !jsc.isElementType(elm, 'body')); + }; + + + // r: 0-255 + // g: 0-255 + // b: 0-255 + // + // returns: [ 0-360, 0-100, 0-100 ] + // + function RGB_HSV (r, g, b) { + r /= 255; + g /= 255; + b /= 255; + var n = Math.min(Math.min(r,g),b); + var v = Math.max(Math.max(r,g),b); + var m = v - n; + if (m === 0) { return [ null, 0, 100 * v ]; } + var h = r===n ? 3+(b-g)/m : (g===n ? 5+(r-b)/m : 1+(g-r)/m); + return [ + 60 * (h===6?0:h), + 100 * (m/v), + 100 * v + ]; + } + + + // h: 0-360 + // s: 0-100 + // v: 0-100 + // + // returns: [ 0-255, 0-255, 0-255 ] + // + function HSV_RGB (h, s, v) { + var u = 255 * (v / 100); + + if (h === null) { + return [ u, u, u ]; + } + + h /= 60; + s /= 100; + + var i = Math.floor(h); + var f = i%2 ? h-i : 1-(h-i); + var m = u * (1 - s); + var n = u * (1 - s * f); + switch (i) { + case 6: + case 0: return [u,n,m]; + case 1: return [n,u,m]; + case 2: return [m,u,n]; + case 3: return [m,n,u]; + case 4: return [n,m,u]; + case 5: return [u,m,n]; + } + } + + + function detachPicker () { + jsc.unsetClass(THIS.targetElement, THIS.activeClass); + jsc.picker.wrap.parentNode.removeChild(jsc.picker.wrap); + delete jsc.picker.owner; + } + + + function drawPicker () { + + // At this point, when drawing the picker, we know what the parent elements are + // and we can do all related DOM operations, such as registering events on them + // or checking their positioning + THIS._processParentElementsInDOM(); + + if (!jsc.picker) { + jsc.picker = { + owner: null, + wrap : document.createElement('div'), + box : document.createElement('div'), + boxS : document.createElement('div'), // shadow area + boxB : document.createElement('div'), // border + pad : document.createElement('div'), + padB : document.createElement('div'), // border + padM : document.createElement('div'), // mouse/touch area + padPal : jsc.createPalette(), + cross : document.createElement('div'), + crossBY : document.createElement('div'), // border Y + crossBX : document.createElement('div'), // border X + crossLY : document.createElement('div'), // line Y + crossLX : document.createElement('div'), // line X + sld : document.createElement('div'), + sldB : document.createElement('div'), // border + sldM : document.createElement('div'), // mouse/touch area + sldGrad : jsc.createSliderGradient(), + sldPtrS : document.createElement('div'), // slider pointer spacer + sldPtrIB : document.createElement('div'), // slider pointer inner border + sldPtrMB : document.createElement('div'), // slider pointer middle border + sldPtrOB : document.createElement('div'), // slider pointer outer border + btn : document.createElement('div'), + btnT : document.createElement('span') // text + }; + + jsc.picker.pad.appendChild(jsc.picker.padPal.elm); + jsc.picker.padB.appendChild(jsc.picker.pad); + jsc.picker.cross.appendChild(jsc.picker.crossBY); + jsc.picker.cross.appendChild(jsc.picker.crossBX); + jsc.picker.cross.appendChild(jsc.picker.crossLY); + jsc.picker.cross.appendChild(jsc.picker.crossLX); + jsc.picker.padB.appendChild(jsc.picker.cross); + jsc.picker.box.appendChild(jsc.picker.padB); + jsc.picker.box.appendChild(jsc.picker.padM); + + jsc.picker.sld.appendChild(jsc.picker.sldGrad.elm); + jsc.picker.sldB.appendChild(jsc.picker.sld); + jsc.picker.sldB.appendChild(jsc.picker.sldPtrOB); + jsc.picker.sldPtrOB.appendChild(jsc.picker.sldPtrMB); + jsc.picker.sldPtrMB.appendChild(jsc.picker.sldPtrIB); + jsc.picker.sldPtrIB.appendChild(jsc.picker.sldPtrS); + jsc.picker.box.appendChild(jsc.picker.sldB); + jsc.picker.box.appendChild(jsc.picker.sldM); + + jsc.picker.btn.appendChild(jsc.picker.btnT); + jsc.picker.box.appendChild(jsc.picker.btn); + + jsc.picker.boxB.appendChild(jsc.picker.box); + jsc.picker.wrap.appendChild(jsc.picker.boxS); + jsc.picker.wrap.appendChild(jsc.picker.boxB); + } + + var p = jsc.picker; + + var displaySlider = !!jsc.getSliderComponent(THIS); + var dims = jsc.getPickerDims(THIS); + var crossOuterSize = (2 * THIS.pointerBorderWidth + THIS.pointerThickness + 2 * THIS.crossSize); + var padToSliderPadding = jsc.getPadToSliderPadding(THIS); + var borderRadius = Math.min( + THIS.borderRadius, + Math.round(THIS.padding * Math.PI)); // px + var padCursor = 'crosshair'; + + // wrap + p.wrap.style.clear = 'both'; + p.wrap.style.width = (dims[0] + 2 * THIS.borderWidth) + 'px'; + p.wrap.style.height = (dims[1] + 2 * THIS.borderWidth) + 'px'; + p.wrap.style.zIndex = THIS.zIndex; + + // picker + p.box.style.width = dims[0] + 'px'; + p.box.style.height = dims[1] + 'px'; + + p.boxS.style.position = 'absolute'; + p.boxS.style.left = '0'; + p.boxS.style.top = '0'; + p.boxS.style.width = '100%'; + p.boxS.style.height = '100%'; + jsc.setBorderRadius(p.boxS, borderRadius + 'px'); + + // picker border + p.boxB.style.position = 'relative'; + p.boxB.style.border = THIS.borderWidth + 'px solid'; + p.boxB.style.borderColor = THIS.borderColor; + p.boxB.style.background = THIS.backgroundColor; + jsc.setBorderRadius(p.boxB, borderRadius + 'px'); + + // IE hack: + // If the element is transparent, IE will trigger the event on the elements under it, + // e.g. on Canvas or on elements with border + p.padM.style.background = + p.sldM.style.background = + '#FFF'; + jsc.setStyle(p.padM, 'opacity', '0'); + jsc.setStyle(p.sldM, 'opacity', '0'); + + // pad + p.pad.style.position = 'relative'; + p.pad.style.width = THIS.width + 'px'; + p.pad.style.height = THIS.height + 'px'; + + // pad palettes (HSV and HVS) + p.padPal.draw(THIS.width, THIS.height, jsc.getPadYComponent(THIS)); + + // pad border + p.padB.style.position = 'absolute'; + p.padB.style.left = THIS.padding + 'px'; + p.padB.style.top = THIS.padding + 'px'; + p.padB.style.border = THIS.insetWidth + 'px solid'; + p.padB.style.borderColor = THIS.insetColor; + + // pad mouse area + p.padM._jscInstance = THIS; + p.padM._jscControlName = 'pad'; + p.padM.style.position = 'absolute'; + p.padM.style.left = '0'; + p.padM.style.top = '0'; + p.padM.style.width = (THIS.padding + 2 * THIS.insetWidth + THIS.width + padToSliderPadding / 2) + 'px'; + p.padM.style.height = dims[1] + 'px'; + p.padM.style.cursor = padCursor; + + // pad cross + p.cross.style.position = 'absolute'; + p.cross.style.left = + p.cross.style.top = + '0'; + p.cross.style.width = + p.cross.style.height = + crossOuterSize + 'px'; + + // pad cross border Y and X + p.crossBY.style.position = + p.crossBX.style.position = + 'absolute'; + p.crossBY.style.background = + p.crossBX.style.background = + THIS.pointerBorderColor; + p.crossBY.style.width = + p.crossBX.style.height = + (2 * THIS.pointerBorderWidth + THIS.pointerThickness) + 'px'; + p.crossBY.style.height = + p.crossBX.style.width = + crossOuterSize + 'px'; + p.crossBY.style.left = + p.crossBX.style.top = + (Math.floor(crossOuterSize / 2) - Math.floor(THIS.pointerThickness / 2) - THIS.pointerBorderWidth) + 'px'; + p.crossBY.style.top = + p.crossBX.style.left = + '0'; + + // pad cross line Y and X + p.crossLY.style.position = + p.crossLX.style.position = + 'absolute'; + p.crossLY.style.background = + p.crossLX.style.background = + THIS.pointerColor; + p.crossLY.style.height = + p.crossLX.style.width = + (crossOuterSize - 2 * THIS.pointerBorderWidth) + 'px'; + p.crossLY.style.width = + p.crossLX.style.height = + THIS.pointerThickness + 'px'; + p.crossLY.style.left = + p.crossLX.style.top = + (Math.floor(crossOuterSize / 2) - Math.floor(THIS.pointerThickness / 2)) + 'px'; + p.crossLY.style.top = + p.crossLX.style.left = + THIS.pointerBorderWidth + 'px'; + + // slider + p.sld.style.overflow = 'hidden'; + p.sld.style.width = THIS.sliderSize + 'px'; + p.sld.style.height = THIS.height + 'px'; + + // slider gradient + p.sldGrad.draw(THIS.sliderSize, THIS.height, '#000', '#000'); + + // slider border + p.sldB.style.display = displaySlider ? 'block' : 'none'; + p.sldB.style.position = 'absolute'; + p.sldB.style.right = THIS.padding + 'px'; + p.sldB.style.top = THIS.padding + 'px'; + p.sldB.style.border = THIS.insetWidth + 'px solid'; + p.sldB.style.borderColor = THIS.insetColor; + + // slider mouse area + p.sldM._jscInstance = THIS; + p.sldM._jscControlName = 'sld'; + p.sldM.style.display = displaySlider ? 'block' : 'none'; + p.sldM.style.position = 'absolute'; + p.sldM.style.right = '0'; + p.sldM.style.top = '0'; + p.sldM.style.width = (THIS.sliderSize + padToSliderPadding / 2 + THIS.padding + 2 * THIS.insetWidth) + 'px'; + p.sldM.style.height = dims[1] + 'px'; + p.sldM.style.cursor = 'default'; + + // slider pointer inner and outer border + p.sldPtrIB.style.border = + p.sldPtrOB.style.border = + THIS.pointerBorderWidth + 'px solid ' + THIS.pointerBorderColor; + + // slider pointer outer border + p.sldPtrOB.style.position = 'absolute'; + p.sldPtrOB.style.left = -(2 * THIS.pointerBorderWidth + THIS.pointerThickness) + 'px'; + p.sldPtrOB.style.top = '0'; + + // slider pointer middle border + p.sldPtrMB.style.border = THIS.pointerThickness + 'px solid ' + THIS.pointerColor; + + // slider pointer spacer + p.sldPtrS.style.width = THIS.sliderSize + 'px'; + p.sldPtrS.style.height = sliderPtrSpace + 'px'; + + // the Close button + function setBtnBorder () { + var insetColors = THIS.insetColor.split(/\s+/); + var outsetColor = insetColors.length < 2 ? insetColors[0] : insetColors[1] + ' ' + insetColors[0] + ' ' + insetColors[0] + ' ' + insetColors[1]; + p.btn.style.borderColor = outsetColor; + } + p.btn.style.display = THIS.closable ? 'block' : 'none'; + p.btn.style.position = 'absolute'; + p.btn.style.left = THIS.padding + 'px'; + p.btn.style.bottom = THIS.padding + 'px'; + p.btn.style.padding = '0 15px'; + p.btn.style.height = THIS.buttonHeight + 'px'; + p.btn.style.border = THIS.insetWidth + 'px solid'; + setBtnBorder(); + p.btn.style.color = THIS.buttonColor; + p.btn.style.font = '12px sans-serif'; + p.btn.style.textAlign = 'center'; + try { + p.btn.style.cursor = 'pointer'; + } catch(eOldIE) { + p.btn.style.cursor = 'hand'; + } + p.btn.onmousedown = function () { + THIS.hide(); + }; + p.btnT.style.lineHeight = THIS.buttonHeight + 'px'; + p.btnT.innerHTML = ''; + p.btnT.appendChild(document.createTextNode(THIS.closeText)); + + // place pointers + redrawPad(); + redrawSld(); + + // If we are changing the owner without first closing the picker, + // make sure to first deal with the old owner + if (jsc.picker.owner && jsc.picker.owner !== THIS) { + jsc.unsetClass(jsc.picker.owner.targetElement, THIS.activeClass); + } + + // Set the new picker owner + jsc.picker.owner = THIS; + + // The redrawPosition() method needs picker.owner to be set, that's why we call it here, + // after setting the owner + if (jsc.isElementType(container, 'body')) { + jsc.redrawPosition(); + } else { + jsc._drawPosition(THIS, 0, 0, 'relative', false); + } + + if (p.wrap.parentNode != container) { + container.appendChild(p.wrap); + } + + jsc.setClass(THIS.targetElement, THIS.activeClass); + } + + + function redrawPad () { + // redraw the pad pointer + switch (jsc.getPadYComponent(THIS)) { + case 's': var yComponent = 1; break; + case 'v': var yComponent = 2; break; + } + var x = Math.round((THIS.hsv[0] / 360) * (THIS.width - 1)); + var y = Math.round((1 - THIS.hsv[yComponent] / 100) * (THIS.height - 1)); + var crossOuterSize = (2 * THIS.pointerBorderWidth + THIS.pointerThickness + 2 * THIS.crossSize); + var ofs = -Math.floor(crossOuterSize / 2); + jsc.picker.cross.style.left = (x + ofs) + 'px'; + jsc.picker.cross.style.top = (y + ofs) + 'px'; + + // redraw the slider + switch (jsc.getSliderComponent(THIS)) { + case 's': + var rgb1 = HSV_RGB(THIS.hsv[0], 100, THIS.hsv[2]); + var rgb2 = HSV_RGB(THIS.hsv[0], 0, THIS.hsv[2]); + var color1 = 'rgb(' + + Math.round(rgb1[0]) + ',' + + Math.round(rgb1[1]) + ',' + + Math.round(rgb1[2]) + ')'; + var color2 = 'rgb(' + + Math.round(rgb2[0]) + ',' + + Math.round(rgb2[1]) + ',' + + Math.round(rgb2[2]) + ')'; + jsc.picker.sldGrad.draw(THIS.sliderSize, THIS.height, color1, color2); + break; + case 'v': + var rgb = HSV_RGB(THIS.hsv[0], THIS.hsv[1], 100); + var color1 = 'rgb(' + + Math.round(rgb[0]) + ',' + + Math.round(rgb[1]) + ',' + + Math.round(rgb[2]) + ')'; + var color2 = '#000'; + jsc.picker.sldGrad.draw(THIS.sliderSize, THIS.height, color1, color2); + break; + } + } + + + function redrawSld () { + var sldComponent = jsc.getSliderComponent(THIS); + if (sldComponent) { + // redraw the slider pointer + switch (sldComponent) { + case 's': var yComponent = 1; break; + case 'v': var yComponent = 2; break; + } + var y = Math.round((1 - THIS.hsv[yComponent] / 100) * (THIS.height - 1)); + jsc.picker.sldPtrOB.style.top = (y - (2 * THIS.pointerBorderWidth + THIS.pointerThickness) - Math.floor(sliderPtrSpace / 2)) + 'px'; + } + } + + + function isPickerOwner () { + return jsc.picker && jsc.picker.owner === THIS; + } + + + function blurValue () { + THIS.importColor(); + } + + + // Find the target element + if (typeof targetElement === 'string') { + var id = targetElement; + var elm = document.getElementById(id); + if (elm) { + this.targetElement = elm; + } else { + jsc.warn('Could not find target element with ID \'' + id + '\''); + } + } else if (targetElement) { + this.targetElement = targetElement; + } else { + jsc.warn('Invalid target element: \'' + targetElement + '\''); + } + + if (this.targetElement._jscLinkedInstance) { + jsc.warn('Cannot link jscolor twice to the same element. Skipping.'); + return; + } + this.targetElement._jscLinkedInstance = this; + + // Find the value element + this.valueElement = jsc.fetchElement(this.valueElement); + // Find the style element + this.styleElement = jsc.fetchElement(this.styleElement); + + var THIS = this; + var container = + this.container ? + jsc.fetchElement(this.container) : + document.getElementsByTagName('body')[0]; + var sliderPtrSpace = 3; // px + + // For BUTTON elements it's important to stop them from sending the form when clicked + // (e.g. in Safari) + if (jsc.isElementType(this.targetElement, 'button')) { + if (this.targetElement.onclick) { + var origCallback = this.targetElement.onclick; + this.targetElement.onclick = function (evt) { + origCallback.call(this, evt); + return false; + }; + } else { + this.targetElement.onclick = function () { return false; }; + } + } + + /* + var elm = this.targetElement; + do { + // If the target element or one of its offsetParents has fixed position, + // then use fixed positioning instead + // + // Note: In Firefox, getComputedStyle returns null in a hidden iframe, + // that's why we need to check if the returned style object is non-empty + var currStyle = jsc.getStyle(elm); + if (currStyle && currStyle.position.toLowerCase() === 'fixed') { + this.fixed = true; + } + + if (elm !== this.targetElement) { + // attach onParentScroll so that we can recompute the picker position + // when one of the offsetParents is scrolled + if (!elm._jscEventsAttached) { + jsc.attachEvent(elm, 'scroll', jsc.onParentScroll); + elm._jscEventsAttached = true; + } + } + } while ((elm = elm.offsetParent) && !jsc.isElementType(elm, 'body')); + */ + + // valueElement + if (this.valueElement) { + if (jsc.isElementType(this.valueElement, 'input')) { + var updateField = function () { + THIS.fromString(THIS.valueElement.value, jsc.leaveValue); + jsc.dispatchFineChange(THIS); + }; + jsc.attachEvent(this.valueElement, 'keyup', updateField); + jsc.attachEvent(this.valueElement, 'input', updateField); + jsc.attachEvent(this.valueElement, 'blur', blurValue); + this.valueElement.setAttribute('autocomplete', 'off'); + } + } + + // styleElement + if (this.styleElement) { + this.styleElement._jscOrigStyle = { + backgroundImage : this.styleElement.style.backgroundImage, + backgroundColor : this.styleElement.style.backgroundColor, + color : this.styleElement.style.color + }; + } + + if (this.value) { + // Try to set the color from the .value option and if unsuccessful, + // export the current color + this.fromString(this.value) || this.exportColor(); + } else { + this.importColor(); + } + } + +}; + + +//================================ +// Public properties and methods +//================================ + + +// By default, search for all elements with class="jscolor" and install a color picker on them. +// +// You can change what class name will be looked for by setting the property jscolor.lookupClass +// anywhere in your HTML document. To completely disable the automatic lookup, set it to null. +// +jsc.jscolor.lookupClass = 'jscolor'; + + +jsc.jscolor.installByClassName = function (className) { + var inputElms = document.getElementsByTagName('input'); + var buttonElms = document.getElementsByTagName('button'); + + jsc.tryInstallOnElements(inputElms, className); + jsc.tryInstallOnElements(buttonElms, className); +}; + + +jsc.register(); + + +return jsc.jscolor; + + +})(); } diff --git a/indego4shng/webif/templates/index.html b/indego4shng/webif/templates/index.html new file mode 100755 index 000000000..53758c38b --- /dev/null +++ b/indego4shng/webif/templates/index.html @@ -0,0 +1,616 @@ +{% extends "base.html" %} + +{% block title %} +{{ p.get_fullname() }} Plugin +{% endblock title %} + + +{% block body_attribs -%} + {% if body_attribs is defined %} {{ body_attribs }}" + {% endif %} +{%- endblock body_attribs %} + +{% block scripts -%} +{{ super() }} + + + + + + + + + + +{%- endblock scripts %} + +{% block content -%} + +{% if scroll_heading is not defined %} +
+{% endif %} + +
+
+ +
+ {% if isfile("static/img/plugin_logo.png") %} + plugin_logo + {% elif isfile("static/img/plugin_logo.jpg") %} + plugin_logo + {% elif isfile("static/img/plugin_logo.svg") %} + plugin_logo + {% else %} + {% set plgtype_logo = "/gstatic/img/plugin_"+p._plgtype+".svg" %} + {% if isfile(plgtype_logo) %} + + {% else %} + plugin_logo_unknown + {% endif %} + {% endif %} +
+
+ +
+
+
+
{{ _('Plugin') }} : {{ p.get_shortname() }} v{{ p.get_version() }}
+ {% if p.get_instance_name() != '' %} +
{{ _('Instanz') }}: {{ p.get_instance_name() }}
+ {% else %} +
+ {% endif %} +
{{ _('Plugin') }}     : {% if p.alive %}{{ _('Aktiv') }}{% else %}{{ _('Gestoppt') }}{% endif %}
+
+ +
+ + {% block headtable %} + + + + + + + + + + + + + + + +
Login-State + {% if p.logged_in %} + {{ _('logged in') }} + {{ _('logged in') }} + {% else %} + {{ _('logged out') }} + {{ _('logged off') }} + {% endif %} + +
Session-ID{{ p.context_id }}
{{_('last Login / Login Experiation')}}:{{ myLastLogin }} / {{ myExperitation_Time }}
+ {% endblock %} + + + + + + + +
+ {% block buttons %} + {% endblock buttons %} + + + + + + +
+
+
+
+ +{% if scroll_heading is not defined %} +
+ +
+{% endif %} + +{% if tabcount is not defined %} + {% set tabcount = 4 %} +{% endif %} + +{% if start_tab is not defined %} + {% set start_tab = 1 %} +{% endif %} +{% if start_tab > tabcount %} + {% set start_tab = tabcount %} +{% endif %} + +{% if tabcount > 6 %} + {% set tabcount = 6 %} +{% endif %} +{% if tabcount < 1 %} + {% set tabcount = 1 %} +{% endif %} + + +{% set tab1title = "Items (" ~ item_count ~ ")" %} +{% set tab2title = _('Gartenkarte/Settings') %} +{% set tab3title = _('State-Protokoll') %} +{% set tab4title = _('Kommunikations-Protokoll') %} + + +{% if tab1title is not defined %} + {% set tab1title = "" ~ p.get_shortname() ~ " tab1" %} +{% endif %} +{% if tab2title is not defined %} + {% set tab2title = "" ~ p.get_shortname() ~ " tab2" %} +{% endif %} +{% if tab3title is not defined %} + {% set tab3title = "" ~ p.get_shortname() ~ " tab3" %} +{% endif %} +{% if tab4title is not defined %} + {% set tab4title = "" ~ p.get_shortname() ~ " tab4" %} +{% endif %} +{% if tab5title is not defined %} + {% set tab5title = "" ~ p.get_shortname() ~ " tab5" %} +{% endif %} +{% if tab6title is not defined %} + {% set tab6title = "" ~ p.get_shortname() ~ " tab6" %} +{% endif %} + + +
+ +
+ +
+ +
+ {% block bodytab1 %} + +
+ + + + + + + + + + {% for item in items %} + + + + + + {% endfor %} + +
{{ _('Item') }}{{ _('Typ') }}{{ _('Wert') }}
{{ item._path }}{{ item._type }}{{ item() }}
+
+ + {% endblock bodytab1 %} +
+ +
+ {% block bodytab2 %} +
+
+ + + + + + + + + + + + + + + + + + + + + +
{{ _('Credentials') }} + + + + +
+ + +
+
+ + + {{ _('encoded Cred.:') }} + + +
+ {{ _('Protokoll :') }} + + +
+ {{ _('zusätzliche Garden-Map Vektoren') }} + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ _('Original-Karte') }}{{ _('Settings') }}{{ _('Wert') }}
+ {{ myMap }} + {{ _('Farbwert für Mäher') }} + +
State-Triggers
State-Event für Trigger 1: + +
State-Event für Trigger 2: + +
State-Event für Trigger 3: + +
State-Event für Trigger 4: + +
Alarm-Triggers
Alarm-Event für Trigger 1: + +
Alarm-Event für Trigger 2: + +
Alarm-Event für Trigger 3: + +
Alarm-Event für Trigger 4: + +
Location-Settings using : {{ myText }}
latitude: + +
longitude: + +
+ + + +
+ + +
+ + + + + {% endblock bodytab2 %} +
+ +
+ {% block bodytab3 %} + + + + + + + +
+ + +
+ + +
+
+
+
+ + + {% if state_log_lines %}{% else %}{{ _('no data available') }}{% endif %} +
+
+
+ + + +
+
+
+ + + {% endblock bodytab3 %} +
+ +
+ {% block bodytab4 %} + + + + + + + +
+ + +
+ + +
+
+ +
+
+ {% if com_log_lines %}{% else %}{{ _('no data available') }}{% endif %} +
+
+ + + {% endblock bodytab4 %} +
+ +
+ {% block bodytab5 %} + {% endblock bodytab5 %} +
+ +
+ {% block bodytab6 %} + {% endblock bodytab6 %} +
+
+ + +{%- endblock content %} + +{% if scroll_heading is not defined %} +
+{% endif %} diff --git a/influxdata/plugin.yaml b/influxdata/plugin.yaml index 2431ea364..f4387be67 100755 --- a/influxdata/plugin.yaml +++ b/influxdata/plugin.yaml @@ -16,6 +16,7 @@ plugin: # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance restartable: unknown + startorder: early # set start priority of plugin (early/normal/late) classname: InfluxData # class containing the plugin parameters: diff --git a/influxdb/plugin.yaml b/influxdb/plugin.yaml index 44ed82eaf..23c81a2aa 100755 --- a/influxdb/plugin.yaml +++ b/influxdb/plugin.yaml @@ -20,6 +20,7 @@ plugin: # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance restartable: unknown + startorder: early # set start priority of plugin (early/normal/late) classname: InfluxDB # class containing the plugin parameters: @@ -77,7 +78,7 @@ parameters: valid_max: 65535 description: de: "Portnummer der InfluxData Datenbank für HTTP-Zugriff" - en: "Port of the InfluxData database for HTTP access" + en: "Port of the InfluxData database for HTTP access" item_attributes: # Definition of item attributes defined by this plugin diff --git a/influxdb2/plugin.yaml b/influxdb2/plugin.yaml index 1277b4f9e..89e12e4ea 100755 --- a/influxdb2/plugin.yaml +++ b/influxdb2/plugin.yaml @@ -10,7 +10,7 @@ plugin: state: develop # change to ready when done with development # keywords: iot xyz # documentation: https://github.com/smarthomeNG/smarthome/wiki/CLI-Plugin # url of documentation (wiki) page -# support: https://knx-user-forum.de/forum/supportforen/smarthome-py + support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1498207-support-thread-für-influxdb-plugin version: 0.1.0 # Plugin version (must match the version specified in __init__.py) sh_minversion: 1.9 # minimum shNG version to use this plugin @@ -19,6 +19,7 @@ plugin: # py_maxversion: # maximum Python version to use for this plugin (leave empty if latest) multi_instance: false # plugin supports multi instance restartable: unknown + startorder: early # set start priority of plugin (early/normal/late) classname: InfluxDB2 # class containing the plugin parameters: diff --git a/influxdb2/user_doc.rst b/influxdb2/user_doc.rst index 51cc4e7da..e07548b0b 100755 --- a/influxdb2/user_doc.rst +++ b/influxdb2/user_doc.rst @@ -1,5 +1,6 @@ .. index:: Plugins; influxdb2 +.. index:: influxdb2 .. index:: InfluxDB; influxdb2 Plugin ========= diff --git a/join/__init__.py b/join/__init__.py index 43425fa4f..d8951ba82 100755 --- a/join/__init__.py +++ b/join/__init__.py @@ -31,7 +31,7 @@ class Join(SmartPlugin): SEND_URL = URL_PREFIX+'messaging/v1/sendPush?apikey=' LIST_URL = URL_PREFIX+'registration/v1/listDevices?apikey=' - PLUGIN_VERSION = "1.4.2" + PLUGIN_VERSION = "1.4.3" def __init__(self, sh): @@ -55,7 +55,7 @@ def stop(self): def send(self, title=None, text=None, icon=None, find=None, smallicon=None, device_id=None, device_ids=None, device_names=None, url=None, image=None, sound=None, group=None, clipboard=None, file=None, callnumber=None, smsnumber=None, smstext=None, mmsfile=None, wallpaper=None, lockWallpaper=None, - interruptionFilter=None, mediaVolume=None, ringVolume=None, alarmVolume=None): + interruptionFilter=None, mediaVolume=None, ringVolume=None, alarmVolume=None, say=None, language=None): req_url = self.SEND_URL + self._api_key if title: req_url += "&title=" + title @@ -107,6 +107,10 @@ def send(self, title=None, text=None, icon=None, find=None, smallicon=None, devi req_url += "&deviceId=" + device_id else: req_url += "&deviceId=" + self._device_id + if say: + req_url += "&say=" + say + if language: + req_url += "&language=" + language self.logger.debug(req_url) try: requests.get(req_url) diff --git a/join/plugin.yaml b/join/plugin.yaml index 67c6a6457..7f767833d 100755 --- a/join/plugin.yaml +++ b/join/plugin.yaml @@ -12,7 +12,7 @@ plugin: documentation: http://smarthomeng.de/user/plugins_doc/config/join.html # url of documentation (wiki) page support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1113523-neues-plugin-join-tts-sms-phonecall-notification-uvm - version: 1.4.2 # Plugin version + version: 1.4.3 # Plugin version sh_minversion: 1.4 # minimum shNG version to use this plugin #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance diff --git a/jsonread/requirements.txt b/jsonread/requirements.txt index 1f25e895a..321b796ed 100755 --- a/jsonread/requirements.txt +++ b/jsonread/requirements.txt @@ -1,7 +1,8 @@ requests requests-file # pyjq: -# The most recent version (2.5.2 released May 2021) is not compatible with Python 3.10 +# The most recent version (2.6 released August 2022) is compatible with Python 3.10 +pyjq +# The version (2.5.2 released May 2021) is not compatible with Python 3.10 # The project's README states, that the project is deprecated in favor Slixmpp (a fork of sleekxmpp). -#pyjq -pyjq;python_version<'3.10' +#pyjq;python_version<'3.10' diff --git a/knx/__init__.py b/knx/__init__.py index 8b31c5332..790d811a1 100755 --- a/knx/__init__.py +++ b/knx/__init__.py @@ -41,43 +41,13 @@ from . import dpts from . import knxproj from .knxd import KNXD +from .globals import * +from .webif import WebInterface -# WebIf -from lib.model.smartplugin import SmartPluginWebIf -import cherrypy -import os - -KNXD_CACHEREAD_DELAY = 0.35 -KNXD_CACHEREAD_DELAY = 0.0 - -KNX_DATA_MASK = 0b00111111 # 0x3f up to 6 bits form data content -KNX_FLAG_MASK = 0b11000000 # 0xC0 -FLAG_KNXREAD = 0b00000000 # 0x00 -FLAG_KNXRESPONSE = 0b01000000 # 0x40 -FLAG_KNXWRITE = 0b10000000 # 0x80 -FLAG_RESERVED = 0b11000000 # 0xC0 none of the above flags, one need to examine the previous byte for lowest two bits then - -# attribute keywords -KNX_DPT = 'knx_dpt' # data point type -KNX_STATUS = 'knx_status' # status -KNX_SEND = 'knx_send' # send changes within SmartHomeNG to this ga -KNX_REPLY = 'knx_reply' # answer read requests from knx with item value from SmartHomeNG -KNX_CACHE = 'knx_cache' # get item from knx_cache -KNX_INIT = 'knx_init' # query knx upon init -KNX_LISTEN = 'knx_listen' # write or response from knx will change the value of this item -KNX_POLL = 'knx_poll' # query (poll) a ga on knx in regular intervals - -KNX_DTP = 'knx_dtp' # often misspelled argument in config files, instead should be knx_dpt - -ITEM = 'item' -ITEMS = 'items' -LOGIC = 'logic' -LOGICS = 'logics' -DPT = 'dpt' class KNX(SmartPlugin): - PLUGIN_VERSION = "1.8.2" + PLUGIN_VERSION = "1.8.5" # tags actually used by the plugin are shown here # can be used later for backend item editing purposes, to check valid item attributes @@ -113,7 +83,7 @@ def __init__(self, smarthome): self._cache_ga = [] # group addresses which should be initalized by the knxd cache self._cache_ga_response_pending = [] # group adresses for which a read request was sent to knxd self._cache_ga_response_no_value = [] # group adresses for which a response from knxd did not provide a value - + self.time_ga = self.get_parameter_value('time_ga') self.date_ga = self.get_parameter_value('date_ga') self._send_time_do = self.get_parameter_value('send_time') @@ -201,7 +171,7 @@ def groupwrite(self, ga, payload, dpt, flag='write'): try: pkt.extend(self.encode(ga, 'ga')) except: - self.logger.warning(self.translate('problem encoding ga: {}').format(ga)) + self.logger.warning('groupwrite: ' + self.translate("problem encoding ga: {}").format(ga)) return pkt.extend([0]) try: @@ -231,7 +201,7 @@ def _cacheread(self, ga): try: pkt.extend(self.encode(ga, 'ga')) except: - self.logger.warning(self.translate('problem encoding ga: {}').format(ga)) + self.logger.warning("_cacheread: " + self.translate('problem encoding ga: {}').format(ga)) return pkt.extend([0, 0]) if self.logger.isEnabledFor(logging.DEBUG): @@ -243,7 +213,7 @@ def groupread(self, ga): try: pkt.extend(self.encode(ga, 'ga')) except: - self.logger.warning(self.translate('problem encoding ga: {}').format(ga)) + self.logger.warning("groupread: " + self.translate('problem encoding ga: {}').format(ga)) return pkt.extend([0, FLAG_KNXREAD]) self._send(pkt) @@ -303,9 +273,10 @@ def handle_connect(self, client): for ga in self._cache_ga: self._cache_ga_response_pending.append(ga) for ga in self._cache_ga: - self._cacheread(ga) - # wait a little to not overdrive the knxd unless there is a fix - time.sleep(KNXD_CACHEREAD_DELAY) + if ga != '': + self._cacheread(ga) + # wait a little to not overdrive the knxd unless there is a fix + time.sleep(KNXD_CACHEREAD_DELAY) self._cache_ga = [] if self.logger.isEnabledFor(logging.DEBUG): self.logger.debug(self.translate('finished reading knxd cache')) @@ -368,7 +339,7 @@ def parse_knxd_message(self, client, data): # expecting the type of the following knxd telegram as an unsigned short integer knxd_msg_type = struct.unpack(">H", data[0:2])[0] - # knxd + # knxd if not knxd_msg_type in [KNXD.GROUP_PACKET, KNXD.CACHE_READ, KNXD.CACHE_READ_NOWAIT]: self.handle_other_knxd_messages(knxd_msg_type, data[2:]) return @@ -381,7 +352,7 @@ def parse_knxd_message(self, client, data): 2 byte source as physical address 2 byte destination as group address 2 byte command/data - n byte data optional, only indicated by length + n byte data optional, only indicated by length """ # knxd will only deliver 4 bytes and no command/data payload when it is unable to provide a group address from cache. @@ -421,7 +392,7 @@ def parse_knxd_message(self, client, data): else: self.logger.warning("Unknown flag: {:02x} src: {} dest: {}".format(flg, src, dst)) return - + if len(knx_data) == 6: payload = bytearray([knx_data[5] & KNX_DATA_MASK ]) # 0x3f else: @@ -629,7 +600,8 @@ def parse_item(self, item): else: if item not in self.gal[ga][ITEMS]: self.gal[ga][ITEMS].append(item) - self._cache_ga.append(ga) + if ga != '': + self._cache_ga.append(ga) if self.has_iattr(item.conf, KNX_REPLY): knx_reply = self.get_iattr_value(item.conf, KNX_REPLY) @@ -878,166 +850,3 @@ def get_unsatisfied_cache_read_ga(self): :return: list of group addresses that did not receive a cache read response """ return self._cache_ga_response_pending - - -# ------------------------------------------ -# Webinterface of the plugin -# ------------------------------------------ - - -class WebInterface(SmartPluginWebIf): - - def __init__(self, webif_dir, plugin): - """ - Initialization of instance of class WebInterface - - :param webif_dir: directory where the webinterface of the plugin resides - :param plugin: instance of the plugin - :type webif_dir: str - :type plugin: object - """ - self.logger = plugin.logger - self.webif_dir = webif_dir - self.plugin = plugin - self.items = Items.get_instance() - self.last_upload = "" - - self.tplenv = self.init_template_environment() - self.knxdaemon = '' - if os.name != 'nt': - if self.get_process_info("ps cax|grep eibd") != '': - self.knxdaemon = 'eibd' - if self.get_process_info("ps cax|grep knxd") != '': - if self.knxdaemon != '': - self.knxdaemon += ' and ' - self.knxdaemon += 'knxd' - else: - self.knxdaemon = 'can not be determined when running on Windows' - - def get_process_info(self, command): - """ - returns output from executing a given command via the shell. - """ - ## get subprocess module - import subprocess - - ## call date command ## - p = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True) - - # Talk with date command i.e. read data from stdout and stderr. Store this info in tuple ## - # Interact with process: Send data to stdin. Read data from stdout and stderr, until end-of-file is reached. - # Wait for process to terminate. The optional input argument should be a string to be sent to the child process, or None, if no data should be sent to the child. - (result, err) = p.communicate() - - ## Wait for date to terminate. Get return returncode ## - p_status = p.wait() - return str(result, encoding='utf-8', errors='strict') - - - @cherrypy.expose - def index(self, reload=None, knxprojfile=None, password=None): - """ - Build index.html for cherrypy - Render the template and return the html file to be delivered to the browser - :return: contents of the template after beeing rendered - """ - if password is not None: - if password != '': - self.plugin.project_file_password = password - self.logger.debug("Set password for knxproj file") - else: - self.logger.debug("Provided password is empty, will not replace the saved password") - - # if given knxprojfile then this is an upload - if self.plugin.use_project_file and knxprojfile is not None: - size = 0 - # ``knxprojfile.file`` is a memory file prepared by cherrypy, - # it could however be ``None``` if no valid file was uploaded by html page - if knxprojfile.file is not None: - with open(self.plugin.projectpath, 'wb') as out: - while True: - data = knxprojfile.file.read(8192) - if not data: - break - out.write(data) - size += len(data) - self.last_upload = "File received.\nFilename: {}\nLength: {}\nMime-type: {}\n".format(knxprojfile.filename, size, knxprojfile.content_type) - self.logger.debug(f"Uploaded projectfile {knxprojfile.filename} with {size} bytes") - self.plugin._parse_projectfile() - else: - self.logger.error(f"Could not upload projectfile {knxprojfile}") - - plgitems = [] - for item in self.items.return_items(): - if any(elem in item.property.attributes for elem in [KNX_DPT, KNX_STATUS, KNX_SEND, KNX_REPLY, KNX_CACHE, KNX_INIT, KNX_LISTEN, KNX_POLL]): - plgitems.append(item) - - # build a dict with groupaddress as key to items and their attributes - # ga_usage_by_Item = { '0/1/2' : { ItemA : { attribute1 : True, attribute2 : True }, - # ItemB : { attribute1 : True, attribute2 : True }}, ...} - # ga_usage_by_Attrib={ '0/1/2' : { attribut1 : { ItemA : True, ItemB : True }, - # attribut2 : { ItemC : True, ItemD : True }}, ...} - ga_usage_by_Item = {} - ga_usage_by_Attrib = {} - for item in plgitems: - for elem in [KNX_DPT, KNX_STATUS, KNX_SEND, KNX_REPLY, KNX_CACHE, KNX_INIT, KNX_LISTEN, KNX_POLL]: - if elem in item.property.attributes: - value = self.plugin.get_iattr_value(item.conf, elem) - # value might be a list or a string here - if isinstance(value, str): - values = [value] - else: - values = value - for ga in values: - # create ga_usage_by_Item entries - if ga not in ga_usage_by_Item: - ga_usage_by_Item[ga] = {} - if item not in ga_usage_by_Item[ga]: - ga_usage_by_Item[ga][item] = {} - ga_usage_by_Item[ga][item][elem] = True - - # create ga_usage_by_Attrib entries - if ga not in ga_usage_by_Attrib: - ga_usage_by_Attrib[ga] = {} - if item not in ga_usage_by_Attrib[ga]: - ga_usage_by_Attrib[ga][elem] = {} - ga_usage_by_Attrib[ga][elem][item] = True - - tmpl = self.tplenv.get_template('index.html') - # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) - return tmpl.render(p=self.plugin, - items=sorted(plgitems, key=lambda k: str.lower(k['_path'])), - knxdaemon=self.knxdaemon, - stats_ga=self.plugin.get_stats_ga(), stats_ga_list=sorted(self.plugin.get_stats_ga(), key=lambda k: str(int(k.split('/')[0]) + 100) + str(int(k.split('/')[1]) + 100) + str(int(k.split('/')[2]) + 1000)), - stats_pa=self.plugin.get_stats_pa(), stats_pa_list=sorted(self.plugin.get_stats_pa(), key=lambda k: str(int(k.split('.')[0]) + 100) + str(int(k.split('.')[1]) + 100) + str(int(k.split('.')[2]) + 1000)), - last_upload=self.last_upload, - ga_usage_by_Item=ga_usage_by_Item, - ga_usage_by_Attrib=ga_usage_by_Attrib, - knx_attribs=[KNX_DPT, KNX_STATUS, KNX_SEND, KNX_REPLY, KNX_CACHE, KNX_INIT, KNX_LISTEN, KNX_POLL] - ) - - - @cherrypy.expose - def get_data_html(self, dataSet=None): - """ - Return data to update the webpage - - For the standard update mechanism of the web interface, the dataSet to return the data for is None - - :param dataSet: Dataset for which the data should be returned (standard: None) - :return: dict with the data needed to update the web page. - """ - if dataSet is None: - # get the new data - data = {} - - # data['item'] = {} - # for i in self.plugin.items: - # data['item'][i]['value'] = self.plugin.getitemvalue(i) - # - # return it as json the the web page - # try: - # return json.dumps(data) - # except Exception as e: - # self.logger.error("get_data_html exception: {}".format(e)) - return {} diff --git a/knx/globals.py b/knx/globals.py new file mode 100755 index 000000000..6eb5cfa72 --- /dev/null +++ b/knx/globals.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2012-2013 Marcus Popp marcus@popp.mx +# Copyright 2016- Christian Strassburg c.strassburg@gmx.de +# Copyright 2017- Serge Wagener serge@wagener.family +# Copyright 2017-2022 Bernd Meiners Bernd.Meiners@mail.de +######################################################################### +# This file is part of SmartHomeNG.py. +# Visit: https://github.com/smarthomeNG/ +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# SmartHomeNG.py is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG.py is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG.py. If not, see . +######################################################################### + +""" Global definitions of constants and functions for KNX plugin """ + +KNXD_CACHEREAD_DELAY = 0.35 +KNXD_CACHEREAD_DELAY = 0.0 + +KNX_DATA_MASK = 0b00111111 # 0x3f up to 6 bits form data content +KNX_FLAG_MASK = 0b11000000 # 0xC0 +FLAG_KNXREAD = 0b00000000 # 0x00 +FLAG_KNXRESPONSE = 0b01000000 # 0x40 +FLAG_KNXWRITE = 0b10000000 # 0x80 +FLAG_RESERVED = 0b11000000 # 0xC0 none of the above flags, one need to examine the previous byte for lowest two bits then + +# attribute keywords +KNX_DPT = 'knx_dpt' # data point type +KNX_STATUS = 'knx_status' # status +KNX_SEND = 'knx_send' # send changes within SmartHomeNG to this ga +KNX_REPLY = 'knx_reply' # answer read requests from knx with item value from SmartHomeNG +KNX_CACHE = 'knx_cache' # get item from knx_cache +KNX_INIT = 'knx_init' # query knx upon init +KNX_LISTEN = 'knx_listen' # write or response from knx will change the value of this item +KNX_POLL = 'knx_poll' # query (poll) a ga on knx in regular intervals + +KNX_DTP = 'knx_dtp' # often misspelled argument in config files, instead should be knx_dpt + +ITEM = 'item' +ITEMS = 'items' +LOGIC = 'logic' +LOGICS = 'logics' +DPT = 'dpt' diff --git a/knx/plugin.yaml b/knx/plugin.yaml index e7ea47a43..5de1eb23f 100755 --- a/knx/plugin.yaml +++ b/knx/plugin.yaml @@ -10,11 +10,10 @@ plugin: tester: psilo909, onkelandy, Sandman60, brandst state: qa-passed keywords: KNX knxd listen cache bus - documentation: http://smarthomeng.de/user/plugins/knx/user_doc.html support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1552531-support-thread-zum-knx-plugin sh_minversion: 1.9.0 # minimum shNG version to use this plugin - version: 1.8.2 # Plugin version + version: 1.8.5 # Plugin version # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: true # plugin supports multi instance restartable: true @@ -81,8 +80,8 @@ parameters: en: ['DEBUG', 'WARNING', 'INFO', 'ERROR', 'NOTSET'] description: de: > - Beim Start von SmartHomeNG werden Items mit dem Attribut knx_cache direkt vom knxd eingelesen. - Wenn die Werte der Gruppenadressen nicht verfügbar sind, liefert der knxd ein fehlerhaftes Paket. + Beim Start von SmartHomeNG werden Items mit dem Attribut knx_cache direkt vom knxd eingelesen. + Wenn die Werte der Gruppenadressen nicht verfügbar sind, liefert der knxd ein fehlerhaftes Paket. Mit dem hier festgelegten Loglevel wird dieses Paket protokolliert. en: > At start of SmartHomeNG items with attribute knx_cache will be read directly from knxd. @@ -293,14 +292,14 @@ item_attributes: knx_init: type: knx_ga description: - de: 'Gruppenadresse für die Initialisierung des Items. Der KNX-Bus wird nach dem Wert abgefragt. Die Angabe impliziert knx_listen, also muss knx_listen nicht für diese Gruppenadresse angegeben werden. Wie bei knx_listen kann eine Liste von GAs angegeben werden. Dann wird der erste Eintrag der Liste für die Initialisierung verwendet.' - en: 'Group address to use for initialization of the item. The KNX bus is queried for the value. It implies knx_listen, so knx_listen need not to be specified for this group address. Like for knx_listen a list of GAs can be specified. In this case, the first list entry is used to read the KNX cache.' + de: 'Gruppenadresse für die Initialisierung des Items. Der KNX-Bus wird nach dem Wert abgefragt. Die Angabe impliziert knx_listen, also muss knx_listen nicht für diese Gruppenadresse angegeben werden.' + en: 'Group address to use for initialization of the item. The KNX bus is queried for the value. It implies knx_listen, so knx_listen need not to be specified for this group address.' knx_cache: type: knx_ga description: - de: 'Gruppenadresse für die Initialisierung des Items. Der Wert wird aus dem Cache von KNXD/EIBD gelesen. Wenn kein Cache-Wert vorhanden ist, wird der KNX-Bus nach dem Wert abgefragt. Die Angabe impliziert knx_listen, also muss knx_listen nicht für diese Gruppenadresse angegeben werden. Wie bei knx_listen kann eine Liste von GAs angegeben werden. Dann wird der erste Eintrag der Liste für die Initialisierung verwendet.' - en: 'Group address to use for initialization of the item. The value is read from the cache of KNXD/EIBD. If no cached value exists, the KNX bus is queried for the value. It implies knx_listen, so knx_listen need not to be specified for this group address. Like for knx_listen a list of GAs can be specified. In this case, the first list entry is used to read the KNX cache.' + de: 'Gruppenadresse für die Initialisierung des Items. Der Wert wird aus dem Cache von KNXD/EIBD gelesen. Wenn kein Cache-Wert vorhanden ist, wird der KNX-Bus nach dem Wert abgefragt. Die Angabe impliziert knx_listen, also muss knx_listen nicht für diese Gruppenadresse angegeben werden.' + en: 'Group address to use for initialization of the item. The value is read from the cache of KNXD/EIBD. If no cached value exists, the KNX bus is queried for the value. It implies knx_listen, so knx_listen need not to be specified for this group address.' knx_reply: type: list(knx_ga) diff --git a/knx/user_doc.rst b/knx/user_doc.rst index 76a0c72f4..966ab6a9e 100755 --- a/knx/user_doc.rst +++ b/knx/user_doc.rst @@ -1,8 +1,16 @@ .. index:: Plugins; knx (KNX Bus Unterstützung) .. index:: knx +=== knx -### +=== + +.. image:: webif/static/img/plugin_logo.svg + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left Konfiguration ============= @@ -126,21 +134,21 @@ Umwandlungen der Datentypen in Itemwerte ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Die Umwandlungen der Werte vom KNX in einen Itemwert und von Itemwerte zum KNX entsprechen den Festlegungen -des Dokumentes **03_07_02_Datapoint Types v02.01.02 AS** der **KNX System specifications**. +des Dokuments **03_07_02_Datapoint Types v02.01.02 AS** der **KNX System specifications**. Es gibt Situationen wo der KNX Werte liefern kann die nicht als Itemwert zugelassen sind. -Ein Beispiel dafür ist der Datenpunkt Typ 14 der eine 4 Byte umfassende Fliesskommazahl codiert. +Ein Beispiel dafür ist der Datenpunkt Typ 14 der eine 4 Byte umfassende Fließkommazahl codiert. Werte die ungültig sind und vom KNX geliefert werden entsprechen in Python einem Wert ``NaN``. Da dieser Wert (Not a Number) in Items von SmartHomeNG nicht zugelassen ist wird die Zuweisung auf ein Item unterdrückt und eine Warnung in das entsprechende Logfile geschrieben (wenn konfiguriert) Beispiele ---------- +========= **ToDo ...** Statistiken ------------ +=========== Die Statistikfunktionen wurden eingebaut um zu sehen, was dauerhaft am KNX passiert. Es wird aufgezeichnet welches Gerät (physikalische Adresse) Gruppenadresse als Leseanforderung abfragt oder als Schreibanforderung einen neuen Wert sendet. @@ -153,7 +161,7 @@ Auf diese Weise werden folgende Fragestellungen beantwortet: Web Interface -------------- +============= Das Plugin Webinterface kann aus dem Admin Interface aufgerufen werden. Dazu auf der Seite Plugins in der entsprechenden Zeile das Icon in der Spalte **Web Interface** anklicken. @@ -174,14 +182,14 @@ Der zweite Tab zeigt Statistiken zu den Gruppenadressen: Wenn es Items gibt, die mit dem Attribut ``knx_cache`` und einer Gruppenadresse konfiguriert wurden wird SmartHomeNG beim Start diese Gruppenadressen vom knxd abfragen. - Wenn die Werte der Gruppenadressen zu diesem Zeitpunkt nicht im knxd vorliegen wird dieser eine Leseanforderung absetzten um die Werte zubekoammen. + Wenn die Werte der Gruppenadressen zu diesem Zeitpunkt nicht im knxd vorliegen wird dieser eine Leseanforderung absetzten um die Werte zu bekommen. Schlägt der Versuch fehl oder sind aus anderem Grund keine Werte im Cache des knxd vorhanden dann sendet dieser ein fehlerhaftes Datenpaket - in dem nur Absender und Empfängeradresse enthalten sind. Die weiteren 2 Bytes mit Kontroll und Dateninformationen fehlen jedoch. + in dem nur Absender und Empfängeradresse enthalten sind. Die weiteren 2 Bytes mit Kontroll- und Dateninformationen fehlen jedoch. Daraus lässt sich auch nicht feststellen, ob die Empfängeradresse eine physikalische Adresse oder eine Gruppenadresse ist. Das Plugin merkt sich diese Empfängeradresse, interpretiert sie als Gruppenadresse und speichert sie in einer internen Liste. Im Webinterface werden alle Items mit ``knx_cache`` und der zugeordneten Gruppenadresse mit dieser Liste verglichen. Taucht die Gruppenadresse dort auf, wird der Eintrag rot eingefärbt als Hinweis das die Konfiguration überprüft werden sollte. - Oftmals haben knx Geräte für eine Gruppenadresse die mit knx_cache ausgelesenwerden soll kein Leseflag in der ETS gesetzt bekommen. + Oftmals haben knx Geräte für eine Gruppenadresse die mit knx_cache ausgelesen werden soll kein Leseflag in der ETS gesetzt bekommen. Es ist möglich den Loglevel mit dem diese fehlerhaften Rückmeldungen geloggt werden in der Plugin Konfiguration festzulegen. Der dritte Tab zeigt Statistiken zu den physikalischen Adressen: diff --git a/knx/webif/__init__.py b/knx/webif/__init__.py new file mode 100755 index 000000000..6114bf90c --- /dev/null +++ b/knx/webif/__init__.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2020- +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.5 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import datetime +import time +import os +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf +from ..globals import * + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + self.last_upload = "" + + self.tplenv = self.init_template_environment() + self.knxdaemon = '' + if os.name != 'nt': + if self.get_process_info("ps cax|grep eibd") != '': + self.knxdaemon = 'eibd' + if self.get_process_info("ps cax|grep knxd") != '': + if self.knxdaemon != '': + self.knxdaemon += ' and ' + self.knxdaemon += 'knxd' + else: + self.knxdaemon = 'can not be determined when running on Windows' + + def get_process_info(self, command): + """ + returns output from executing a given command via the shell. + """ + ## get subprocess module + import subprocess + + ## call date command ## + p = subprocess.Popen(command, stdout=subprocess.PIPE, shell=True) + + # Talk with date command i.e. read data from stdout and stderr. Store this info in tuple ## + # Interact with process: Send data to stdin. Read data from stdout and stderr, until end-of-file is reached. + # Wait for process to terminate. The optional input argument should be a string to be sent to the child process, or None, if no data should be sent to the child. + (result, err) = p.communicate() + + ## Wait for date to terminate. Get return returncode ## + p_status = p.wait() + return str(result, encoding='utf-8', errors='strict') + + + @cherrypy.expose + def index(self, reload=None, knxprojfile=None, password=None): + """ + Build index.html for cherrypy + Render the template and return the html file to be delivered to the browser + :return: contents of the template after beeing rendered + """ + pagelength = self.plugin.get_parameter_value('webif_pagelength') + if password is not None: + if password != '': + self.plugin.project_file_password = password + self.logger.debug("Set password for knxproj file") + else: + self.logger.debug("Provided password is empty, will not replace the saved password") + + # if given knxprojfile then this is an upload + if self.plugin.use_project_file and knxprojfile is not None: + size = 0 + # ``knxprojfile.file`` is a memory file prepared by cherrypy, + # it could however be ``None``` if no valid file was uploaded by html page + if knxprojfile.file is not None: + with open(self.plugin.projectpath, 'wb') as out: + while True: + data = knxprojfile.file.read(8192) + if not data: + break + out.write(data) + size += len(data) + self.last_upload = "File received.\nFilename: {}\nLength: {}\nMime-type: {}\n".format(knxprojfile.filename, size, knxprojfile.content_type) + self.logger.debug(f"Uploaded projectfile {knxprojfile.filename} with {size} bytes") + self.plugin._parse_projectfile() + else: + self.logger.error(f"Could not upload projectfile {knxprojfile}") + + plgitems = [] + for item in self.items.return_items(): + if any(elem in item.property.attributes for elem in [KNX_DPT, KNX_STATUS, KNX_SEND, KNX_REPLY, KNX_CACHE, KNX_INIT, KNX_LISTEN, KNX_POLL]): + plgitems.append(item) + + # build a dict with groupaddress as key to items and their attributes + # ga_usage_by_Item = { '0/1/2' : { ItemA : { attribute1 : True, attribute2 : True }, + # ItemB : { attribute1 : True, attribute2 : True }}, ...} + # ga_usage_by_Attrib={ '0/1/2' : { attribut1 : { ItemA : True, ItemB : True }, + # attribut2 : { ItemC : True, ItemD : True }}, ...} + ga_usage_by_Item = {} + ga_usage_by_Attrib = {} + for item in plgitems: + for elem in [KNX_DPT, KNX_STATUS, KNX_SEND, KNX_REPLY, KNX_CACHE, KNX_INIT, KNX_LISTEN, KNX_POLL]: + if elem in item.property.attributes: + value = self.plugin.get_iattr_value(item.conf, elem) + # value might be a list or a string here + if isinstance(value, str): + values = [value] + else: + values = value + if values is not None: + for ga in values: + # create ga_usage_by_Item entries + if ga not in ga_usage_by_Item: + ga_usage_by_Item[ga] = {} + if item not in ga_usage_by_Item[ga]: + ga_usage_by_Item[ga][item] = {} + ga_usage_by_Item[ga][item][elem] = True + + # create ga_usage_by_Attrib entries + if ga not in ga_usage_by_Attrib: + ga_usage_by_Attrib[ga] = {} + if item not in ga_usage_by_Attrib[ga]: + ga_usage_by_Attrib[ga][elem] = {} + ga_usage_by_Attrib[ga][elem][item] = True + + tmpl = self.tplenv.get_template('index.html') + # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, + items=sorted(plgitems, key=lambda k: str.lower(k['_path'])), + knxdaemon=self.knxdaemon, + stats_ga=self.plugin.get_stats_ga(), stats_ga_list=sorted(self.plugin.get_stats_ga(), key=lambda k: str(int(k.split('/')[0]) + 100) + str(int(k.split('/')[1]) + 100) + str(int(k.split('/')[2]) + 1000)), + stats_pa=self.plugin.get_stats_pa(), stats_pa_list=sorted(self.plugin.get_stats_pa(), key=lambda k: str(int(k.split('.')[0]) + 100) + str(int(k.split('.')[1]) + 100) + str(int(k.split('.')[2]) + 1000)), + last_upload=self.last_upload, + ga_usage_by_Item=ga_usage_by_Item, + ga_usage_by_Attrib=ga_usage_by_Attrib, + knx_attribs=[KNX_DPT, KNX_STATUS, KNX_SEND, KNX_REPLY, KNX_CACHE, KNX_INIT, KNX_LISTEN, KNX_POLL] + ) + + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + if dataSet == 'itemtable': + # get the new data + data = self.plugin._webdata + try: + data = json.dumps(data) + return data + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") + if dataSet == 'patable': + # get the new data + data = self.plugin.get_stats_pa() + try: + data = json.dumps(data) + return data + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") + if dataSet == 'gatable': + # get the new data + data = self.plugin.get_stats_ga() + try: + data = json.dumps(data) + return data + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") + if dataSet is None: + # get the new data + data = {} + + # data['item'] = {} + # for i in self.plugin.items: + # data['item'][i]['value'] = self.plugin.getitemvalue(i) + # + # return it as json the the web page + # try: + # return json.dumps(data) + # except Exception as e: + # self.logger.error("get_data_html exception: {}".format(e)) + return {} diff --git a/knx/webif/templates/index.html b/knx/webif/templates/index.html index 2177d9872..9335908a9 100755 --- a/knx/webif/templates/index.html +++ b/knx/webif/templates/index.html @@ -1,31 +1,81 @@ {% extends "base_plugin.html" %} {% set logo_frame = false %} - +{% block pluginstyles %} + +{% endblock pluginstyles %} {% block pluginscripts %} + + {% endblock pluginscripts %} @@ -47,10 +264,14 @@
{{ _('Cycle') }}{{ p._cycle }}{{ p._cycle }} {{ _('Sek.') }}
- -{% for bus in p._buses %} - -{% endfor %} -
{{ _('Bus') }}{{ _('angeschlossene Sensoren') }}
{{ bus }}{{ p._buses[bus] }}
-{% endif %} - -{% if p._sensors|length %} -
{{ p._sensors|length }} {{ _('Sensoren in SmartHomeNG Items definiert') }}
- - -{% for sensor in p._sensors %} - {% for entry in p._sensors[sensor] %} - - {% endfor %} -{% endfor %} -
{{ _('Sensor') }}{{ _('Item') }}{{ _('Wert') }}{{ _('Letzte Änderung') }}
{{ sensor }}{{ p._sensors[sensor][entry]['item'].id() }}{{ p._sensors[sensor][entry]['item']() }}{{ p._sensors[sensor][entry]['item'].property.last_change }}
-{% endif %} -{% if p._ios|length %} -
{{ p._ios|length }} {{ _('IO Geräte in SmartHomeNG Items definiert') }}
- - -{% for sensor in p._ios %} - {% for entry in p._ios[sensor] %} - - {% endfor %} -{% endfor %} -
{{ _('Sensor') }}{{ _('Item') }}{{ _('Wert') }}{{ _('Letzte Änderung') }}
{{ sensor }}{{ p._ios[sensor][entry]['item'].id() }}{{ p._ios[sensor][entry]['item']() }}{{ p._ios[sensor][entry]['item'].property.last_change }}
-{% endif %} + +
+
Item Information
+
+
-{% if p._ibuttons|length %} -
{{ p._ibuttons|length }} {{ _('IO Geräte in SmartHomeNG Items definiert') }}
- - -{% for sensor in p._ibuttons %} - {% for entry in p._ibuttons[sensor] %} - - {% endfor %} -{% endfor %} -
{{ _('Sensor') }}{{ _('Item') }}{{ _('Wert') }}{{ _('Letzte Änderung') }}
{{ sensor }}{{ p._ibuttons[sensor][entry]['item'].id() }}{{ p._ibuttons[sensor][entry]['item']() }}{{ p._ibuttons[sensor][entry]['item'].property.last_change }}
-{% endif %} +{% endblock bodytab1 %} + +{% set tab2title = "" ~ p.get_shortname() ~ " " ~ _('Busse') ~ " (" ~ p._buses|length ~ ")" %} +{% block bodytab2 %} +
+
Bus Information
+
-{% endblock bodytab1 %} + +{% endblock bodytab2 %} diff --git a/openweathermap/__init__.py b/openweathermap/__init__.py index c6c02ddad..997fe1c81 100755 --- a/openweathermap/__init__.py +++ b/openweathermap/__init__.py @@ -46,9 +46,9 @@ def __init__(self, *args: object) -> None: class OpenWeatherMap(SmartPlugin): - PLUGIN_VERSION = "1.8.5" + PLUGIN_VERSION = "1.8.7" - _base_url = 'https://api.openweathermap.org/%s' + _base_url = 'https://api.openweathermap.org/' _base_img_url = 'https://tile.openweathermap.org/map/%s/%s/%s/%s.png?appid=%s' # source for german descriptions https://www.smarthomeng.de/vom-winde-verweht @@ -88,6 +88,10 @@ def __init__(self, sh, *args, **kwargs): """ Initializes the plugin """ + + # Call init code of parent class (SmartPlugin) + super().__init__() + if '.'.join(VERSION.split('.', 2)[:2]) <= '1.5': self.logger = logging.getLogger(__name__) self._key = self.get_parameter_value('key') @@ -103,6 +107,8 @@ def __init__(self, sh, *args, **kwargs): self._elev = self.get_sh()._elev self._lang = self.get_parameter_value('lang') self._units = self.get_parameter_value('units') + self._api_version = self.get_parameter_value('api_version') + softfail_mode_precipitation = self.get_parameter_value('softfail_precipitation') softfail_mode_wind_gust = self.get_parameter_value('softfail_wind_gust') @@ -194,11 +200,8 @@ def __init__(self, sh, *args, **kwargs): self._origins_layer = [ 'clouds_new', 'precipitation_new', 'pressure_new', 'wind_new', 'temp_new'] - if not self.init_webinterface(WebInterface): - self.logger.error("Unable to start Webinterface") - self._init_complete = False - else: - self.logger.debug("Init complete") + self.init_webinterface(WebInterface) + def run(self): self.scheduler_add(__name__, self._update_loop, @@ -236,7 +239,7 @@ def force_download_all_data(self): self.__query_api_if(self._data_source_key_onecall, force=True) - self.__query_api_if(self._data_source_key_back0day,force=True, delta_t=0) + self.__query_api_if(self._data_source_key_back0day, force=True, delta_t=0) self.__query_api_if(self._data_source_key_back1day, force=True, delta_t=-1) self.__query_api_if(self._data_source_key_back2day, force=True, delta_t=-2) self.__query_api_if(self._data_source_key_back3day, force=True, delta_t=-3) @@ -431,10 +434,8 @@ def _update(self): if owm_matchstring in self._origins_layer: ret_val = self.__build_url('owm_layer', item) wrk_typ = 'owm_layer' - item(ret_val, self.get_shortname(), - f"{wrk_typ} // {owm_matchstring}") - self.logger.debug( - "%s OK: owm-string: %s as layer" % (item, owm_matchstring)) + item(ret_val, self.get_shortname(), f"{wrk_typ} // {owm_matchstring}") + self.logger.debug("%s OK: owm-string: %s as layer" % (item, owm_matchstring)) else: try: ret_val, wrk_typ, changed_match_string, was_ok = self.get_value_with_meta( @@ -446,8 +447,12 @@ def _update(self): self.logger.info( "%s INFO: owm-string: %s --> %s from wrk=%s, Info: %s" % (item, owm_matchstring, changed_match_string, wrk_typ, ret_val)) elif isinstance(ret_val, Exception): - self.logger.info( - "%s ERROR: owm-string: %s --> %s from wrk=%s, Error: %s" % (item, owm_matchstring, changed_match_string, wrk_typ, ret_val)) + if str(ret_val) == 'cannot unpack non-iterable NoneType object': + self.logger.debug( + "%s ERROR: owm-string: %s --> %s from wrk=%s, Error: %s" % (item, owm_matchstring, changed_match_string, wrk_typ, ret_val)) + else: + self.logger.info( + "%s ERROR: owm-string: %s --> %s from wrk=%s, Error: %s" % (item, owm_matchstring, changed_match_string, wrk_typ, ret_val)) else: item(ret_val, self.get_shortname(), f"{wrk_typ} // {changed_match_string}") @@ -458,8 +463,7 @@ def _update(self): self.logger.warning( "%s OK, FIXED: owm-string: %s --> %s from wrk=%s" % (item, owm_matchstring, changed_match_string, wrk_typ)) except Exception as e: - self.logger.error( - "%s FATAL: owm-string: %s, Error: %s" % (item, owm_matchstring, e)) + self.logger.error("%s FATAL: owm-string: %s, Error: %s" % (item, owm_matchstring, e)) return @@ -473,21 +477,14 @@ def __calculate_eto(self, s, correlation_hint): """ self.logger.debug("%s _calculate_eto: for %s" % ((correlation_hint, s))) - sunrise_value = self.get_value_or_raise( - s.replace('/eto', "/sunrise"), correlation_hint) + sunrise_value = self.get_value_or_raise(s.replace('/eto', "/sunrise"), correlation_hint) climate_sunrise = datetime.utcfromtimestamp(int(sunrise_value)) - climate_humidity = self.get_value_or_raise( - s.replace('/eto', "/humidity"), correlation_hint) - climate_pressure = self.get_value_or_raise( - s.replace('/eto', "/pressure"), correlation_hint) - climate_min = self.get_value_or_raise( - s.replace('/eto', "/temp" if s.startswith('day/-') else "/temp/min"), correlation_hint) - climate_max = self.get_value_or_raise( - s.replace('/eto', "/temp" if s.startswith('day/-') else "/temp/max"), correlation_hint) - climate_speed = self.get_value_or_raise( - s.replace('/eto', "/wind_speed"), correlation_hint) - solarRad = self.get_value_or_raise( - s.replace('/eto', "/uvi"), correlation_hint) + climate_humidity = self.get_value_or_raise(s.replace('/eto', "/humidity"), correlation_hint) + climate_pressure = self.get_value_or_raise(s.replace('/eto', "/pressure"), correlation_hint) + climate_min = self.get_value_or_raise(s.replace('/eto', "/temp" if s.startswith('day/-') else "/temp/min"), correlation_hint) + climate_max = self.get_value_or_raise(s.replace('/eto', "/temp" if s.startswith('day/-') else "/temp/max"), correlation_hint) + climate_speed = self.get_value_or_raise(s.replace('/eto', "/wind_speed"), correlation_hint) + solarRad = self.get_value_or_raise(s.replace('/eto', "/uvi"), correlation_hint) alt = float(self._elev) lat = float(self._lat) @@ -495,7 +492,7 @@ def __calculate_eto(self, s, correlation_hint): rS = solarRad * 3.6 U_2 = climate_speed * 0.748 slopeSvpc = 4098 * (0.6108 * math.exp((17.27 * tMean) / - (tMean + 237.3))) / math.pow((tMean + 237.3), 2) + (tMean + 237.3))) / math.pow((tMean + 237.3), 2) pA = climate_pressure / 10 pSc = pA * 0.000665 DT = slopeSvpc / (slopeSvpc + (pSc * (1 + (0.34 * U_2)))) @@ -553,16 +550,14 @@ def __get_virtual_value(self, virtual_ms, correlation_hint): if mode == 'next': if unit == 'h': if number > 48: - raise Exception( - "Cannot get value further than 48h in future, switch unit to 'd' to see further into the future") + raise Exception("Cannot get value further than 48h in future, switch unit to 'd' to see further into the future") for hr in range(0, number): val = self.get_value(f'hour/{hr}/{data_field}', correlation_hint) if not isinstance(val, Exception): pool.append(val) elif unit == 'd': if number > 6: - raise Exception( - "Cannot get value further than 6d in future") + raise Exception("Cannot get value further than 6d in future") for day in range(0, number): val = self.get_value(f'day/{day}/{data_field}', correlation_hint) if not isinstance(val, Exception): @@ -574,13 +569,11 @@ def __get_virtual_value(self, virtual_ms, correlation_hint): hours = number days_back = int(hours / 24) + 1 - self.logger.debug( - f"PAST: {virtual_ms} into: hrs:{hours}, days_back:{days_back}") + self.logger.debug(f"PAST: {virtual_ms} into: hrs:{hours}, days_back:{days_back}") for day_back in range(days_back, -1, -1): for hr in range(0, 24): try: - val = self.get_value( - f'day/-{day_back}/hour/{hr}/{data_field}', correlation_hint) + val = self.get_value(f'day/-{day_back}/hour/{hr}/{data_field}', correlation_hint) if not isinstance(val, Exception): pool.append(val) except: @@ -597,13 +590,11 @@ def __get_virtual_value(self, virtual_ms, correlation_hint): elif operation == "avg": if len(pool) == 0: return 0 - return round(functools.reduce( - lambda x, y: x + y, pool) / len(pool), 2) + return round(functools.reduce(lambda x, y: x + y, pool) / len(pool), 2) elif operation == "sum": if len(pool) == 0: return 0 - return round(functools.reduce( - lambda x, y: x + y, pool), 2) + return round(functools.reduce(lambda x, y: x + y, pool), 2) elif operation == "all": return pool else: @@ -621,7 +612,7 @@ def __handle_fail(self, last_popped, current_leaf, successful_path, original_mat elif soft_fail_mode == "no_update": changed_match_string = '/'.join(successful_path) + missing_child_path self.logger.debug( - "%s DEBUG: owm-string: %s --> %s, Missing Child, Soft-Fail to no_update" % (correlation_hint, original_match_string, changed_match_string)) + "%s DEBUG: owm-string: %s --> %s, Missing Child, Soft-Fail to no_update" % (correlation_hint, original_match_string, changed_match_string)) return None elif soft_fail_mode.startswith("number="): return int(soft_fail_mode.replace("number=", "")) @@ -638,23 +629,27 @@ def __handle_fail(self, last_popped, current_leaf, successful_path, original_mat new_match_string = "/".join(match_path) self.logger.debug(f"{correlation_hint} '{original_match_string}' is matching soft_fail '{fail_match_string}' and will query '{new_match_string}'") return self.get_value_or_raise(new_match_string, correlation_hint) - raise OpenWeatherMapNoValueHardException( f"Missing child '{last_popped}' after '{'/'.join(successful_path)}' (complete path missing: {missing_child_path})") - - def __get_val_from_dict(self, s, wrk, correlation_hint, original_match_string): """ Uses string s as a path to navigate to the requested value in dict wrk. """ + + # Check if dictionary data in variable wrk are invalid. This occurs, if download fails. + if wrk == 'Not downloaded!': + self.logger.debug(f"{correlation_hint} __get_val_from_dict function aborted because dictionary data are invalid.") + return + successful_path = [] last_popped = None sp = s.split('/') while True: if (len(sp) == 0) or (wrk is None): if wrk is None: - wrk = self.__handle_fail(last_popped, sp, successful_path, original_match_string, correlation_hint) + wrk = self.__handle_fail( + last_popped, sp, successful_path, original_match_string, correlation_hint) break if type(wrk) is list: @@ -767,31 +762,43 @@ def __query_api_if(self, data_source_key, only_if=False, delta_t=0, force=False) def __query_api(self, data_source_key, delta_t=0, force=False): """ Requests the weather information at openweathermap.com + Return true on success and false on errors. """ try: - url = self.__build_url(data_source_key, delta_t=delta_t, force=force) + url = self.__build_url( + data_source_key, delta_t=delta_t, force=force) response = self._session.get(url) except Exception as e: self.logger.error( - "__query_api: Exception when sending GET request for data_source_key '%s': %s" % (data_source_key, str(e))) - return - num_bytes = len(response.content) - self.logger.debug(f"Received {num_bytes} bytes for {data_source_key} from {url}") - if num_bytes < 50: - self.logger.error(f"Response for {data_source_key} from {url} was too short to be meaningful: '{response.content}'") - return + f"Request failed for DataSource {data_source_key}: {str(e)}") + return False - try: - json_obj = response.json() - except Exception as e: - self.logger.error(f"Exception trying to decode json resoponse: {e}") - self.logger.error(f" - Status code: {response.status_code}") - self.logger.info(f" - resoponse: {response.text}") - json_obj = {} + if response.ok: + num_bytes = len(response.content) + self.logger.debug( + f"Received {num_bytes} bytes for {data_source_key} from {url}") + if num_bytes < 50: + self.logger.error( + f"Response for {data_source_key} from {url} was too short to be meaningful ({num_bytes} bytes): '{response.content}'") + return False + try: + json_obj = response.json() - self._data_sources[data_source_key]['url'] = url - self._data_sources[data_source_key]['fetched'] = datetime.now() - self._data_sources[data_source_key]['data'] = json_obj + self._data_sources[data_source_key]['url'] = url + self._data_sources[data_source_key]['fetched'] = datetime.now() + self._data_sources[data_source_key]['data'] = json_obj + return True + except json.JSONDecodeError as decode_error: + self.logger.error( + f"Response for {data_source_key} from {url} could not be parsed: '{decode_error.msg}'") + return False + elif response.status_code == 401: + self.logger.error(f"Access denied for {url}, received: '{response.text}'") + return False + else: + self.logger.error( + f"Response for {data_source_key} from {url} returned status-code: '{response.status_code}'") + return False def parse_item(self, item): """ @@ -899,37 +906,38 @@ def __build_url(self, url_type=None, item=None, delta_t=0, force=False): """ url = '' if url_type is None or url_type == self._data_source_key_weather: - url = self._base_url % 'data/2.5/weather' + url = self._base_url + 'data/' + '2.5' + '/weather' parameters = "?lat=%s&lon=%s&appid=%s&lang=%s&units=%s" % (self._lat, self._lon, self._key, self._lang, self._units) url = '%s%s' % (url, parameters) elif url_type == self._data_source_key_forecast: - url = self._base_url % 'data/2.5/forecast' + url = self._base_url + 'data/' + '2.5' + '/forecast' parameters = "?lat=%s&lon=%s&appid=%s&lang=%s&units=%s" % (self._lat, self._lon, self._key, self._lang, self._units) url = '%s%s' % (url, parameters) elif url_type == self._data_source_key_uvi: - url = self._base_url % 'data/2.5/uvi' + url = self._base_url + 'data/' + '2.5' + '/uvi' parameters = "?lat=%s&lon=%s&appid=%s&lang=%s&units=%s" % (self._lat, self._lon, self._key, self._lang, self._units) url = '%s%s' % (url, parameters) elif url_type == self._data_source_key_airpollution_current: - url = self._base_url % 'data/2.5/air_pollution' + url = self._base_url + 'data/' + '2.5' + '/air_pollution' parameters = "?lat=%s&lon=%s&appid=%s" % ( self._lat, self._lon, self._key) url = '%s%s' % (url, parameters) elif url_type == self._data_source_key_airpollution_forecast: - url = self._base_url % 'data/2.5/air_pollution/history' + url = self._base_url + 'data/' + '2.5' + '/air_pollution/history' parameters = "?lat=%s&lon=%s&start=%i&end=%i&appid=%s" % ( self._lat, self._lon, datetime.utcnow().timestamp(), self.__get_timestamp_for_delta_days(5), self._key) url = '%s%s' % (url, parameters) elif url_type.startswith('airpollution-'): - url = self._base_url % 'data/2.5/air_pollution/history' + url = self._base_url + 'data/' + '2.5' + '/air_pollution/history' parameters = "?lat=%s&lon=%s&start=%i&end=%i&appid=%s" % (self._lat, self._lon, self.__get_timestamp_for_delta_days( delta_t), self.__get_timestamp_for_delta_days(delta_t + 1), self._key) url = '%s%s' % (url, parameters) elif url_type == self._data_source_key_onecall: - url = self._base_url % 'data/2.5/onecall' + # Two different API versions exists for the onecall API: 2.5 and 3.0, configurable via plugin config. + url = self._base_url + 'data/' + self._api_version + '/onecall' if force: exclude = "" else: @@ -949,7 +957,8 @@ def __build_url(self, url_type=None, item=None, delta_t=0, force=False): self._key, self._lang, self._units) url = '%s%s' % (url, parameters) elif url_type.startswith('onecall-'): - url = self._base_url % 'data/2.5/onecall/timemachine' + # Two different API versions exists for the onecall API: 2.5 and 3.0, configurable via plugin config. + url = self._base_url + 'data/' + self._api_version + '/onecall/timemachine' parameters = "?lat=%s&lon=%s&dt=%i&appid=%s&lang=%s&units=%s" % (self._lat, self._lon, self.__get_timestamp_for_delta_days(delta_t), self._key, self._lang, self._units) url = '%s%s' % (url, parameters) diff --git a/openweathermap/plugin.yaml b/openweathermap/plugin.yaml index cc40c2bd5..9570ca65e 100755 --- a/openweathermap/plugin.yaml +++ b/openweathermap/plugin.yaml @@ -9,9 +9,9 @@ plugin: tester: Sisamiwe, jentz1986 state: qa-passed keywords: weather precipation irrigation - documentation: 'http://smarthomeng.de/user/plugins/openweathermap/user_doc.html' + documentation: '' support: 'https://knx-user-forum.de/forum/supportforen/smarthome-py/1246998-support-thread-zum-openweathermap-plugin' - version: 1.8.5 # Plugin version + version: 1.8.7 # Plugin version sh_minversion: 1.9.0 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: True # plugin supports multi instance @@ -69,6 +69,17 @@ parameters: de: '(optional) Zeit zwischen zwei Updateläufen.' en: '(optional) Time period between two update cycles.' + api_version: + type: str + mandatory: False + default: '2.5' + valid_list: + - '2.5' + - '3.0' + description: + de: 'Version der Openweathermap Onecall API. Neue User nutzen 3.0' + en: 'Openweathermap Oncecall API version. New users shall use 3.0 by default' + softfail_precipitation: type: str mandatory: False @@ -1464,8 +1475,8 @@ plugin_functions: get_beaufort_description: type: str description: - de: "" - en: "" + de: "Gibt die angegebene Windstärke als beschreibeneden Text aus" + en: "Returns a descriptive text for the given wind-speed" parameters: speed_in_bft: type: num diff --git a/openweathermap/user_doc.rst b/openweathermap/user_doc.rst index 4db65cd43..19c42ec1c 100755 --- a/openweathermap/user_doc.rst +++ b/openweathermap/user_doc.rst @@ -1,7 +1,20 @@ + +.. index:: Plugins; openweathermap (openweathermap.org Wetterdaten) +.. index:: openweathermap +.. index:: Wetter; openweathermap +.. index:: struct; openweathermap + ============== openweathermap ============== +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + Dieses Plugin stellt die Wetterinformationen via OpenWeatherMap (https://openweathermap.org/) zur Verfügung. Die folgenden APIs von OpenWeatherMap werden unterstützt: @@ -15,7 +28,7 @@ Die folgenden APIs von OpenWeatherMap werden unterstützt: Requirements -============= +============ Zur Verwendung des Plugins wird ein API Key von OpenWeatherMap benötigt, der kostenfrei bei https://openweathermap.org zu erstellen ist. @@ -104,7 +117,7 @@ Der Beginn des "Matchstring" definiert die zu verwendende Daten-Quelle (API): - ``weather/0/main`` Gruppenname der Wetter-Parameter (Rain, Snow, Extreme etc.) - ``weather/0/description`` Wetterbeschreibung innerhalb der Gruppe - ``weather/0/icon`` Wetter-Icon-ID - + - beginnt mit ``day/N/`` wobei N [0..6] die Anzahl der Tage von heute in die Zukunft ist bzw. [-4..-0] die Anzahl der Tage von heute in die Vergangenheit ist. Achtung: -0 and 0 ergeben verschiedene Werte! Datenquelle ist onecall-API; Beispiel: day/1/feels_like/night um die morgige gefühlte Nachttemperatur zu bekommen; (Bemerkung: Der prefix "day/" wird durch "daily/" ersetzt, wenn entsprechende Items in der Datenquelle vorhanden sind) Die folgende Liste enthält alle verfügbaren Datenpunkte: @@ -159,7 +172,7 @@ Der Beginn des "Matchstring" definiert die zu verwendende Daten-Quelle (API): - ``airpollution/components/pm2_5`` Partikel 2-5µm - ``airpollution/components/pm10`` Partikel 10µm - ``airpollution/components/nh3`` NH3 Wert - + Ergänzt man ``/day/-1/hour/11/`` zwischen airpollution und main oder component, mit day [-1 .. -4] und hour [0 .. 23] erhält man die Daten für eine definierte Stunde am definierten Tag in der Vergangenheit. Ergänzt man ``/hour/11`` (ohne Tag) mit hour [0 .. 72] erhält man die Vorhersage-Daten für die definierte Stunde von jetzt ab. @@ -178,7 +191,7 @@ Der Beginn des "Matchstring" definiert die zu verwendende Daten-Quelle (API): - ``wind_new`` - ``temp_new`` -- bei allen anderen Werten wird versucht, diese gegen die weather-API zu prüfen. +- bei allen anderen Werten wird versucht, diese gegen die weather-API zu prüfen. - ``base`` / ``cod`` / ``sys/id`` / ``sys/type`` um einige interne Parameter zu bekommen. - ``coord/lon`` / ``coord/lat`` / ``id`` / ``name`` / ``sys/country`` / ``timezone`` für OWMs Interpretation deiner Ortsdaten. @@ -200,8 +213,8 @@ Zugriff auf Listen ------------------ Die Wetterkonditionen sind als Liste gespeichert und können mit ``current/weather/0/description`` adressiert werden. Da der Datentyp "list" nicht offensichtlich ist, setzt das Plugin automatisch "/0/" ein, um auf das erste Element der Liste zuzugreifen. Deshalb führt ``current/weather/description`` zum entsprechenden Wert und einer WARNING im Log bei jedem Update. Diese Umsetzung soll dazu dienen, Probleme leicht zu identifizieren und durch ein Update des Matchstrings in der Konfiguration zu beheben. -Dynamischen Listen wie bspw. bei ``alerts`` beinhalten eine unbekannte Anzahl von Elementen in der Liste. Mit ``@count`` kann die Anzahl der Listenelemente ermittelt werden. -Beispiele: ``current/weather/@count`` (immer 1) oder ``alerts/@count`` +Dynamischen Listen wie bspw. bei ``alerts`` beinhalten eine unbekannte Anzahl von Elementen in der Liste. Mit ``@count`` kann die Anzahl der Listenelemente ermittelt werden. +Beispiele: ``current/weather/@count`` (immer 1) oder ``alerts/@count`` Virtuelle Matchstrings @@ -260,7 +273,7 @@ Hier ein Beispiel für die Verwendung der virtuellen Matchstrings mit dem smartV {% import "widgets_openweathermap.html" as owm %} {{ owm.rain_overview('visual_id', 'weather.rain_past_12h', 'weather.rain_next_12h', 'weather.as_of') }} - + Tagesvorhersage (berechnet) --------------------------- @@ -306,7 +319,7 @@ Wetteralarme ------------ Wetteralarme werden von der entsprechenden Behörde wie bspw. der Deutscher Wetterdienst bereitgestellt und entsprechend weitergeleitet. Im Falle eines Alarmes, werden 2 Einträge (einer in Landessprache und einer in Englisch) in der Liste zugefügt. -Liegt kein realer Alarm vor, ist der Alarm-Knoten der API-Antwort nicht vorhanden und führt zu einem Fehler bzw ERROR im Log. Um dies zu verhindern, stellt das Plugin sicher, dass immer mindestens ein Alarm, der "Placebo-Alarm" mit der Beschreibung "No Alert" ein. vorliegt. +Liegt kein realer Alarm vor, ist der Alarm-Knoten der API-Antwort nicht vorhanden und führt zu einem Fehler bzw ERROR im Log. Um dies zu verhindern, stellt das Plugin sicher, dass immer mindestens ein Alarm, der "Placebo-Alarm" mit der Beschreibung "No Alert" ein. vorliegt. So wird sichergestellt, dass der Matchstring ``alerts/0/event`` immer einen Wert zugewiesen bekommt. Durch die Verwendung von ``alerts/@count`` kann die Anzahl der vorliegenden Alarme ermittelt werden. Liegt nur der "Placebo-Alarm" vor, ist die Antwort der numerische Wert "0". @@ -1024,8 +1037,8 @@ Das Beispiel, passend zur YAML von oben: {% import "widgets_openweathermap.html" as owm %} {{ owm.irrigation_weekly('valve_2', 'Lawn in the backyard', 'garden.irrigation_valve2') }} - - + + Funktionen des Plugins ====================== @@ -1042,8 +1055,8 @@ Berechnet aus der Windgeschwindigkeit die Beschreibung der Windstärke nach Beau -Web Interface des Plugins -========================= +Web Interface +============= OWM Items diff --git a/philips_tv/__init__.py b/philips_tv/__init__.py index 32e0b95f4..87ca0c808 100755 --- a/philips_tv/__init__.py +++ b/philips_tv/__init__.py @@ -5,8 +5,7 @@ ######################################################################### # This file is part of SmartHomeNG. # -# Sample plugin for new plugins to run with SmartHomeNG version 1.8 and -# upwards. +# Plugin to connect with Philips SmartTVs # # SmartHomeNG is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -25,6 +24,8 @@ from lib.model.smartplugin import * from lib.item import Items +from .webif import WebInterface + import binascii import sys import requests @@ -81,7 +82,7 @@ def __init__(self, sh, *args, **kwargs): else: self.logger.info(f"Philips TV configured on IP: {self.ip}") self.logger.debug("Init completed.") - self.init_webinterface() + self.init_webinterface(WebInterface) self._items = {} return @@ -423,134 +424,3 @@ def post(self, path, body, verbose=True, err_count=0): self.logger.info(json.dumps({"error":"Can not reach the API"})) return json.dumps({"error":"Can not reach the API"}) - def init_webinterface(self): - """" - Initialize the web interface for this plugin - - This method is only needed if the plugin is implementing a web interface - """ - try: - self.mod_http = Modules.get_instance().get_module( - 'http') # try/except to handle running in a core version that does not support modules - except: - self.mod_http = None - if self.mod_http == None: - self.logger.error("Not initializing the web interface") - return False - - import sys - if not "SmartPluginWebIf" in list(sys.modules['lib.model.smartplugin'].__dict__): - self.logger.warning("Web interface needs SmartHomeNG v1.5 and up. Not initializing the web interface") - return False - - # set application configuration for cherrypy - webif_dir = self.path_join(self.get_plugin_dir(), 'webif') - config = { - '/': { - 'tools.staticdir.root': webif_dir, - }, - '/static': { - 'tools.staticdir.on': True, - 'tools.staticdir.dir': 'static' - } - } - - # Register the web interface as a cherrypy app - self.mod_http.register_webif(WebInterface(webif_dir, self), - self.get_shortname(), - config, - self.get_classname(), self.get_instance_name(), - description='') - - return True - - -# ------------------------------------------ -# Webinterface of the plugin -# ------------------------------------------ - -import cherrypy -from jinja2 import Environment, FileSystemLoader - - -class WebInterface(SmartPluginWebIf): - - def __init__(self, webif_dir, plugin): - """ - Initialization of instance of class WebInterface - - :param webif_dir: directory where the webinterface of the plugin resides - :param plugin: instance of the plugin - :type webif_dir: str - :type plugin: object - """ - self.logger = logging.getLogger(__name__) - self.webif_dir = webif_dir - self.plugin = plugin - self.tplenv = self.init_template_environment() - - self.items = Items.get_instance() - - @cherrypy.expose - def index(self, reload=None, action=None, email=None, hashInput=None, code=None, tokenInput=None): - """ - Build index.html for cherrypy - - Render the template and return the html file to be delivered to the browser - - :return: contents of the template after beeing rendered - """ - - codeRequestSuccessfull = None - pairingCompleted = None - - if action is not None: - if action == "requestCode": - self.logger.info("Request code triggered via webinterface") - codeRequestSuccessfull = self.plugin.startPairing() - elif action == "confirmCode": - self.logger.info("Confirm code triggered via webinterface") - if (code is not None) and (not code == ''): - pairingCompleted = self.plugin.completePairing(code) - elif (code is None) or (code == ''): - self.logger.error("Confirmation not possible: TV Paring code missing.") - pairingCompleted = False - else: - self.logger.error("Confirmation no possible: Missing argument.") - pairingCompleted = False - else: - self.logger.error("Unknown command received via webinterface") - - tmpl = self.tplenv.get_template('index.html') - # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) - return tmpl.render(p=self.plugin, - codeRequestSuccessfull=codeRequestSuccessfull, - pairingCompleted=pairingCompleted) - - - @cherrypy.expose - def get_data_html(self, dataSet=None): - """ - Return data to update the webpage - - For the standard update mechanism of the web interface, the dataSet to return the data for is None - - :param dataSet: Dataset for which the data should be returned (standard: None) - :return: dict with the data needed to update the web page. - """ - if dataSet is None: - # get the new data - data = {} - - # data['item'] = {} - # for i in self.plugin.items: - # data['item'][i]['value'] = self.plugin.getitemvalue(i) - # - # return it as json the the web page - # try: - # return json.dumps(data) - # except Exception as e: - # self.logger.error(f"get_data_html exception: {e}") - return {} - - diff --git a/philips_tv/locale.yaml b/philips_tv/locale.yaml index 806d7e080..e7324406d 100755 --- a/philips_tv/locale.yaml +++ b/philips_tv/locale.yaml @@ -6,10 +6,10 @@ plugin_translations: # Alternative format for translations of longer texts: 'Philips Explanation': - de: 'Schritt fuer Schritt Anleitung zur Bindung/Authorisierung mit dem Philips TV.' - en: 'Step by Step manual for generating an OAuth2 token for Vorwerk API, compatible with new MyKobold APP.' + de: 'Schritt für Schritt Anleitung zur Bindung/Authorisierung mit dem Philips TV.' + en: 'Step by Step manual for pairing with a Philips TV.' - 'Bestaetigen': + 'Bestätigen': de: '=' en: 'Confirm' @@ -17,11 +17,11 @@ plugin_translations: de: '=' en: 'Insert TV code here' - 'Philips TV IP ueberpruefen': + 'Philips TV IP überpruefen': de: '=' en: 'Doublechek IP Adress of Philips TV' - 'Einstellung ueber plugin.yaml': + 'Einstellung über plugin.yaml': de: '=' en: 'Settings via plugin.yaml' @@ -29,7 +29,7 @@ plugin_translations: de: '=' en: 'Insert TV code here' - 'Freischaltung bestaetigen': + 'Freischaltung bestätigen': de: '=' en: 'Confirm pairing' @@ -37,7 +37,7 @@ plugin_translations: de: '=' en: 'Pairing code is displayed on TV' - 'Automatisch generiert oder Einstellung ueber plugin.yaml': + 'Automatisch generiert oder Einstellung über plugin.yaml': de: '=' en: 'Automatically generated or set via plugin.yaml' diff --git a/philips_tv/plugin.yaml b/philips_tv/plugin.yaml index 05a6dc9b5..c6744cf3f 100755 --- a/philips_tv/plugin.yaml +++ b/philips_tv/plugin.yaml @@ -7,7 +7,7 @@ plugin: en: 'Plugin to connect a Philips TV with SmartHomeNG' maintainer: aschwith tester: 'n/a' - state: develop # change to ready when done with development + state: ready keywords: Philips, TV documentation: https://github.com/smarthomeNG/plugins/blob/develop/philips_tv/user_doc.rst support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1698335-supportthread-f%C3%BCr-philips_tv-plugin diff --git a/philips_tv/user_doc.rst b/philips_tv/user_doc.rst index bb1b42736..604f54d35 100755 --- a/philips_tv/user_doc.rst +++ b/philips_tv/user_doc.rst @@ -1,16 +1,27 @@ -.. index:: Plugins; Philips (Philips TV Unterstützung) -.. index:: Philips +.. index:: Plugins; philips_tv +.. index:: philips_tv -============= +========== philips_tv -============= +========== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left -SmarthomeNG plugin, mit Unterstützung für Philips TVs mit OAuth Identifizierung. +Allgemein +========= + +SmarthomeNG plugin mit Unterstützung für Philips Smart TVs mit OAuth Identifizierung. Konfiguration ============= Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/philips_tv` beschrieben. +Die Kopplung zwischen Plugin und TV erfolgt über das OAuth Verfahren. Das Webinterface führt Schritt für Schritt durch den Kopplungsprozess. Requirements ============= @@ -24,7 +35,8 @@ Philips Smart TV mit OAuth Identifizierung Web Interface ============= -Das philips_tv Plugin verfügt über ein Webinterface, um für Philips TV das OAuth2 Authentifizierungsverfahren direkt durchzuführen und die Anmeldedaten (user + password) direkt in die Konfiguration (plugin.yaml) zu uebernehmen. +Das philips_tv Plugin verfügt über ein Webinterface, um für Philips TV das OAuth2 Authentifizierungsverfahren direkt durchzuführen +und die Anmeldedaten (user + password) persistent direkt in die Konfiguration (plugin.yaml) zu übernehmen. Aufruf des Webinterfaces @@ -41,8 +53,5 @@ Beispiele Folgende Informationen können im Webinterface angezeigt werden: -Im ersten Tab Philips OAuth2 findet sich direkt die Schritt fuer Schritt Anleitung zur OAuth2 Authentifizierung. +Im ersten Tab Philips OAuth2 findet sich direkt die Schritt für Schritt Anleitung zur OAuth2 Authentifizierung. -Changelog ---------- -V1.9.6 Initial plugin version \ No newline at end of file diff --git a/philips_tv/webif/__init__.py b/philips_tv/webif/__init__.py new file mode 100755 index 000000000..d8ff1e3df --- /dev/null +++ b/philips_tv/webif/__init__.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2020- +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.5 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import datetime +import time +import os +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + + @cherrypy.expose + def index(self, reload=None, action=None, email=None, hashInput=None, code=None, tokenInput=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + + codeRequestSuccessfull = None + pairingCompleted = None + + if action is not None: + if action == "requestCode": + self.logger.info("Request code triggered via webinterface") + codeRequestSuccessfull = self.plugin.startPairing() + elif action == "confirmCode": + self.logger.info("Confirm code triggered via webinterface") + if (code is not None) and (not code == ''): + pairingCompleted = self.plugin.completePairing(code) + elif (code is None) or (code == ''): + self.logger.error("Confirmation not possible: TV Paring code missing.") + pairingCompleted = False + else: + self.logger.error("Confirmation no possible: Missing argument.") + pairingCompleted = False + else: + self.logger.error("Unknown command received via webinterface") + + tmpl = self.tplenv.get_template('index.html') + # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin, + codeRequestSuccessfull=codeRequestSuccessfull, + pairingCompleted=pairingCompleted) + + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + # if dataSets are used, define them here + if dataSet == 'overview': + # get the new data from the plugin variable called _webdata + data = self.plugin._webdata + try: + data = json.dumps(data) + return data + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") + if dataSet is None: + # get the new data + data = {} + + # data['item'] = {} + # for i in self.plugin.items: + # data['item'][i]['value'] = self.plugin.getitemvalue(i) + # + # return it as json the the web page + # try: + # return json.dumps(data) + # except Exception as e: + # self.logger.error("get_data_html exception: {}".format(e)) + return {} diff --git a/philips_tv/webif/static/img/plugin_logo.png b/philips_tv/webif/static/img/plugin_logo.png new file mode 100755 index 000000000..c3ad05a45 Binary files /dev/null and b/philips_tv/webif/static/img/plugin_logo.png differ diff --git a/philips_tv/webif/templates/index.html b/philips_tv/webif/templates/index.html index 270be93d7..8fef8d25b 100755 --- a/philips_tv/webif/templates/index.html +++ b/philips_tv/webif/templates/index.html @@ -19,7 +19,7 @@ } function confirmPairCommand() { - if (confirm('{{ _('Wollen Sie die Bindung mit dem TV bestaetigen?') }}')) { + if (confirm('{{ _('Wollen Sie die Bindung mit dem TV bestätigen?') }}')) { codeInputField = $('#input-code'); window.location.href='?action=confirmCode&code=' + codeInputField.val(); } @@ -81,7 +81,7 @@ {% if codeRequestSuccessfull %}
- 4) {{ _('Freischaltung bestaetigen') }}: + 4) {{ _('Freischaltung bestätigen') }}:
- +
diff --git a/piratewthr/__init__.py b/piratewthr/__init__.py new file mode 100755 index 000000000..234eeb443 --- /dev/null +++ b/piratewthr/__init__.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +# +######################################################################### +# Copyright 2023- Martin Sinn m.sinn@gmx.de +# based on darksky plugin: +# Copyright 2018 René Frieß rene.friess(a)gmail.com +######################################################################### +# +# This file is part of SmartHomeNG. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import logging +import requests +import datetime +import json +from collections import OrderedDict +from lib.module import Modules +from lib.model.smartplugin import * + +from .webif import WebInterface + + +class PirateWeather(SmartPlugin): + + + PLUGIN_VERSION = "1.1.0" + + # https://api.pirateweather.net/forecast/[apikey]/[latitude],[longitude] + _base_url = 'https://api.pirateweather.net/forecast/' + _base_forecast_url = _base_url + '%s/%s,%s' + + def __init__(self, sh, *args, **kwargs): + """ + Initalizes the plugin. + + If you need the sh object at all, use the method self.get_sh() to get it. There should be almost no need for + a reference to the sh object any more. + + Plugins have to use the new way of getting parameter values: + use the SmartPlugin method get_parameter_value(parameter_name). Anywhere within the Plugin you can get + the configured (and checked) value for a parameter by calling self.get_parameter_value(parameter_name). It + returns the value in the datatype that is defined in the metadata. + """ + + # Call init code of parent class (SmartPlugin) + super().__init__() + + # get the parameters for the plugin (as defined in metadata plugin.yaml): + self._key = self.get_parameter_value('key') + if self.get_parameter_value('latitude') != '' and self.get_parameter_value('longitude') != '': + self._lat = self.get_parameter_value('latitude') + self._lon = self.get_parameter_value('longitude') + else: + self.logger.debug("__init__: latitude and longitude not provided, using shng system values instead.") + self._lat = self.get_sh()._lat + self._lon = self.get_sh()._lon + self._lang = self.get_parameter_value('lang') + self._units = self.get_parameter_value('units') + self._jsonData = {} + self._session = requests.Session() + self._cycle = int(self.get_parameter_value('cycle')) + self._items = {} # Items that are handled by this plugin + + self.init_webinterface(WebInterface) + + + def run(self): + self.scheduler_add(__name__, self._update_loop, prio=5, cycle=self._cycle, offset=2) + self.alive = True + + def stop(self): + self.alive = False + + + def _update_loop(self): + """ + Starts the update loop for all known items. + """ + self.logger.debug('Starting update loop for instance {}'.format(self.get_instance_name())) + if not self.alive: + return + + self._update() + + + def _update(self): + """ + Updates information on items when it becomes available on the weather service + """ + forecast = self.get_forecast() + if forecast is None: + self.logger.error("Forecast is None! Perhaps server did not reply?") + return + self._jsonData = forecast + for s, matchStringItems in self._items.items(): + wrk = forecast + sp = s.split('/') + if s == "flags/sources": + wrk = ', '.join(wrk['flags']['sources']) + elif s == "alerts" or s == "alerts_string": + if 'alerts' in wrk: + if s == "alerts": + wrk = wrk['alerts'] + else: + alerts_string = '' + if 'alerts' in wrk: + for alert in wrk['alerts']: + start_time = datetime.datetime.fromtimestamp( + int(alert['time']) + ).strftime('%d.%m.%Y %H:%M') + expire_time = datetime.datetime.fromtimestamp( + int(alert['expires']) + ).strftime('%d.%m.%Y %H:%M') + alerts_string_wrk = "

"+alert['title']+" ("+start_time+" - "+expire_time+")

" + alerts_string_wrk = alerts_string_wrk + ""+alert['description']+"

" + alerts_string = alerts_string + alerts_string_wrk + wrk = alerts_string + else: + if s == "alerts_string": + wrk = '' + else: + wrk = [] + else: + while True: + if (len(sp) == 0) or (wrk is None): + break + if type(wrk) is list: + if self.is_int(sp[0]): + if int(sp[0]) < len(wrk): + wrk = wrk[int(sp[0])] + else: + self.logger.error( + "_update: invalid pw_matchstring '{}'; integer too large in matchstring".format( + s)) + break + else: + self.logger.error( + "_update: invalid pw_matchstring '{}'; integer expected in matchstring".format( + s)) + break + else: + wrk = wrk.get(sp[0]) + if len(sp) == 1: + spl = s.split('/') + self.logger.debug( + "_update: pw_matchstring split len={}, content={} -> '{}'".format(str(len(spl)), + str(spl), + str(wrk))) + sp.pop(0) + + # if a value was found, store it to item + if wrk is not None: + for sameMatchStringItem in matchStringItems: + sameMatchStringItem(wrk, 'piratewthr') + self.logger.debug('_update: Value "{0}" written to item {1}'.format(wrk, sameMatchStringItem)) + + return + + def get_forecast(self): + """ + Requests the forecast information at pirateweather.net + """ + self.logger.info(f"get_forecast: url={self._build_url()}") + try: + response = self._session.get(self._build_url()) + except Exception as e: + self.logger.warning("get_forecast: Exception when sending GET" + " request for get_forecast: {}".format(e)) + return + try: + json_obj = response.json() + except Exception as e: + self.logger.warning("get_forecast: Response {} is no valid" + " json format: {}".format(response, e)) + return + self.logger.info(f"get_forecast: json response={json_obj}") + daily_data = OrderedDict() + if not json_obj.get('daily', False): + self.logger.warning("get_forecast: Response {} has no info for daily values." + " Ignoring response.".format(response)) + return + if not json_obj.get('hourly', False): + self.logger.warning("get_forecast: Response {} has no info for hourly values." + " Ignoring response.".format(response)) + return + + # add icon_visu, date and day to daily and currently + json_obj['daily'].update({'icon_visu': self.map_icon(json_obj['daily']['icon'])}) + json_obj['hourly'].update({'icon_visu': self.map_icon(json_obj['hourly']['icon'])}) + if not json_obj.get('currently'): + self.logger.warning("get_forecast: Response {} has no info for current values." + " Skipping update for currently values.".format(response)) + else: + date_entry = datetime.datetime.fromtimestamp(json_obj['currently']['time']).strftime('%d.%m.%Y') + day_entry = datetime.datetime.fromtimestamp(json_obj['currently']['time']).strftime('%A') + hour_entry = datetime.datetime.fromtimestamp(json_obj['currently']['time']).hour + json_obj['currently'].update({'date': date_entry, 'weekday': day_entry, + 'hour': hour_entry, 'icon_visu': + self.map_icon(json_obj['currently']['icon'])}) + + # add icon_visu, date and day to each day + for day in json_obj['daily'].get('data'): + date_entry = datetime.datetime.fromtimestamp(day['time']).strftime('%d.%m.%Y') + day_entry = datetime.datetime.fromtimestamp(day['time']).strftime('%A') + day.update({'date': date_entry, 'weekday': day_entry, 'icon_visu': self.map_icon(day['icon'])}) + daily_data.update({datetime.datetime.fromtimestamp(day['time']).date(): day}) + json_obj['daily'].update(daily_data) + json_obj['daily'].pop('data') + + # add icon_visu, date and day to each hour. Add the hours to the corresponding day as well as map to hour0, hour1, etc. + for number, hour in enumerate(json_obj['hourly'].get('data')): + date_entry = datetime.datetime.fromtimestamp(hour['time']).strftime('%d.%m.%Y') + day_entry = datetime.datetime.fromtimestamp(hour['time']).strftime('%A') + hour_entry = datetime.datetime.fromtimestamp(hour['time']).hour + date_key = datetime.datetime.fromtimestamp(hour['time']).date() + hour.update({'date': date_entry, 'weekday': day_entry, 'hour': hour_entry, 'icon_visu': self.map_icon(hour['icon'])}) + if json_obj['daily'].get(date_key) is None: + json_obj['daily'].update({date_key: {}}) + elif json_obj['daily'][date_key].get('hours') is None: + json_obj['daily'][date_key].update({'hours': {}}) + json_obj['daily'][date_key]['hours'].update(OrderedDict({hour_entry: hour})) + json_obj['hourly'].update(OrderedDict({'hour{}'.format(number): hour})) + if json_obj['daily'][date_key].get('precipProbability_mean') is None: + json_obj['daily'][date_key].update({'precipProbability_mean': []}) + if json_obj['daily'][date_key].get('precipIntensity_mean') is None: + json_obj['daily'][date_key].update({'precipIntensity_mean': []}) + if json_obj['daily'][date_key].get('temperature_mean') is None: + json_obj['daily'][date_key].update({'temperature_mean': []}) + json_obj['daily'][date_key]['precipProbability_mean'].append(hour.get('precipProbability')) + json_obj['daily'][date_key]['precipIntensity_mean'].append(hour.get('precipIntensity')) + json_obj['daily'][date_key]['temperature_mean'].append(hour.get('temperature')) + json_obj['hourly'].pop('data') + + # add mean values to each day and replace datetime object by day0, day1, day2, etc. + i = 0 + # for entry in json_obj['daily']: + json_keys = list(json_obj['daily'].keys()) + for entry in json_keys: + if isinstance(entry, datetime.date): + try: + precip_probability = json_obj['daily'][entry]['precipProbability_mean'] + json_obj['daily'][entry]['precipProbability_mean'] = round(sum(precip_probability)/len(precip_probability), 2) + precip_intensity = json_obj['daily'][entry]['precipIntensity_mean'] + json_obj['daily'][entry]['precipIntensity_mean'] = round(sum(precip_intensity)/len(precip_intensity), 2) + temperature = json_obj['daily'][entry]['temperature_mean'] + json_obj['daily'][entry]['temperature_mean'] = round(sum(temperature)/len(temperature), 2) + except Exception: + pass + json_obj['daily']['day{}'.format(i)] = json_obj['daily'].pop(entry) + i += 1 + return json_obj + + def map_icon(self, icon): + """ + Maps the icons from pirateweather.net to the icons in SmartVisu + + :param icon icon to map, as string. + :return SmartVisu icon as string. + """ + if icon == 'clear-day': + return 'sun_1' + elif icon == 'clear-night': + return 'sun_1' + elif icon == 'partly-cloudy-day': + return 'sun_4' + elif icon == 'partly-cloudy-night': + return 'sun_4' + elif icon == 'fog': + return 'sun_6' + elif icon == 'rain': + return 'cloud_8' + elif icon == 'wind': + return 'sun_10' + elif icon == 'snow': + return 'sun_12' + elif icon == 'cloudy': + return 'cloud_4' + elif icon == 'sleet': + return 'cloud_11' + else: + return 'high' + + def parse_item(self, item): + """ + Default plugin parse_item method. Is called when the plugin is initialized. Selects each item corresponding to + the pw_matchstring and adds it to an internal array + + :param item: The item to process. + """ + pw_matchstring = self.get_iattr_value(item.conf, 'pw_matchstring') + if pw_matchstring: + if not pw_matchstring in self._items: + self._items[pw_matchstring] = [] + self._items[pw_matchstring].append(item) + + self.add_item(item, mapping=pw_matchstring, config_data_dict={}) + + return + + + def get_items(self): + return self._items + + def get_json_data(self): + return self._jsonData + + def _build_url(self, url_type='forecast'): + """ + Builds a request url + @param url_type: url type (currently on 'forecast', as historic data are not supported. + @return: string of the url + """ + url = '' + if url_type == 'forecast': + url = self._base_forecast_url % (self._key, self._lat, self._lon) + parameters = "?lang=%s" % self._lang + if self._units is not None: + parameters = "%s&units=%s" % (parameters, self._units) + url = '%s%s' % (url, parameters) + else: + self.logger.error('_build_url: Wrong url type specified: %s' %url_type) + return url + diff --git a/piratewthr/assets/webif_tab1.jpg b/piratewthr/assets/webif_tab1.jpg new file mode 100755 index 000000000..d33c8f068 Binary files /dev/null and b/piratewthr/assets/webif_tab1.jpg differ diff --git a/piratewthr/assets/webif_tab2.jpg b/piratewthr/assets/webif_tab2.jpg new file mode 100755 index 000000000..8693bf2ca Binary files /dev/null and b/piratewthr/assets/webif_tab2.jpg differ diff --git a/piratewthr/plugin.yaml b/piratewthr/plugin.yaml new file mode 100755 index 000000000..87dd91539 --- /dev/null +++ b/piratewthr/plugin.yaml @@ -0,0 +1,6603 @@ +# Metadata for the Smart-Plugin +plugin: + # Global plugin attributes + type: web # plugin type (gateway, interface, protocol, system, web) + description: + de: 'Wetterdaten über pirateweather.net.' + en: 'Weather data via pirateweather.net.' + maintainer: msinn + tester: aschwith + state: ready + keywords: weather sun wind rain precipitation + #documentation: '' + support: 'https://knx-user-forum.de/forum/supportforen/smarthome-py/1852685' + version: 1.1.0 # Plugin version + sh_minversion: 1.9.3.4 # minimum shNG version to use this plugin + #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) + multi_instance: True # plugin supports multi instance + restartable: True + classname: PirateWeather # class containing the plugin + +parameters: + # Definition of parameters to be configured in etc/plugin.yaml + key: + type: str + mandatory: True + description: + de: 'Persönlicher API Key für pirateweather.net. Registrierung unter https://pirateweather.net/.' + en: 'Your own personal API key for pirateweather.net. For your own key register to https://pirateweather.net/.' + latitude: + type: str + mandatory: False + default: '' + description: + de: 'Latitude des Ortes, für den die Wetterdaten abgerufen werden sollen. Default: SmartHomeNG Settings' + en: 'Latitude for the location, of which weather data is requested. Default: SmartHomeNG Settings' + longitude: + type: str + mandatory: False + default: '' + description: + de: 'Longitude des Ortes, für den die Wetterdaten abgerufen werden sollen. Default: SmartHomeNG Settings.' + en: 'Longitude for the location, of which weather data is requested. Default: SmartHomeNG Settings.' + lang: + type: str + mandatory: False + default: 'de' + description: + de: 'Sprache der zurückgelieferten Daten. Mögliche Werte siehe https://pirateweather.net/.' + en: 'Language of the data to be returned. Possible values see https://pirateweather.net/.' + units: + type: str + mandatory: False + default: 'ca' + description: + de: 'Einheit der zurückgelieferten Daten. Mögliche Werte siehe https://pirateweather.net/. Default: "ca".' + en: 'Unit of the returned data. Possible values see https://pirateweather.net/. Default: "ca".' + cycle: + type: int + mandatory: False + default: 300 + description: + de: '(optional) Zeit zwischen zwei Updateläufen. Default ist 300 Sekunden.' + en: '(optional) Time period between two update cycles. Default is 300 seconds.' + + + +item_attributes: + # Definition of item attributes defined by this plugin + pw_matchstring: + type: str + mandatory: True + description: + de: 'Matchstring basierend auf der Response des "Forecast Request" aus https://pirateweather.net/ (Example Request), bspw. "currently/temperature". Ausnahmen: Im Fall von "alerts" wird ein Item vom Typ "list" benötigt, der Wert "alerts_string" schreibt die Alerts-Liste in einen HTML String. Das Array in "flags/sources" wird in ein "str" Item gemerged.' + en: 'Matchstring according to the response of the "forecast request" described in https://pirateweather.net/ (Example Request), e.g. "currently/temperature". Exceptions: In case of "alerts" an item of type "list" will be filled with raw data, the value "alerts_string" creates a html string with alerts data. The array "flags/sources" will be merged to a string.' + +item_structs: + weather: + name: Complete weather report from pirateweather.net - Current weather and forecasts are written to database + + latitude: + type: num + pw_matchstring@instance: latitude + + longitude: + type: num + pw_matchstring@instance: longitude + + timezone: + type: str + pw_matchstring@instance: timezone + + struct: + - piratewthr.current_weather + - piratewthr.forecast_hourly + - piratewthr.forecast_daily + + minutely: + + summary: + type: str + pw_matchstring@instance: minutely/summary + + icon: + type: str + pw_matchstring@instance: minutely/icon + + icon_visu: + type: str + pw_matchstring@instance: minutely/icon_visu + + alerts: + + list: + type: list + pw_matchstring@instance: alerts + + string_detail: + type: str + pw_matchstring@instance: alerts_string + + flags: + + sources: + type: str + pw_matchstring@instance: flags/sources + + units: + type: str + pw_matchstring@instance: flags/units + + nearest_station: + type: num + pw_matchstring@instance: flags/nearest-station + + weather_nodb: + name: Complete weather report from pirateweather.net - Current weather and forecasts are not to database + + latitude: + type: num + pw_matchstring@instance: latitude + + longitude: + type: num + pw_matchstring@instance: longitude + + timezone: + type: str + pw_matchstring@instance: timezone + + struct: + - piratewthr.current_weather_nodb + - piratewthr.forecast_hourly_nodb + - piratewthr.forecast_daily_nodb + + minutely: + + summary: + type: str + pw_matchstring@instance: minutely/summary + + icon: + type: str + pw_matchstring@instance: minutely/icon + + icon_visu: + type: str + pw_matchstring@instance: minutely/icon_visu + + alerts: + + list: + type: list + pw_matchstring@instance: alerts + + string_detail: + type: str + pw_matchstring@instance: alerts_string + + flags: + + sources: + type: str + pw_matchstring@instance: flags/sources + + units: + type: str + pw_matchstring@instance: flags/units + + nearest_station: + type: num + pw_matchstring@instance: flags/nearest-station + + weather_current_db: + name: Complete weather report from pirateweather.net - Only current weather is written to database + + latitude: + type: num + pw_matchstring@instance: latitude + + longitude: + type: num + pw_matchstring@instance: longitude + + timezone: + type: str + pw_matchstring@instance: timezone + + struct: + - piratewthr.current_weather + - piratewthr.forecast_hourly_nodb + - piratewthr.forecast_daily_nodb + + minutely: + + summary: + type: str + pw_matchstring@instance: minutely/summary + + icon: + type: str + pw_matchstring@instance: minutely/icon + + icon_visu: + type: str + pw_matchstring@instance: minutely/icon_visu + + alerts: + + list: + type: list + pw_matchstring@instance: alerts + + string_detail: + type: str + pw_matchstring@instance: alerts_string + + flags: + + sources: + type: str + pw_matchstring@instance: flags/sources + + units: + type: str + pw_matchstring@instance: flags/units + + nearest_station: + type: num + pw_matchstring@instance: flags/nearest-station + + weather_current_nodb: + name: Complete weather report from pirateweather.net - No data is written to database + + latitude: + type: num + pw_matchstring@instance: latitude + + longitude: + type: num + pw_matchstring@instance: longitude + + timezone: + type: str + pw_matchstring@instance: timezone + + struct: + - piratewthr.current_weather_nodb + - piratewthr.forecast_hourly_nodb + - piratewthr.forecast_daily_nodb + + minutely: + + summary: + type: str + pw_matchstring@instance: minutely/summary + + icon: + type: str + pw_matchstring@instance: minutely/icon + + icon_visu: + type: str + pw_matchstring@instance: minutely/icon_visu + + alerts: + + list: + type: list + pw_matchstring@instance: alerts + + string_detail: + type: str + pw_matchstring@instance: alerts_string + + flags: + + sources: + type: str + pw_matchstring@instance: flags/sources + + units: + type: str + pw_matchstring@instance: flags/units + + nearest_station: + type: num + pw_matchstring@instance: flags/nearest-station + + current_weather: + name: Current weather of Weather report from pirateweather.net - Data is written do database + currently: + + time_epoch: + type: num + pw_matchstring@instance: currently/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + weekday: + type: str + pw_matchstring@instance: currently/weekday + + summary: + type: str + pw_matchstring@instance: currently/summary + + icon: + type: str + pw_matchstring@instance: currently/icon + + icon_visu: + type: str + pw_matchstring@instance: currently/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: currently/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: currently/precipIntensity + database: init + database_maxage: 731 # 2 Jahre + + precipIntensityError: + type: num + pw_matchstring@instance: currently/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: currently/precipProbability + database: init + database_maxage: 731 # 2 Jahre + + precipType: + type: str + pw_matchstring@instance: currently/precipType + + temperature: + type: num + pw_matchstring@instance: currently/temperature + database: init + database_maxage: 731 # 2 Jahre + + apparenttemperature: + type: num + pw_matchstring@instance: currently/apparentTemperature + database: init + database_maxage: 731 # 2 Jahre + + dewpoint: + type: num + pw_matchstring@instance: currently/dewPoint + database: init + database_maxage: 731 # 2 Jahre + + humidity: + type: num + pw_matchstring@instance: currently/humidity + database: init + database_maxage: 731 # 2 Jahre + + pressure: + type: num + pw_matchstring@instance: currently/pressure + database: init + database_maxage: 731 # 2 Jahre + + windSpeed: + type: num + pw_matchstring@instance: currently/windSpeed + database: init + database_maxage: 731 # 2 Jahre + + windGust: + type: num + pw_matchstring@instance: currently/windGust + database: init + database_maxage: 731 # 2 Jahre + + windBearing: + type: num + pw_matchstring@instance: currently/windBearing + database: init + database_maxage: 731 # 2 Jahre + + cloudCover: + type: num + pw_matchstring@instance: currently/cloudCover + database: init + database_maxage: 731 # 2 Jahre + + uvIndex: + type: num + pw_matchstring@instance: currently/uvIndex + database: init + database_maxage: 731 # 2 Jahre + + visibility: + type: num + pw_matchstring@instance: currently/visibility + database: init + database_maxage: 731 # 2 Jahre + + ozone: + type: num + pw_matchstring@instance: currently/ozone + database: init + database_maxage: 731 # 2 Jahre + + date: + type: str + pw_matchstring@instance: currently/date + database: init + database_maxage: 731 # 2 Jahre + + day: + type: num + pw_matchstring@instance: currently/day + database: init + database_maxage: 731 # 2 Jahre + + forecast_hourly: + name: Hourly forcast of Weather report from pirateweather.net - Data is written do database + hourly: + + summary: + type: str + pw_matchstring@instance: hourly/summary + + icon: + type: str + pw_matchstring@instance: hourly/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/icon_visu + + hour0: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour0/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour0/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour0/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour0/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour0/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour0/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour0/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour0/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: hourly/hour0/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour0/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour0/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour0/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: hourly/hour0/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: hourly/hour0/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour0/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: hourly/hour0/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: hourly/hour0/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour0/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour0/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: hourly/hour0/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: hourly/hour0/ozone + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: hourly/hour0/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: hourly/hour0/weekday + database: init + database_maxage: 92 + + hour1: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour1/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour1/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour1/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour1/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour1/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour1/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour1/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour1/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: hourly/hour1/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour1/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour1/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour1/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: hourly/hour1/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: hourly/hour1/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour1/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: hourly/hour1/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: hourly/hour1/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour1/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour1/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: hourly/hour1/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: hourly/hour1/ozone + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: hourly/hour1/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: hourly/hour1/weekday + database: init + database_maxage: 92 + + hour2: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour2/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour2/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour2/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour2/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour2/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour2/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour2/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour2/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: hourly/hour2/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour2/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour2/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour2/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: hourly/hour2/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: hourly/hour2/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour2/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: hourly/hour2/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: hourly/hour2/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour2/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour2/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: hourly/hour2/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: hourly/hour2/ozone + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: hourly/hour2/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: hourly/hour2/weekday + database: init + database_maxage: 92 + + hour3: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour3/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour3/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour3/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour3/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour3/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour3/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour3/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour3/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: hourly/hour3/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour3/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour3/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour3/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: hourly/hour3/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: hourly/hour3/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour3/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: hourly/hour3/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: hourly/hour3/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour3/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour3/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: hourly/hour3/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: hourly/hour3/ozone + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: hourly/hour3/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: hourly/hour3/weekday + database: init + database_maxage: 92 + + hour4: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour4/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour4/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour4/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour4/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour4/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour4/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour4/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour4/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: hourly/hour4/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour4/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour4/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour4/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: hourly/hour4/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: hourly/hour4/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour4/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: hourly/hour4/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: hourly/hour4/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour4/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour4/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: hourly/hour4/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: hourly/hour4/ozone + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: hourly/hour4/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: hourly/hour4/weekday + database: init + database_maxage: 92 + + hour5: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour5/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour5/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour5/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour5/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour5/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour5/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour5/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour5/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: hourly/hour5/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour5/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour5/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour5/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: hourly/hour5/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: hourly/hour5/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour5/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: hourly/hour5/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: hourly/hour5/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour5/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour5/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: hourly/hour5/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: hourly/hour5/ozone + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: hourly/hour5/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: hourly/hour5/weekday + database: init + database_maxage: 92 + + hour6: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour6/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour6/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour6/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour6/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour6/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour6/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour6/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour6/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: hourly/hour6/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour6/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour6/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour6/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: hourly/hour6/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: hourly/hour6/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour6/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: hourly/hour6/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: hourly/hour6/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour6/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour6/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: hourly/hour6/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: hourly/hour6/ozone + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: hourly/hour6/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: hourly/hour6/weekday + database: init + database_maxage: 92 + + hour7: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour7/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour7/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour7/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour7/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour7/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour7/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour7/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour7/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: hourly/hour7/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour7/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour7/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour7/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: hourly/hour7/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: hourly/hour7/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour7/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: hourly/hour7/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: hourly/hour7/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour7/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour7/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: hourly/hour7/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: hourly/hour7/ozone + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: hourly/hour7/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: hourly/hour7/weekday + database: init + database_maxage: 92 + + hour8: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour8/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour8/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour8/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour8/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour8/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour8/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour8/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour8/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: hourly/hour8/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour8/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour8/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour8/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: hourly/hour8/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: hourly/hour8/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour8/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: hourly/hour8/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: hourly/hour8/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour8/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour8/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: hourly/hour8/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: hourly/hour8/ozone + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: hourly/hour8/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: hourly/hour8/weekday + database: init + database_maxage: 92 + + hour9: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour9/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour9/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour9/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour9/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour9/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour9/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour9/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour9/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: hourly/hour9/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour9/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour9/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour9/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: hourly/hour9/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: hourly/hour9/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour9/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: hourly/hour9/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: hourly/hour9/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour9/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour9/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: hourly/hour9/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: hourly/hour9/ozone + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: hourly/hour9/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: hourly/hour9/weekday + database: init + database_maxage: 92 + + hour10: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour10/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour10/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour10/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour10/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour10/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour10/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour10/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour10/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: hourly/hour10/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour10/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour10/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour10/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: hourly/hour10/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: hourly/hour10/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour10/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: hourly/hour10/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: hourly/hour10/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour10/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour10/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: hourly/hour10/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: hourly/hour10/ozone + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: hourly/hour10/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: hourly/hour10/weekday + database: init + database_maxage: 92 + + hour11: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour11/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour11/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour11/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour11/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour11/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour11/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour11/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour11/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: hourly/hour11/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour11/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour11/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour11/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: hourly/hour11/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: hourly/hour11/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour11/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: hourly/hour11/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: hourly/hour11/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour11/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour11/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: hourly/hour11/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: hourly/hour11/ozone + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: hourly/hour11/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: hourly/hour11/weekday + database: init + database_maxage: 92 + + hour12: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour12/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour12/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour12/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour12/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour12/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour12/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour12/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour12/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: hourly/hour12/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour12/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour12/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour12/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: hourly/hour12/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: hourly/hour12/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour12/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: hourly/hour12/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: hourly/hour12/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour12/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour12/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: hourly/hour12/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: hourly/hour12/ozone + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: hourly/hour12/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: hourly/hour12/weekday + database: init + database_maxage: 92 + + forecast_daily: + name: Daily forcast of Weather report from pirateweather.net - Data is written do database + daily: + + summary: + type: str + pw_matchstring@instance: daily/summary + + icon: + type: str + pw_matchstring@instance: daily/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/icon_visu + + day0: + + time_epoch: + type: num + pw_matchstring@instance: daily/day0/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day0/summary + + icon: + type: str + pw_matchstring@instance: daily/day0/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day0/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day0/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day0/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day0/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day0/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: daily/day0/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day0/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day0/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: daily/day0/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: daily/day0/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: daily/day0/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: daily/day0/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: daily/day0/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: daily/day0/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: daily/day0/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: daily/day0/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: daily/day0/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: daily/day0/ozone + database: init + database_maxage: 92 + + temperatureMin: + type: num + pw_matchstring@instance: daily/day0/temperatureMin + database: init + database_maxage: 92 + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day0/temperatureMinTime + database: init + database_maxage: 92 + + temperatureMax: + type: num + pw_matchstring@instance: daily/day0/temperatureMax + database: init + database_maxage: 92 + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day0/temperatureMaxTime + database: init + database_maxage: 92 + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day0/apparentTemperatureMin + database: init + database_maxage: 92 + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day0/apparentTemperatureMinTime + database: init + database_maxage: 92 + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day0/apparentTemperatureMax + database: init + database_maxage: 92 + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day0/apparentTemperatureMaxTime + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: daily/day0/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: daily/day0/weekday + database: init + database_maxage: 92 + + precipProbability_mean: + type: num + pw_matchstring@instance: daily/day0/precipProbability_mean + database: init + database_maxage: 92 + + precipIntensity_mean: + type: num + pw_matchstring@instance: daily/day0/precipIntensity_mean + database: init + database_maxage: 92 + + temperature_mean: + type: num + pw_matchstring@instance: daily/day0/temperature_mean + database: init + database_maxage: 92 + + hours: + type: dict + pw_matchstring@instance: daily/day0/hours + + day1: + + time_epoch: + type: num + pw_matchstring@instance: daily/day1/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day1/summary + + icon: + type: str + pw_matchstring@instance: daily/day1/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day1/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day1/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day1/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day1/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day1/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: daily/day1/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day1/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day1/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: daily/day1/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: daily/day1/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: daily/day1/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: daily/day1/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: daily/day1/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: daily/day1/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: daily/day1/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: daily/day1/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: daily/day1/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: daily/day1/ozone + database: init + database_maxage: 92 + + temperatureMin: + type: num + pw_matchstring@instance: daily/day1/temperatureMin + database: init + database_maxage: 92 + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day1/temperatureMinTime + database: init + database_maxage: 92 + + temperatureMax: + type: num + pw_matchstring@instance: daily/day1/temperatureMax + database: init + database_maxage: 92 + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day1/temperatureMaxTime + database: init + database_maxage: 92 + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day1/apparentTemperatureMin + database: init + database_maxage: 92 + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day1/apparentTemperatureMinTime + database: init + database_maxage: 92 + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day1/apparentTemperatureMax + database: init + database_maxage: 92 + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day1/apparentTemperatureMaxTime + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: daily/day1/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: daily/day1/weekday + database: init + database_maxage: 92 + + precipProbability_mean: + type: num + pw_matchstring@instance: daily/day1/precipProbability_mean + database: init + database_maxage: 92 + + precipIntensity_mean: + type: num + pw_matchstring@instance: daily/day1/precipIntensity_mean + database: init + database_maxage: 92 + + temperature_mean: + type: num + pw_matchstring@instance: daily/day1/temperature_mean + database: init + database_maxage: 92 + + hours: + type: dict + pw_matchstring@instance: daily/day1/hours + + day2: + + time_epoch: + type: num + pw_matchstring@instance: daily/day2/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day2/summary + + icon: + type: str + pw_matchstring@instance: daily/day2/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day2/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day2/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day2/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day2/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day2/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: daily/day2/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day2/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day2/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: daily/day2/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: daily/day2/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: daily/day2/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: daily/day2/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: daily/day2/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: daily/day2/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: daily/day2/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: daily/day2/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: daily/day2/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: daily/day2/ozone + database: init + database_maxage: 92 + + temperatureMin: + type: num + pw_matchstring@instance: daily/day2/temperatureMin + database: init + database_maxage: 92 + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day2/temperatureMinTime + database: init + database_maxage: 92 + + temperatureMax: + type: num + pw_matchstring@instance: daily/day2/temperatureMax + database: init + database_maxage: 92 + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day2/temperatureMaxTime + database: init + database_maxage: 92 + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day2/apparentTemperatureMin + database: init + database_maxage: 92 + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day2/apparentTemperatureMinTime + database: init + database_maxage: 92 + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day2/apparentTemperatureMax + database: init + database_maxage: 92 + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day2/apparentTemperatureMaxTime + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: daily/day2/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: daily/day2/weekday + database: init + database_maxage: 92 + + precipProbability_mean: + type: num + pw_matchstring@instance: daily/day2/precipProbability_mean + database: init + database_maxage: 92 + + precipIntensity_mean: + type: num + pw_matchstring@instance: daily/day2/precipIntensity_mean + database: init + database_maxage: 92 + + temperature_mean: + type: num + pw_matchstring@instance: daily/day2/temperature_mean + database: init + database_maxage: 92 + + hours: + type: dict + pw_matchstring@instance: daily/day2/hours + + day3: + + time_epoch: + type: num + pw_matchstring@instance: daily/day3/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day3/summary + + icon: + type: str + pw_matchstring@instance: daily/day3/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day3/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day3/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day3/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day3/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day3/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: daily/day3/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day3/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day3/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: daily/day3/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: daily/day3/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: daily/day3/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: daily/day3/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: daily/day3/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: daily/day3/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: daily/day3/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: daily/day3/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: daily/day3/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: daily/day3/ozone + database: init + database_maxage: 92 + + temperatureMin: + type: num + pw_matchstring@instance: daily/day3/temperatureMin + database: init + database_maxage: 92 + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day3/temperatureMinTime + database: init + database_maxage: 92 + + temperatureMax: + type: num + pw_matchstring@instance: daily/day3/temperatureMax + database: init + database_maxage: 92 + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day3/temperatureMaxTime + database: init + database_maxage: 92 + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day3/apparentTemperatureMin + database: init + database_maxage: 92 + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day3/apparentTemperatureMinTime + database: init + database_maxage: 92 + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day3/apparentTemperatureMax + database: init + database_maxage: 92 + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day3/apparentTemperatureMaxTime + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: daily/day3/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: daily/day3/weekday + database: init + database_maxage: 92 + + day4: + + time_epoch: + type: num + pw_matchstring@instance: daily/day4/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day4/summary + + icon: + type: str + pw_matchstring@instance: daily/day4/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day4/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day4/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day4/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day4/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day4/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: daily/day4/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day4/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day4/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: daily/day4/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: daily/day4/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: daily/day4/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: daily/day4/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: daily/day4/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: daily/day4/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: daily/day4/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: daily/day4/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: daily/day4/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: daily/day4/ozone + database: init + database_maxage: 92 + + temperatureMin: + type: num + pw_matchstring@instance: daily/day4/temperatureMin + database: init + database_maxage: 92 + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day4/temperatureMinTime + database: init + database_maxage: 92 + + temperatureMax: + type: num + pw_matchstring@instance: daily/day4/temperatureMax + database: init + database_maxage: 92 + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day4/temperatureMaxTime + database: init + database_maxage: 92 + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day4/apparentTemperatureMin + database: init + database_maxage: 92 + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day4/apparentTemperatureMinTime + database: init + database_maxage: 92 + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day4/apparentTemperatureMax + database: init + database_maxage: 92 + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day4/apparentTemperatureMaxTime + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: daily/day4/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: daily/day4/weekday + database: init + database_maxage: 92 + + day5: + + time_epoch: + type: num + pw_matchstring@instance: daily/day5/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day5/summary + + icon: + type: str + pw_matchstring@instance: daily/day5/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day5/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day5/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day5/precipIntensity + database: init + database_maxage: 92 + precipIntensityError: + type: num + pw_matchstring@instance: daily/day5/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day5/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: daily/day5/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day5/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day5/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: daily/day5/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: daily/day5/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: daily/day5/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: daily/day5/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: daily/day5/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: daily/day5/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: daily/day5/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: daily/day5/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: daily/day5/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: daily/day5/ozone + database: init + database_maxage: 92 + + temperatureMin: + type: num + pw_matchstring@instance: daily/day5/temperatureMin + database: init + database_maxage: 92 + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day5/temperatureMinTime + database: init + database_maxage: 92 + + temperatureMax: + type: num + pw_matchstring@instance: daily/day5/temperatureMax + database: init + database_maxage: 92 + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day5/temperatureMaxTime + database: init + database_maxage: 92 + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day5/apparentTemperatureMin + database: init + database_maxage: 92 + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day5/apparentTemperatureMinTime + database: init + database_maxage: 92 + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day5/apparentTemperatureMax + database: init + database_maxage: 92 + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day5/apparentTemperatureMaxTime + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: daily/day5/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: daily/day5/weekday + database: init + database_maxage: 92 + + day6: + + time_epoch: + type: num + pw_matchstring@instance: daily/day6/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day6/summary + + icon: + type: str + pw_matchstring@instance: daily/day6/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day6/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day6/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day6/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day6/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day6/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: daily/day6/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day6/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day6/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: daily/day6/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: daily/day6/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: daily/day6/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: daily/day6/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: daily/day6/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: daily/day6/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: daily/day6/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: daily/day6/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: daily/day6/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: daily/day6/ozone + database: init + database_maxage: 92 + + temperatureMin: + type: num + pw_matchstring@instance: daily/day6/temperatureMin + database: init + database_maxage: 92 + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day6/temperatureMinTime + database: init + database_maxage: 92 + + temperatureMax: + type: num + pw_matchstring@instance: daily/day6/temperatureMax + database: init + database_maxage: 92 + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day6/temperatureMaxTime + database: init + database_maxage: 92 + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day6/apparentTemperatureMin + database: init + database_maxage: 92 + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day6/apparentTemperatureMinTime + database: init + database_maxage: 92 + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day6/apparentTemperatureMax + database: init + database_maxage: 92 + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day6/apparentTemperatureMaxTime + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: daily/day6/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: daily/day6/weekday + database: init + database_maxage: 92 + + day7: + + time_epoch: + type: num + pw_matchstring@instance: daily/day7/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day7/summary + + icon: + type: str + pw_matchstring@instance: daily/day7/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day7/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day7/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day7/precipIntensity + database: init + database_maxage: 92 + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day7/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day7/precipProbability + database: init + database_maxage: 92 + + precipType: + type: str + pw_matchstring@instance: daily/day7/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day7/temperature + database: init + database_maxage: 92 + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day7/apparentTemperature + database: init + database_maxage: 92 + + dewpoint: + type: num + pw_matchstring@instance: daily/day7/dewPoint + database: init + database_maxage: 92 + + humidity: + type: num + pw_matchstring@instance: daily/day7/humidity + database: init + database_maxage: 92 + + pressure: + type: num + pw_matchstring@instance: daily/day7/pressure + database: init + database_maxage: 92 + + windSpeed: + type: num + pw_matchstring@instance: daily/day7/windSpeed + database: init + database_maxage: 92 + + windGust: + type: num + pw_matchstring@instance: daily/day7/windGust + database: init + database_maxage: 92 + + windBearing: + type: num + pw_matchstring@instance: daily/day7/windBearing + database: init + database_maxage: 92 + + cloudCover: + type: num + pw_matchstring@instance: daily/day7/cloudCover + database: init + database_maxage: 92 + + uvIndex: + type: num + pw_matchstring@instance: daily/day7/uvIndex + database: init + database_maxage: 92 + + visibility: + type: num + pw_matchstring@instance: daily/day7/visibility + database: init + database_maxage: 92 + + ozone: + type: num + pw_matchstring@instance: daily/day7/ozone + database: init + database_maxage: 92 + + temperatureMin: + type: num + pw_matchstring@instance: daily/day7/temperatureMin + database: init + database_maxage: 92 + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day7/temperatureMinTime + database: init + database_maxage: 92 + + temperatureMax: + type: num + pw_matchstring@instance: daily/day7/temperatureMax + database: init + database_maxage: 92 + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day7/temperatureMaxTime + database: init + database_maxage: 92 + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day7/apparentTemperatureMin + database: init + database_maxage: 92 + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day7/apparentTemperatureMinTime + database: init + database_maxage: 92 + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day7/apparentTemperatureMax + database: init + database_maxage: 92 + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day7/apparentTemperatureMaxTime + database: init + database_maxage: 92 + + date: + type: str + pw_matchstring@instance: daily/day7/date + database: init + database_maxage: 92 + + weekday: + type: str + pw_matchstring@instance: daily/day7/weekday + database: init + database_maxage: 92 + + current_weather_nodb: + name: Current weather of Weather report from pirateweather.net - Data is NOT written do database + currently: + + time_epoch: + type: num + pw_matchstring@instance: currently/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: currently/summary + + icon: + type: str + pw_matchstring@instance: currently/icon + + icon_visu: + type: str + pw_matchstring@instance: currently/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: currently/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: currently/precipIntensity + + precipIntensityError: + type: num + pw_matchstring@instance: currently/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: currently/precipProbability + + precipType: + type: str + pw_matchstring@instance: currently/precipType + + temperature: + type: num + pw_matchstring@instance: currently/temperature + + apparenttemperature: + type: num + pw_matchstring@instance: currently/apparentTemperature + + dewpoint: + type: num + pw_matchstring@instance: currently/dewPoint + + humidity: + type: num + pw_matchstring@instance: currently/humidity + + pressure: + type: num + pw_matchstring@instance: currently/pressure + + windSpeed: + type: num + pw_matchstring@instance: currently/windSpeed + + windGust: + type: num + pw_matchstring@instance: currently/windGust + + windBearing: + type: num + pw_matchstring@instance: currently/windBearing + + cloudCover: + type: num + pw_matchstring@instance: currently/cloudCover + + uvIndex: + type: num + pw_matchstring@instance: currently/uvIndex + + visibility: + type: num + pw_matchstring@instance: currently/visibility + + ozone: + type: num + pw_matchstring@instance: currently/ozone + + date: + type: str + pw_matchstring@instance: currently/date + + day: + type: num + pw_matchstring@instance: currently/day + + forecast_hourly_nodb: + name: Hourly forcast of Weather report from pirateweather.net - Data is NOT written do database + hourly: + + summary: + type: str + pw_matchstring@instance: hourly/summary + + icon: + type: str + pw_matchstring@instance: hourly/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/icon_visu + + hour0: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour0/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour0/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour0/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour0/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour0/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour0/precipIntensity + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour0/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour0/precipProbability + + precipType: + type: str + pw_matchstring@instance: hourly/hour0/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour0/temperature + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour0/apparentTemperature + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour0/dewPoint + + humidity: + type: num + pw_matchstring@instance: hourly/hour0/humidity + + pressure: + type: num + pw_matchstring@instance: hourly/hour0/pressure + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour0/windSpeed + + windGust: + type: num + pw_matchstring@instance: hourly/hour0/windGust + + windBearing: + type: num + pw_matchstring@instance: hourly/hour0/windBearing + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour0/cloudCover + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour0/uvIndex + + visibility: + type: num + pw_matchstring@instance: hourly/hour0/visibility + + ozone: + type: num + pw_matchstring@instance: hourly/hour0/ozone + + date: + type: str + pw_matchstring@instance: hourly/hour0/date + + + weekday: + type: str + pw_matchstring@instance: hourly/hour0/weekday + + + hour1: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour1/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour1/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour1/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour1/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour1/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour1/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour1/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour1/precipProbability + + + precipType: + type: str + pw_matchstring@instance: hourly/hour1/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour1/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour1/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour1/dewPoint + + + humidity: + type: num + pw_matchstring@instance: hourly/hour1/humidity + + + pressure: + type: num + pw_matchstring@instance: hourly/hour1/pressure + + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour1/windSpeed + + + windGust: + type: num + pw_matchstring@instance: hourly/hour1/windGust + + + windBearing: + type: num + pw_matchstring@instance: hourly/hour1/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour1/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour1/uvIndex + + + visibility: + type: num + pw_matchstring@instance: hourly/hour1/visibility + + + ozone: + type: num + pw_matchstring@instance: hourly/hour1/ozone + + + date: + type: str + pw_matchstring@instance: hourly/hour1/date + + + weekday: + type: str + pw_matchstring@instance: hourly/hour1/weekday + + + hour2: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour2/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour2/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour2/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour2/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour2/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour2/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour2/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour2/precipProbability + + + precipType: + type: str + pw_matchstring@instance: hourly/hour2/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour2/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour2/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour2/dewPoint + + + humidity: + type: num + pw_matchstring@instance: hourly/hour2/humidity + + + pressure: + type: num + pw_matchstring@instance: hourly/hour2/pressure + + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour2/windSpeed + + + windGust: + type: num + pw_matchstring@instance: hourly/hour2/windGust + + + windBearing: + type: num + pw_matchstring@instance: hourly/hour2/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour2/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour2/uvIndex + + + visibility: + type: num + pw_matchstring@instance: hourly/hour2/visibility + + + ozone: + type: num + pw_matchstring@instance: hourly/hour2/ozone + + + date: + type: str + pw_matchstring@instance: hourly/hour2/date + + + weekday: + type: str + pw_matchstring@instance: hourly/hour2/weekday + + + hour3: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour3/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour3/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour3/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour3/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour3/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour3/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour3/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour3/precipProbability + + + precipType: + type: str + pw_matchstring@instance: hourly/hour3/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour3/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour3/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour3/dewPoint + + + humidity: + type: num + pw_matchstring@instance: hourly/hour3/humidity + + + pressure: + type: num + pw_matchstring@instance: hourly/hour3/pressure + + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour3/windSpeed + + + windGust: + type: num + pw_matchstring@instance: hourly/hour3/windGust + + + windBearing: + type: num + pw_matchstring@instance: hourly/hour3/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour3/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour3/uvIndex + + + visibility: + type: num + pw_matchstring@instance: hourly/hour3/visibility + + + ozone: + type: num + pw_matchstring@instance: hourly/hour3/ozone + + + date: + type: str + pw_matchstring@instance: hourly/hour3/date + + + weekday: + type: str + pw_matchstring@instance: hourly/hour3/weekday + + + hour4: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour4/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour4/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour4/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour4/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour4/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour4/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour4/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour4/precipProbability + + + precipType: + type: str + pw_matchstring@instance: hourly/hour4/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour4/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour4/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour4/dewPoint + + + humidity: + type: num + pw_matchstring@instance: hourly/hour4/humidity + + + pressure: + type: num + pw_matchstring@instance: hourly/hour4/pressure + + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour4/windSpeed + + + windGust: + type: num + pw_matchstring@instance: hourly/hour4/windGust + + + windBearing: + type: num + pw_matchstring@instance: hourly/hour4/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour4/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour4/uvIndex + + + visibility: + type: num + pw_matchstring@instance: hourly/hour4/visibility + + + ozone: + type: num + pw_matchstring@instance: hourly/hour4/ozone + + + date: + type: str + pw_matchstring@instance: hourly/hour4/date + + + weekday: + type: str + pw_matchstring@instance: hourly/hour4/weekday + + + hour5: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour5/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour5/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour5/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour5/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour5/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour5/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour5/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour5/precipProbability + + + precipType: + type: str + pw_matchstring@instance: hourly/hour5/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour5/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour5/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour5/dewPoint + + + humidity: + type: num + pw_matchstring@instance: hourly/hour5/humidity + + + pressure: + type: num + pw_matchstring@instance: hourly/hour5/pressure + + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour5/windSpeed + + + windGust: + type: num + pw_matchstring@instance: hourly/hour5/windGust + + + windBearing: + type: num + pw_matchstring@instance: hourly/hour5/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour5/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour5/uvIndex + + + visibility: + type: num + pw_matchstring@instance: hourly/hour5/visibility + + + ozone: + type: num + pw_matchstring@instance: hourly/hour5/ozone + + + date: + type: str + pw_matchstring@instance: hourly/hour5/date + + + weekday: + type: str + pw_matchstring@instance: hourly/hour5/weekday + + + hour6: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour6/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour6/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour6/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour6/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour6/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour6/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour6/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour6/precipProbability + + + precipType: + type: str + pw_matchstring@instance: hourly/hour6/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour6/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour6/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour6/dewPoint + + + humidity: + type: num + pw_matchstring@instance: hourly/hour6/humidity + + + pressure: + type: num + pw_matchstring@instance: hourly/hour6/pressure + + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour6/windSpeed + + + windGust: + type: num + pw_matchstring@instance: hourly/hour6/windGust + + + windBearing: + type: num + pw_matchstring@instance: hourly/hour6/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour6/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour6/uvIndex + + + visibility: + type: num + pw_matchstring@instance: hourly/hour6/visibility + + + ozone: + type: num + pw_matchstring@instance: hourly/hour6/ozone + + + date: + type: str + pw_matchstring@instance: hourly/hour6/date + + + weekday: + type: str + pw_matchstring@instance: hourly/hour6/weekday + + + hour7: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour7/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour7/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour7/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour7/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour7/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour7/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour7/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour7/precipProbability + + + precipType: + type: str + pw_matchstring@instance: hourly/hour7/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour7/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour7/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour7/dewPoint + + + humidity: + type: num + pw_matchstring@instance: hourly/hour7/humidity + + + pressure: + type: num + pw_matchstring@instance: hourly/hour7/pressure + + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour7/windSpeed + + + windGust: + type: num + pw_matchstring@instance: hourly/hour7/windGust + + + windBearing: + type: num + pw_matchstring@instance: hourly/hour7/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour7/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour7/uvIndex + + + visibility: + type: num + pw_matchstring@instance: hourly/hour7/visibility + + + ozone: + type: num + pw_matchstring@instance: hourly/hour7/ozone + + + date: + type: str + pw_matchstring@instance: hourly/hour7/date + + + weekday: + type: str + pw_matchstring@instance: hourly/hour7/weekday + + + hour8: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour8/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour8/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour8/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour8/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour8/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour8/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour8/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour8/precipProbability + + + precipType: + type: str + pw_matchstring@instance: hourly/hour8/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour8/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour8/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour8/dewPoint + + + humidity: + type: num + pw_matchstring@instance: hourly/hour8/humidity + + + pressure: + type: num + pw_matchstring@instance: hourly/hour8/pressure + + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour8/windSpeed + + + windGust: + type: num + pw_matchstring@instance: hourly/hour8/windGust + + + windBearing: + type: num + pw_matchstring@instance: hourly/hour8/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour8/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour8/uvIndex + + + visibility: + type: num + pw_matchstring@instance: hourly/hour8/visibility + + + ozone: + type: num + pw_matchstring@instance: hourly/hour8/ozone + + + date: + type: str + pw_matchstring@instance: hourly/hour8/date + + + weekday: + type: str + pw_matchstring@instance: hourly/hour8/weekday + + + hour9: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour9/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour9/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour9/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour9/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour9/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour9/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour9/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour9/precipProbability + + + precipType: + type: str + pw_matchstring@instance: hourly/hour9/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour9/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour9/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour9/dewPoint + + + humidity: + type: num + pw_matchstring@instance: hourly/hour9/humidity + + + pressure: + type: num + pw_matchstring@instance: hourly/hour9/pressure + + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour9/windSpeed + + + windGust: + type: num + pw_matchstring@instance: hourly/hour9/windGust + + + windBearing: + type: num + pw_matchstring@instance: hourly/hour9/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour9/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour9/uvIndex + + + visibility: + type: num + pw_matchstring@instance: hourly/hour9/visibility + + + ozone: + type: num + pw_matchstring@instance: hourly/hour9/ozone + + + date: + type: str + pw_matchstring@instance: hourly/hour9/date + + + weekday: + type: str + pw_matchstring@instance: hourly/hour9/weekday + + + hour10: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour10/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour10/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour10/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour10/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour10/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour10/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour10/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour10/precipProbability + + + precipType: + type: str + pw_matchstring@instance: hourly/hour10/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour10/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour10/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour10/dewPoint + + + humidity: + type: num + pw_matchstring@instance: hourly/hour10/humidity + + + pressure: + type: num + pw_matchstring@instance: hourly/hour10/pressure + + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour10/windSpeed + + + windGust: + type: num + pw_matchstring@instance: hourly/hour10/windGust + + + windBearing: + type: num + pw_matchstring@instance: hourly/hour10/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour10/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour10/uvIndex + + + visibility: + type: num + pw_matchstring@instance: hourly/hour10/visibility + + + ozone: + type: num + pw_matchstring@instance: hourly/hour10/ozone + + + date: + type: str + pw_matchstring@instance: hourly/hour10/date + + + weekday: + type: str + pw_matchstring@instance: hourly/hour10/weekday + + + hour11: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour11/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour11/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour11/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour11/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour11/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour11/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour11/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour11/precipProbability + + + precipType: + type: str + pw_matchstring@instance: hourly/hour11/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour11/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour11/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour11/dewPoint + + + humidity: + type: num + pw_matchstring@instance: hourly/hour11/humidity + + + pressure: + type: num + pw_matchstring@instance: hourly/hour11/pressure + + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour11/windSpeed + + + windGust: + type: num + pw_matchstring@instance: hourly/hour11/windGust + + + windBearing: + type: num + pw_matchstring@instance: hourly/hour11/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour11/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour11/uvIndex + + + visibility: + type: num + pw_matchstring@instance: hourly/hour11/visibility + + + ozone: + type: num + pw_matchstring@instance: hourly/hour11/ozone + + + date: + type: str + pw_matchstring@instance: hourly/hour11/date + + + weekday: + type: str + pw_matchstring@instance: hourly/hour11/weekday + + + hour12: + + time_epoch: + type: num + pw_matchstring@instance: hourly/hour12/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: hourly/hour12/summary + + icon: + type: str + pw_matchstring@instance: hourly/hour12/icon + + icon_visu: + type: str + pw_matchstring@instance: hourly/hour12/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: hourly/hour12/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: hourly/hour12/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: hourly/hour12/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: hourly/hour12/precipProbability + + + precipType: + type: str + pw_matchstring@instance: hourly/hour12/precipType + + temperature: + type: num + pw_matchstring@instance: hourly/hour12/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: hourly/hour12/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: hourly/hour12/dewPoint + + + humidity: + type: num + pw_matchstring@instance: hourly/hour12/humidity + + + pressure: + type: num + pw_matchstring@instance: hourly/hour12/pressure + + + windSpeed: + type: num + pw_matchstring@instance: hourly/hour12/windSpeed + + + windGust: + type: num + pw_matchstring@instance: hourly/hour12/windGust + + + windBearing: + type: num + pw_matchstring@instance: hourly/hour12/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: hourly/hour12/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: hourly/hour12/uvIndex + + + visibility: + type: num + pw_matchstring@instance: hourly/hour12/visibility + + + ozone: + type: num + pw_matchstring@instance: hourly/hour12/ozone + + + date: + type: str + pw_matchstring@instance: hourly/hour12/date + + + weekday: + type: str + pw_matchstring@instance: hourly/hour12/weekday + + forecast_daily_nodb: + name: Daily forcast of Weather report from pirateweather.net - Data is NOT written do database + daily: + + summary: + type: str + pw_matchstring@instance: daily/summary + + icon: + type: str + pw_matchstring@instance: daily/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/icon_visu + + day0: + + time_epoch: + type: num + pw_matchstring@instance: daily/day0/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day0/summary + + icon: + type: str + pw_matchstring@instance: daily/day0/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day0/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day0/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day0/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day0/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day0/precipProbability + + + precipType: + type: str + pw_matchstring@instance: daily/day0/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day0/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day0/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: daily/day0/dewPoint + + + humidity: + type: num + pw_matchstring@instance: daily/day0/humidity + + + pressure: + type: num + pw_matchstring@instance: daily/day0/pressure + + + windSpeed: + type: num + pw_matchstring@instance: daily/day0/windSpeed + + + windGust: + type: num + pw_matchstring@instance: daily/day0/windGust + + + windBearing: + type: num + pw_matchstring@instance: daily/day0/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: daily/day0/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: daily/day0/uvIndex + + + visibility: + type: num + pw_matchstring@instance: daily/day0/visibility + + + ozone: + type: num + pw_matchstring@instance: daily/day0/ozone + + + temperatureMin: + type: num + pw_matchstring@instance: daily/day0/temperatureMin + + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day0/temperatureMinTime + + + temperatureMax: + type: num + pw_matchstring@instance: daily/day0/temperatureMax + + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day0/temperatureMaxTime + + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day0/apparentTemperatureMin + + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day0/apparentTemperatureMinTime + + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day0/apparentTemperatureMax + + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day0/apparentTemperatureMaxTime + + + date: + type: str + pw_matchstring@instance: daily/day0/date + + + weekday: + type: str + pw_matchstring@instance: daily/day0/weekday + + + precipProbability_mean: + type: num + pw_matchstring@instance: daily/day0/precipProbability_mean + + + precipIntensity_mean: + type: num + pw_matchstring@instance: daily/day0/precipIntensity_mean + + + temperature_mean: + type: num + pw_matchstring@instance: daily/day0/temperature_mean + + + hours: + type: dict + pw_matchstring@instance: daily/day0/hours + + day1: + + time_epoch: + type: num + pw_matchstring@instance: daily/day1/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day1/summary + + icon: + type: str + pw_matchstring@instance: daily/day1/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day1/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day1/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day1/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day1/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day1/precipProbability + + + precipType: + type: str + pw_matchstring@instance: daily/day1/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day1/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day1/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: daily/day1/dewPoint + + + humidity: + type: num + pw_matchstring@instance: daily/day1/humidity + + + pressure: + type: num + pw_matchstring@instance: daily/day1/pressure + + + windSpeed: + type: num + pw_matchstring@instance: daily/day1/windSpeed + + + windGust: + type: num + pw_matchstring@instance: daily/day1/windGust + + + windBearing: + type: num + pw_matchstring@instance: daily/day1/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: daily/day1/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: daily/day1/uvIndex + + + visibility: + type: num + pw_matchstring@instance: daily/day1/visibility + + + ozone: + type: num + pw_matchstring@instance: daily/day1/ozone + + + temperatureMin: + type: num + pw_matchstring@instance: daily/day1/temperatureMin + + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day1/temperatureMinTime + + + temperatureMax: + type: num + pw_matchstring@instance: daily/day1/temperatureMax + + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day1/temperatureMaxTime + + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day1/apparentTemperatureMin + + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day1/apparentTemperatureMinTime + + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day1/apparentTemperatureMax + + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day1/apparentTemperatureMaxTime + + + date: + type: str + pw_matchstring@instance: daily/day1/date + + + weekday: + type: str + pw_matchstring@instance: daily/day1/weekday + + + precipProbability_mean: + type: num + pw_matchstring@instance: daily/day1/precipProbability_mean + + + precipIntensity_mean: + type: num + pw_matchstring@instance: daily/day1/precipIntensity_mean + + + temperature_mean: + type: num + pw_matchstring@instance: daily/day1/temperature_mean + + + hours: + type: dict + pw_matchstring@instance: daily/day1/hours + + day2: + + time_epoch: + type: num + pw_matchstring@instance: daily/day2/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day2/summary + + icon: + type: str + pw_matchstring@instance: daily/day2/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day2/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day2/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day2/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day2/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day2/precipProbability + + + precipType: + type: str + pw_matchstring@instance: daily/day2/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day2/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day2/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: daily/day2/dewPoint + + + humidity: + type: num + pw_matchstring@instance: daily/day2/humidity + + + pressure: + type: num + pw_matchstring@instance: daily/day2/pressure + + + windSpeed: + type: num + pw_matchstring@instance: daily/day2/windSpeed + + + windGust: + type: num + pw_matchstring@instance: daily/day2/windGust + + + windBearing: + type: num + pw_matchstring@instance: daily/day2/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: daily/day2/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: daily/day2/uvIndex + + + visibility: + type: num + pw_matchstring@instance: daily/day2/visibility + + + ozone: + type: num + pw_matchstring@instance: daily/day2/ozone + + + temperatureMin: + type: num + pw_matchstring@instance: daily/day2/temperatureMin + + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day2/temperatureMinTime + + + temperatureMax: + type: num + pw_matchstring@instance: daily/day2/temperatureMax + + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day2/temperatureMaxTime + + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day2/apparentTemperatureMin + + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day2/apparentTemperatureMinTime + + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day2/apparentTemperatureMax + + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day2/apparentTemperatureMaxTime + + + date: + type: str + pw_matchstring@instance: daily/day2/date + + + weekday: + type: str + pw_matchstring@instance: daily/day2/weekday + + + precipProbability_mean: + type: num + pw_matchstring@instance: daily/day2/precipProbability_mean + + + precipIntensity_mean: + type: num + pw_matchstring@instance: daily/day2/precipIntensity_mean + + + temperature_mean: + type: num + pw_matchstring@instance: daily/day2/temperature_mean + + + hours: + type: dict + pw_matchstring@instance: daily/day2/hours + + day3: + + time_epoch: + type: num + pw_matchstring@instance: daily/day3/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day3/summary + + icon: + type: str + pw_matchstring@instance: daily/day3/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day3/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day3/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day3/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day3/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day3/precipProbability + + + precipType: + type: str + pw_matchstring@instance: daily/day3/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day3/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day3/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: daily/day3/dewPoint + + + humidity: + type: num + pw_matchstring@instance: daily/day3/humidity + + + pressure: + type: num + pw_matchstring@instance: daily/day3/pressure + + + windSpeed: + type: num + pw_matchstring@instance: daily/day3/windSpeed + + + windGust: + type: num + pw_matchstring@instance: daily/day3/windGust + + + windBearing: + type: num + pw_matchstring@instance: daily/day3/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: daily/day3/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: daily/day3/uvIndex + + + visibility: + type: num + pw_matchstring@instance: daily/day3/visibility + + + ozone: + type: num + pw_matchstring@instance: daily/day3/ozone + + + temperatureMin: + type: num + pw_matchstring@instance: daily/day3/temperatureMin + + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day3/temperatureMinTime + + + temperatureMax: + type: num + pw_matchstring@instance: daily/day3/temperatureMax + + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day3/temperatureMaxTime + + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day3/apparentTemperatureMin + + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day3/apparentTemperatureMinTime + + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day3/apparentTemperatureMax + + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day3/apparentTemperatureMaxTime + + + date: + type: str + pw_matchstring@instance: daily/day3/date + + + weekday: + type: str + pw_matchstring@instance: daily/day3/weekday + + + day4: + + time_epoch: + type: num + pw_matchstring@instance: daily/day4/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day4/summary + + icon: + type: str + pw_matchstring@instance: daily/day4/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day4/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day4/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day4/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day4/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day4/precipProbability + + + precipType: + type: str + pw_matchstring@instance: daily/day4/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day4/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day4/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: daily/day4/dewPoint + + + humidity: + type: num + pw_matchstring@instance: daily/day4/humidity + + + pressure: + type: num + pw_matchstring@instance: daily/day4/pressure + + + windSpeed: + type: num + pw_matchstring@instance: daily/day4/windSpeed + + + windGust: + type: num + pw_matchstring@instance: daily/day4/windGust + + + windBearing: + type: num + pw_matchstring@instance: daily/day4/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: daily/day4/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: daily/day4/uvIndex + + + visibility: + type: num + pw_matchstring@instance: daily/day4/visibility + + + ozone: + type: num + pw_matchstring@instance: daily/day4/ozone + + + temperatureMin: + type: num + pw_matchstring@instance: daily/day4/temperatureMin + + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day4/temperatureMinTime + + + temperatureMax: + type: num + pw_matchstring@instance: daily/day4/temperatureMax + + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day4/temperatureMaxTime + + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day4/apparentTemperatureMin + + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day4/apparentTemperatureMinTime + + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day4/apparentTemperatureMax + + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day4/apparentTemperatureMaxTime + + + date: + type: str + pw_matchstring@instance: daily/day4/date + + + weekday: + type: str + pw_matchstring@instance: daily/day4/weekday + + + day5: + + time_epoch: + type: num + pw_matchstring@instance: daily/day5/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day5/summary + + icon: + type: str + pw_matchstring@instance: daily/day5/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day5/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day5/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day5/precipIntensity + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day5/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day5/precipProbability + + + precipType: + type: str + pw_matchstring@instance: daily/day5/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day5/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day5/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: daily/day5/dewPoint + + + humidity: + type: num + pw_matchstring@instance: daily/day5/humidity + + + pressure: + type: num + pw_matchstring@instance: daily/day5/pressure + + + windSpeed: + type: num + pw_matchstring@instance: daily/day5/windSpeed + + + windGust: + type: num + pw_matchstring@instance: daily/day5/windGust + + + windBearing: + type: num + pw_matchstring@instance: daily/day5/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: daily/day5/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: daily/day5/uvIndex + + + visibility: + type: num + pw_matchstring@instance: daily/day5/visibility + + + ozone: + type: num + pw_matchstring@instance: daily/day5/ozone + + + temperatureMin: + type: num + pw_matchstring@instance: daily/day5/temperatureMin + + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day5/temperatureMinTime + + + temperatureMax: + type: num + pw_matchstring@instance: daily/day5/temperatureMax + + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day5/temperatureMaxTime + + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day5/apparentTemperatureMin + + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day5/apparentTemperatureMinTime + + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day5/apparentTemperatureMax + + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day5/apparentTemperatureMaxTime + + + date: + type: str + pw_matchstring@instance: daily/day5/date + + + weekday: + type: str + pw_matchstring@instance: daily/day5/weekday + + + day6: + + time_epoch: + type: num + pw_matchstring@instance: daily/day6/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day6/summary + + icon: + type: str + pw_matchstring@instance: daily/day6/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day6/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day6/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day6/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day6/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day6/precipProbability + + + precipType: + type: str + pw_matchstring@instance: daily/day6/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day6/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day6/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: daily/day6/dewPoint + + + humidity: + type: num + pw_matchstring@instance: daily/day6/humidity + + + pressure: + type: num + pw_matchstring@instance: daily/day6/pressure + + + windSpeed: + type: num + pw_matchstring@instance: daily/day6/windSpeed + + + windGust: + type: num + pw_matchstring@instance: daily/day6/windGust + + + windBearing: + type: num + pw_matchstring@instance: daily/day6/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: daily/day6/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: daily/day6/uvIndex + + + visibility: + type: num + pw_matchstring@instance: daily/day6/visibility + + + ozone: + type: num + pw_matchstring@instance: daily/day6/ozone + + + temperatureMin: + type: num + pw_matchstring@instance: daily/day6/temperatureMin + + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day6/temperatureMinTime + + + temperatureMax: + type: num + pw_matchstring@instance: daily/day6/temperatureMax + + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day6/temperatureMaxTime + + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day6/apparentTemperatureMin + + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day6/apparentTemperatureMinTime + + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day6/apparentTemperatureMax + + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day6/apparentTemperatureMaxTime + + + date: + type: str + pw_matchstring@instance: daily/day6/date + + + weekday: + type: str + pw_matchstring@instance: daily/day6/weekday + + + day7: + + time_epoch: + type: num + pw_matchstring@instance: daily/day7/time + + time: + type: str + eval_trigger: ..time_epoch + eval: datetime.datetime.fromtimestamp(sh...time_epoch()).strftime('%HH:%MM') + + summary: + type: str + pw_matchstring@instance: daily/day7/summary + + icon: + type: str + pw_matchstring@instance: daily/day7/icon + + icon_visu: + type: str + pw_matchstring@instance: daily/day7/icon_visu + + nearestStormDistance: + type: num + pw_matchstring@instance: daily/day7/nearestStormDistance + + precipIntensity: + type: num + pw_matchstring@instance: daily/day7/precipIntensity + + + precipIntensityError: + type: num + pw_matchstring@instance: daily/day7/precipIntensityError + + precipProbability: + type: num + pw_matchstring@instance: daily/day7/precipProbability + + + precipType: + type: str + pw_matchstring@instance: daily/day7/precipType + + temperature: + type: num + pw_matchstring@instance: daily/day7/temperature + + + apparenttemperature: + type: num + pw_matchstring@instance: daily/day7/apparentTemperature + + + dewpoint: + type: num + pw_matchstring@instance: daily/day7/dewPoint + + + humidity: + type: num + pw_matchstring@instance: daily/day7/humidity + + + pressure: + type: num + pw_matchstring@instance: daily/day7/pressure + + + windSpeed: + type: num + pw_matchstring@instance: daily/day7/windSpeed + + + windGust: + type: num + pw_matchstring@instance: daily/day7/windGust + + + windBearing: + type: num + pw_matchstring@instance: daily/day7/windBearing + + + cloudCover: + type: num + pw_matchstring@instance: daily/day7/cloudCover + + + uvIndex: + type: num + pw_matchstring@instance: daily/day7/uvIndex + + + visibility: + type: num + pw_matchstring@instance: daily/day7/visibility + + + ozone: + type: num + pw_matchstring@instance: daily/day7/ozone + + + temperatureMin: + type: num + pw_matchstring@instance: daily/day7/temperatureMin + + + temperatureMinTime: + type: num + pw_matchstring@instance: daily/day7/temperatureMinTime + + + temperatureMax: + type: num + pw_matchstring@instance: daily/day7/temperatureMax + + + temperatureMaxTime: + type: num + pw_matchstring@instance: daily/day7/temperatureMaxTime + + + apparentTemperatureMin: + type: num + pw_matchstring@instance: daily/day7/apparentTemperatureMin + + + apparentTemperatureMinTime: + type: num + pw_matchstring@instance: daily/day7/apparentTemperatureMinTime + + + apparentTemperatureMax: + type: num + pw_matchstring@instance: daily/day7/apparentTemperatureMax + + + apparentTemperatureMaxTime: + type: num + pw_matchstring@instance: daily/day7/apparentTemperatureMaxTime + + + date: + type: str + pw_matchstring@instance: daily/day7/date + + + weekday: + type: str + pw_matchstring@instance: daily/day7/weekday + + +logic_parameters: NONE + # Definition of logic parameters defined by this plugin + +plugin_functions: + # Definition of function interface of the plugin + + map_icon: + type: str + description: + de: "Gibt das SmartVisu Icon zum übergebenen Pirate-Weather Icon zurück." + en: "Returns the SmartVisu icon to the provided Pirate-Weather icon." + parameters: + icon: + type: str + description: + de: "Icon als String." + en: "Icon as string." diff --git a/piratewthr/requirements.txt b/piratewthr/requirements.txt new file mode 100755 index 000000000..f2293605c --- /dev/null +++ b/piratewthr/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/piratewthr/user_doc.rst b/piratewthr/user_doc.rst new file mode 100755 index 000000000..702dc8526 --- /dev/null +++ b/piratewthr/user_doc.rst @@ -0,0 +1,139 @@ + +.. index:: Plugins; piratewthr (pirateweather.net / forecast.io Wetterdaten) +.. index:: piratewthr +.. index:: pirateweather.net +.. index:: Wetter; piratewthr +.. index:: struct; piratewthr + +========== +piratewthr +========== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + +Wetterdaten von `pirateweather.net `_ lesen. + +Pirate Weather stellt ein DarkSky kompatibles API zur Verfügung, um Wetterdaten abzurufen. + +darksky.net wurde am 31. März 2020 von Apple gekauft. Als Folge davon werden keine weiteren API Schlüssel vergeben +und das API ist auch nur noch bis Ende März 2023 zugänglich. + +| + +Konfiguration +============= + +Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/piratewthr` beschrieben. + + +Item Konfiguration +------------------ + +Eine sehr einfache Möglichkeit die benötigten Items das Plugins zu definieren, ist die Nutzung des mit dem +Plugin mitgelieferten struct-Templates. + +Hierzu kann einfach ein Item (hier wetter_darksky) angelegt und als ``struct`` vom Typ ``piratewthr.weather`` definiert +werden. Standardmäßig sind unter hourly die nächsten 12 Stunden implementiert. + +.. code-block:: yaml + + ... + + wetter_piratewthr: + struct: piratewthr.weather + + +Besonderheiten +-------------- + +Zeiten werden bei piratewthr als Epoch Time angegeben, diese Zeit ist in den struct als ``time_epoch`` hinterlegt. +In den ``time`` Items wird sie durch folgende Konvertierung umformatiert. +Wer möchte, kann dieses Muster für die anderen Zeitangaben (Sonnenauf/untergang, etc.) nutzen. +Das unten definierte ``eval`` Attribut kann überschrieben bzw. bei den relevanten Items hinzugefügt werden. +Unabhängig davon haben alle Items auch einen ``weekday`` und ``date`` Eintrag, die direkt abgefragt werden können. + +.. code-block:: yaml + + time: + type: str + eval: datetime.datetime.fromtimestamp(value).strftime('%HH:%MM') + + +Die Originalantwort der Pirate Weather Webseite wird vom Plugin entsprechend aufgedröselt. +Informationen, die unter daily/data und hourly/data angegeben sind, sind nun direkt als +day0, day1, etc. sowie hour0, hour1, etc. abrufbar. +Der ursprüngliche data Eintrag wird aus dem JSON Objekt gelöscht, um die Übersichtlichkeit zu bewahren. +Die stündlichen Informationen werden neben den relativen Angaben +(hour0 = aktuelle Stunde, hour1, kommende Stunde, etc.) auch in den passenden Tagen direkt als Uhrzeit gelistet. +Diese Information ist als Dictionary in den Items day0/hours, day1/hours und day2/hours hinterlegt. +Es empfiehlt sich, das Web Interface zu nutzen, um die vorhandenen Informationen zu erforschen. +Um diese Daten zu nutzen, sind entsprechende Logiken notwendig. + + +Die relevantesten Berechnungen zu den stundenweisen Vorhersagen sind aber bereits im Plugin implementiert. +Und zwar sind unter den Items bzw. ds_matchstring mit den Namen +``precipProbability_mean``, ``precipIntensity_mean`` und ``temperature_mean`` die durchschnittlichen +Regen- und Temperaturvorhersagen abrufbar. +Hierbei werden die entsprechenden stündlichen Einzelwerte herangezogen, um den Mittelwert zu erstellen. +Auf diese Weise ist es z.B. möglich, die Regenwahrscheinlichkeit für den restlichen heutigen Tag abzufragen. + + +Instanzen +--------- + +Wenn mehrere Instanzen des Plugins konfiguriert sind, kann das struct-Template auch mehrfach eingebunden werden. +Hierbei muss bei der eingebundenen struct-Template angegeben werden, für welche Instanz des Plugins sie verwendet +werden soll: + +.. code-block:: yaml + + ... + + wetter_ham: + struct: piratewthr.weather + instance: ham + + wetter_bos: + struct: piratewthr.weather + instance: bos + +| + +Web Interface +============= + +Das piratewthr Plugin verfügt über ein Webinterface, mit dessen Hilfe die Items die das Plugin nutzen +übersichtlich dargestellt werden. + + +Aufruf des Webinterfaces +------------------------ + +Das Plugin kann aus dem backend aufgerufen werden. Dazu auf der Seite Plugins in der entsprechenden +Zeile das Icon in der Spalte **Web Interface** anklicken. + +Außerdem kann das Webinterface direkt über ``http://smarthome.local:8383/piratewthr`` bzw. +``http://smarthome.local:8383/piratewthr_`` aufgerufen werden. + + +Beispiele +--------- + +Folgende Informationen können im Webinterface angezeigt werden: + +Oben rechts werden allgemeine Parameter zum Plugin angezeigt. + +Im ersten Tab werden die Items angezeigt, die das piratewthr Plugin nutzen: + +.. image:: assets/webif_tab1.jpg + :class: screenshot + +Im zweiten Tab werden die piratewthr Rohdaten (JSON Format) angezeigt: + +.. image:: assets/webif_tab2.jpg + :class: screenshot diff --git a/piratewthr/webif/__init__.py b/piratewthr/webif/__init__.py new file mode 100755 index 000000000..c4d1ff0a3 --- /dev/null +++ b/piratewthr/webif/__init__.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2023- Martin Sinn m.sinn@gmx.de +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.5 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import json +from collections import OrderedDict + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.tplenv = self.init_template_environment() + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + + def printdict(OD, mode='dict', s="", indent=' '*4, level=0): + def is_number(s): + try: + float(s) + return True + except Exception: + return False + + def fstr(s): + return s if is_number(s) else '"{}"'.format(s) + if mode != 'dict': + kv_tpl = '("{}")'.format(s) + ST = 'OrderedDict([\n' + END = '])' + else: + kv_tpl = '"%s": %s' + ST = '{\n' + END = '}' + for i, k in enumerate(OD.keys()): + if type(OD[k]) in [dict, OrderedDict]: + level += 1 + s += (level-1)*indent+kv_tpl%(k,ST+printdict(OD[k], mode=mode, indent=indent, level=level)+(level-1)*indent+END) + level -= 1 + else: + s += level*indent+kv_tpl%(k,fstr(OD[k])) + if i != len(OD) - 1: + s += "," + s += "\n" + return s + + json_data = json.dumps(self.plugin.get_json_data(), indent=4) + + tmpl = self.tplenv.get_template('index.html') + return tmpl.render(p=self.plugin, + webif_pagelength=self.plugin.get_parameter_value('webif_pagelength'), + items=sorted(self.plugin.get_item_list(), key=lambda k: str.lower(k['_path'])), + + json_data=json_data ) + + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + if dataSet is None: + result_array = [] + + # callect data for 'items' tab + item_list = [] + for item in self.plugin.get_item_list(): + item_config = self.plugin.get_item_config(item) + value_dict = {} + value_dict['path'] = item.id() + value_dict['type'] = item.type() + value_dict['matchstring'] = self.plugin.get_item_mapping(item) + value_dict['value'] = item() + value_dict['last_update'] = item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') + value_dict['last_change'] = item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') + item_list.append(value_dict) + + result = {'items': item_list} + + # send result to wen interface + try: + data = json.dumps(result) + if data: + return data + else: + return None + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") + + return {} diff --git a/piratewthr/webif/static/img/plugin_logo.png b/piratewthr/webif/static/img/plugin_logo.png new file mode 100755 index 000000000..a9c999ad7 Binary files /dev/null and b/piratewthr/webif/static/img/plugin_logo.png differ diff --git a/piratewthr/webif/templates/index.html b/piratewthr/webif/templates/index.html new file mode 100755 index 000000000..cb6a03e97 --- /dev/null +++ b/piratewthr/webif/templates/index.html @@ -0,0 +1,254 @@ +{% extends "base_plugin.html" %} + +{% set logo_frame = false %} + + +{% set update_interval = 10000 %} +{% set reload_button = false %} + +{% set use_bodytabs = true %} +{% set tabcount = 2 %} +{% set start_tab = 1 %} + +{% block pluginstyles %} + +{% endblock pluginstyles %} + + +{% block pluginscripts %} + + + + +{% endblock pluginscripts %} + +{% block headtable %} + + + + + + + + + + + + + + + + + + + + + + + + +
Lat + {{ p._lat }} + Lang + {{ p._lang }} + Units + {{ p._units }} +
Lon + {{ p._lon }} + Key + {{ p._key }} +
Cycle + {{ p._cycle }} {{ _('Sek') }} + URL + {{ p._base_url }}[apikey]/... +
+{% endblock headtable %} + + +{% set tab1title = "" ~ _('Items') ~ " (" ~ p.get_items()|length ~ ")" %} +{% block bodytab1 %} + + + +
+ + + {% for item in items %} + + + + + + + + + + {% endfor %} + +
{{ item.property.path }}{{ item.property.type }}{{ p.get_item_mapping(item) }}{{ item.property.value }}{{ item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') }}{{ item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') }}
+ +
+
+ +{% endblock bodytab1 %} + +{% set tab2title = "" ~ _('JSON Daten') ~ "" %} +{% block bodytab2 %} + +
+ + + + + + + +
+ +{% endblock bodytab2 %} diff --git a/pluggit/README.md b/pluggit/README.md index 4a31aae33..faedd299e 100755 --- a/pluggit/README.md +++ b/pluggit/README.md @@ -24,6 +24,9 @@ struct: pluggit.pluggit ## Änderungen: +V2.0.4 - 13.11.2022 +- Verbesserungen zur Versionsprüfung "pymodbus" + V2.0.3 - 25.10.2022 - Support für pymodbus 3.0 diff --git a/pluggit/__init__.py b/pluggit/__init__.py index 6e18574dd..514e01872 100755 --- a/pluggit/__init__.py +++ b/pluggit/__init__.py @@ -30,15 +30,13 @@ from lib.model.smartplugin import SmartPlugin # pymodbus library from https://github.com/riptideio/pymodbus +from pymodbus.version import version +pymodbus_baseversion = int(version.short().split('.')[0]) -#from pymodbus.client.tcp import ModbusTcpClient -#from pymodbus.client import ModbusTcpClient -#from pymodbus.client.sync import ModbusTcpClient - -try: +if pymodbus_baseversion > 2: # for newer versions of pymodbus from pymodbus.client.tcp import ModbusTcpClient -except: +else: # for older versions of pymodbus from pymodbus.client.sync import ModbusTcpClient @@ -47,7 +45,7 @@ from pymodbus.payload import BinaryPayloadBuilder class Pluggit(SmartPlugin): - PLUGIN_VERSION="2.0.3" + PLUGIN_VERSION="2.0.4" _itemReadDictionary = {} _itemWriteDictionary = {} diff --git a/pluggit/plugin.yaml b/pluggit/plugin.yaml index 3fb62c24e..8cd06782a 100755 --- a/pluggit/plugin.yaml +++ b/pluggit/plugin.yaml @@ -11,7 +11,7 @@ plugin: keywords: modbus # documentation: # support: - version: 2.0.3 # Plugin version + version: 2.0.4 # Plugin version sh_minversion: 1.8 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance diff --git a/pluggit/requirements.txt b/pluggit/requirements.txt index 2b23649c1..04c18ec78 100755 --- a/pluggit/requirements.txt +++ b/pluggit/requirements.txt @@ -1 +1,2 @@ -pymodbus>=1.4.0 \ No newline at end of file +pymodbus>=2.3,<3.0;python_version<'3.8' +pymodbus>=3.0.2;python_version>="3.8" diff --git a/rcs1000n/__init__.py b/rcs1000n/__init__.py new file mode 100755 index 000000000..0ea66c936 --- /dev/null +++ b/rcs1000n/__init__.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2022 Frank Häfele mail@frankhaefele.de +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +from lib.model.smartplugin import SmartPlugin +from .webif import WebInterface +from lib.item import Items +from .cRcSocketSwitch import cRcSocketSwitch +import time +import threading + + +class RCS1000N(SmartPlugin): + + ALLOW_MULTIINSTANCE = False + PLUGIN_VERSION = "1.0.0" + + def __init__(self, sh): + """ + Initalizes the plugin. + + If you need the sh object at all, use the method self.get_sh() to get it. There should be almost no need for + a reference to the sh object any more. + + Plugins have to use the new way of getting parameter values: + use the SmartPlugin method get_parameter_value(parameter_name). Anywhere within the Plugin you can get + the configured (and checked) value for a parameter by calling self.get_parameter_value(parameter_name). It + returns the value in the datatype that is defined in the metadata. + """ + # Call init code of parent class (SmartPlugin) + super().__init__() + # Initialization code goes here + # internal member variables + self._gpio = int(self.get_parameter_value('rcs1000n_gpio')) + self._send_duration = float(self.get_parameter_value('rcs1000n_sendDuration')) + # create threading.Lock() obj + self._lock = threading.Lock() + + # On initialization error use: + # self._init_complete = False + # return + + # if plugin should start even without web interface + self.init_webinterface(WebInterface) + # if plugin should not start without web interface + # if not self.init_webinterface(): + # self._init_complete = False + return None + + def run(self): + self.logger.debug("Run method called") + self.alive = True + + def stop(self): + self.logger.debug("Stop method called") + self.alive = False + + def parse_item(self, item): + # generate warnings for incomplete configured itemns + if self.has_iattr(item.conf, 'rcs_SystemCode'): + # do a sanity check for rcs_SystemCode + systemcode_ok = cRcSocketSwitch.RCS1000N.sanity_check_Systemcode(self.get_iattr_value(item.conf, 'rcs_SystemCode')) + if self.has_iattr(item.conf, 'rcs_ButtonCode'): + # do a sanity check for rcs_ButtonCode + buttoncode_ok = cRcSocketSwitch.RCS1000N.sanity_check_Buttoncode(self.get_iattr_value(item.conf, 'rcs_ButtonCode')) + if systemcode_ok and buttoncode_ok: + return self.update_item + else: + self.logger.warning('Warning: Item {} is NOT correctly configured!. Item will be ignored by rcSwitch_python plugin'.format(item.id())) + else: + self.logger.warning('Warning: attribute for {} missing. Item will be ignored by rcSwitch_python plugin'.format(item.id())) + return None + elif self.has_iattr(item.conf, 'rcs_ButtonCode'): + self.logger.warning('Warning: attribute for {} missing. Item will be ignored by RCswitch plugin'.format(item.id())) + return None + else: + return None + + + def update_item(self, item, caller=None, source=None, dest=None): + # send commands to devices + # if 'rcs_ButtonCode' in item.conf and 'rcs_SystemCode' in item.conf and self.setupOK: + if self.alive and caller != self.get_shortname(): + + # if SystemCode and Buttoncode used + if self.has_iattr(item.conf, 'rcs_SystemCode') and self.has_iattr(item.conf, 'rcs_ButtonCode'): + SystemCode = self.get_iattr_value(item.conf, 'rcs_SystemCode') + ButtonCode = self.get_iattr_value(item.conf, 'rcs_ButtonCode') + + self.logger.info(f"update_item was called with item {item.id()} from caller {caller}, source {source} and dest {dest}") + # prepare parameters + value = int(item()) + values = (SystemCode, ButtonCode, value) + + # sending commands and avoid parallel access by threading.Lock() + with self._lock: + try: + # create Brennenstuhl RCS1000N object + obj = cRcSocketSwitch.RCS1000N(self._gpio) + # prepare and send values + obj.send(*values) + except Exception as err: + self.logger.error('Error: during instantiation of object or during send to device: {}'.format(err)) + else: + self.logger.info('Info: setting Device {} with SystemCode {} to {}'.format(ButtonCode, SystemCode, value)) + finally: + # give the transmitter time to complete sending of the command (but not more than 10s) + time.sleep(min(self._send_duration, 10)) diff --git a/rcs1000n/cRcSocketSwitch/__init__.py b/rcs1000n/cRcSocketSwitch/__init__.py new file mode 100755 index 000000000..f611eaf84 --- /dev/null +++ b/rcs1000n/cRcSocketSwitch/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +from .cRcSocketSwitch import RCS1000N diff --git a/rcs1000n/cRcSocketSwitch/cRcSocketSwitch.py b/rcs1000n/cRcSocketSwitch/cRcSocketSwitch.py new file mode 100755 index 000000000..33d3d6161 --- /dev/null +++ b/rcs1000n/cRcSocketSwitch/cRcSocketSwitch.py @@ -0,0 +1,222 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +from rpi_rf import RFDevice +import logging + +def sanity_check_Systemcode(SystemCode_raw): + ''' + check is SystemCode has the intented format of str (e.g: '10000') + ''' + # check if SystemCode ist of type str + if isinstance(SystemCode_raw, str): + # check now the length of the SystemCode + if len(SystemCode_raw) == 5: + if set(SystemCode_raw) <= {'0','1'}: + # check if SystemCode contains only '0' or '1' + # SystemCode ok + logging.info("SystemCode is ok: {} - {}\n".format(SystemCode_raw, type(SystemCode_raw))) + return True + else: + logging.error("SystemCode is type of str BUT contains NOT only '0' or '1': {} - {}\n".format(SystemCode_raw, type(SystemCode_raw))) + return False + else: + # SystemCode not ok + logging.error("SystemCode length is NOT 5: {} - {}\n".format(SystemCode_raw, type(SystemCode_raw))) + return False + else: + logging.error("SystemCode is not a type of str: {} - {}\n".format(SystemCode_raw, type(SystemCode_raw))) + return False + + +def sanity_check_Buttoncode(ButtonCode_raw): + ''' + check the intented format of ButtonCode + The ButtonCode can have the following formats: + - type str like 'a' or 'A' + - type str like '10000' + - type int like 1, 2, 4, ...etc + ''' + # check if ButtonCode is of type str + if ButtonCode_raw in RCS1000N._button_list: + # ok fine ButtonCode is type of str like 'A', 'B, etc... + logging.info("ButtonCode is ok: {} - {}\n".format(ButtonCode_raw, type(ButtonCode_raw))) + return True + elif isinstance(ButtonCode_raw, str): + if len(ButtonCode_raw) == 5: + # check if ButtonCode is like '10000' + if set(ButtonCode_raw) <= {'0','1'}: + # check if Buttoncode contains only '0' or '1' + logging.info("ButtonCode is ok: {} - {}\n".format(ButtonCode_raw, type(ButtonCode_raw))) + return True + else: + logging.error("ButtonCode is type of str BUT contains NOT only '0' or '1': {} - {}\n".format(ButtonCode_raw, type(ButtonCode_raw))) + return False + else: + logging.error("ButtonCode is type of str BUT len i NOT 5: {} - {}\n".format(ButtonCode_raw, type(ButtonCode_raw))) + return False + elif isinstance(ButtonCode_raw, int) and ButtonCode_raw < 32: + # ButtonCode is of type int and has valid value + logging.info("ButtonCode is ok: {} - {}\n".format(ButtonCode_raw, type(ButtonCode_raw))) + return True + else: + logging.error("ButtonCode is type of int BUT has wrong value: {} - {}\n".format(ButtonCode_raw, type(ButtonCode_raw))) + return False + + +class RCS1000N: + ''' + class for switching remote socket devices as: + Brennenstuhl RCS 1000 N + I calculated the corresponding send code in decimal value + and uses the library rpi-rf to send the command via 433 MHz send device + ''' + sanity_check_Systemcode = staticmethod(sanity_check_Systemcode) + sanity_check_Buttoncode = staticmethod(sanity_check_Buttoncode) + + _button_list = ['a', 'A', 'b', 'B', 'c', 'C', 'd', 'D', 'e', 'E'] + _button_mapping = {'A':16, 'B':8, 'C':4, 'D':2, 'E':1} + + + def __init__(self, gpio_pin = 17): + ''' + Constructor for GPIO Pin and Configuration + ''' + self.gpio = gpio_pin + self.config = {'code': None, 'tx_proto': 1, 'tx_pulselength': 320, 'tx_length': 24} + logging.info("Brennenstuhl RCS1000 N object created with GPIO pin {}".format(self.gpio)) + + + def prepareCodes(self, SystemCode_raw, ButtonCode_raw, status): + ''' + this method prepares the codes and checks the imput format in case + of different usecases + ''' + # check if the input for the ButtonCode is in the Case 'A', 'B', etc... + if ButtonCode_raw in self._button_list: + ButtonCode_raw = ButtonCode_raw.upper() + ButtonCode = self._button_mapping[ButtonCode_raw] + ButtonCode = '{:05b}'.format(ButtonCode) + logging.info("Buttoncode: {}\n".format(ButtonCode)) + + # check if the ButtonCode is an integer like 1, 2, 3, etc... + elif isinstance(ButtonCode_raw, int): + logging.info("ButtonCode_raw is of type int") + ButtonCode = '{:05b}'.format(ButtonCode_raw) + logging.info("Buttoncode: {}\n".format(ButtonCode)) + + # assume the code is in the way '01000' check the length (5) + elif isinstance(ButtonCode_raw, str): + logging.info("ButtonCode_raw is of type str") + # check length of 5 + if len(ButtonCode_raw) == 5: + ButtonCode = ButtonCode_raw + logging.info("Buttoncode: {} - {}\n".format(ButtonCode, type(ButtonCode))) + else: + ButtonCode = None + logging.error("ERROR: wrong len of ButtonCode_raw!") + + # check now the lenght of the SystemCode + if len(SystemCode_raw) == 5: + SystemCode = SystemCode_raw + logging.info("SystemCode: {} - {}\n".format(SystemCode, type(SystemCode))) + else: + SystemCode = None + logging.error("ERROR: wrong len of SystemCode_raw!") + + # check the status + if isinstance(status, bool): + if status: + status = 1 + else: + status = 0 + return (SystemCode, ButtonCode, status) + + + def calcTristateCode(self, SystemCode, ButtonCode, status): + ''' + calculate the corresponding Tristate Code in the same way as the library + wiringPi does it in c-code + return value is the TriState String Code + ''' + code = "" + for c in SystemCode: + if c == '0': + code += 'F' + else: + code += '0' + + for c in ButtonCode: + if c == '0': + code += 'F' + else: + code += '0' + + if status: + code += '0F' + else: + code += 'F0' + return code + + + def calcBinaryCode(self, strCode): + ''' + calculate the Binary Code of the switch command in the same way as th library + wiringPi does it in c-code + return value is then a decimal value of the switch command + ''' + code = 0 + len = 0 + for c in strCode: + code <<= 2 + #print(c, type(c)) + #print(bin(code)) + if c == '0': + # bit pattern 00 + pass + elif c == 'F': + # bit pattern 01 + code += 1 + elif c =='1': + # bit pattern 11 + code += 3 + len += 2 + logging.info("Length of code: {}\n".format(len)) + logging.info("code: {}\n".format(int(code))) + return code + + + def calc_DecimalCode_python_style(self, SystemCode, ButtonCode, status): + ''' + calculate the decimal Code in a python style + this combines the methods: + calcTristateCode + calcBinaryCode in on step + ''' + code = str(SystemCode + ButtonCode) + help = code.replace('0', 'F').replace('1', '0') + if status: + help += '0F' + else: + help += 'F0' + logging.info("Py - TriState code: {}\n".format(help)) + code = help.replace('0','00').replace('F', '01') + binstr = '0b' + code + logging.info("binary string: {}\n".format(binstr)) + return int(binstr, 2) + + + def send(self, systemCode, btn_code, status): + ''' + Method to prepare the codes and send it to the actuator + ''' + try: + rfdevice = RFDevice(self.gpio) + rfdevice.enable_tx() + rfdevice.tx_repeat = 10 + values = self.prepareCodes(systemCode, btn_code, status) + send_code = self.calc_DecimalCode_python_style(*values) + self.config['code'] = send_code + rfdevice.tx_code(**self.config) + + finally: + rfdevice.cleanup() \ No newline at end of file diff --git a/rcs1000n/pictures/RCS1000N_switches.png b/rcs1000n/pictures/RCS1000N_switches.png new file mode 100755 index 000000000..c1b347239 Binary files /dev/null and b/rcs1000n/pictures/RCS1000N_switches.png differ diff --git a/rcs1000n/pictures/rcs1000n_webif.png b/rcs1000n/pictures/rcs1000n_webif.png new file mode 100755 index 000000000..203ffb179 Binary files /dev/null and b/rcs1000n/pictures/rcs1000n_webif.png differ diff --git a/rcs1000n/plugin.yaml b/rcs1000n/plugin.yaml new file mode 100755 index 000000000..960e88eb8 --- /dev/null +++ b/rcs1000n/plugin.yaml @@ -0,0 +1,76 @@ +# Metadata for the Smart-Plugin +plugin: + # Global plugin attributes + type: gateway # plugin type (gateway, interface, protocol, system, web) + description: # Alternative: description in multiple languages + de: 'Schalten von 433 MHz Funksteckdosen z.B. für Brennenstuhl RCS 1000 N' + en: 'Support for 433 MHz wireless sockets e.g. for Brennenstuhl RCS 1000 N' + maintainer: hasenradball + tester: hasenradball # Who tests this plugin? + state: ready + keywords: 433MHz Brennenstuhl rcs1000n + documentation: https://github.com/hasenradball/rcSwitch-python # url of documentation (wiki) page + support: https://knx-user-forum.de/forum/supportforen/smarthome-py/39094-logic-und-howto-für-433mhz-steckdosen + + version: 1.0.0 # Plugin version + sh_minversion: 1.9 # minimum shNG version to use this plugin +# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) + multi_instance: False # plugin supports multi instance + restartable: unknown + classname: RCS1000N # class containing the plugin + +parameters: + + rcs1000n_gpio: + type: num + default: 17 + valid_min: 0 + valid_max: 40 + description: + de: "GPIO-Pin an dem der 433 MHz Sender angeschlossen ist" + en: "GPIO-Pin to which the 433 MHz sender is connected to" + + rcs1000n_sendDuration: + type: num + default: 0.1 + valid_min: 0.0 + valid_max: 2.0 + description: + de: "Minimale Zeit in Sekunden zwischen dem Senden von verschiedenen Befehlen" + en: "Minimum time in s between sending of different commands" + + +item_attributes: + # Definition of item attributes defined by this plugin + rcs_SystemCode: + type: str + description: + de: "SystemCode des Gerätes - Muss in der Form von 5 binären Digits angegeben werden [00000 - 11111]" + en: "SystemCode of device - Must be 5 binary digits [00000 - 11111]" + + rcs_ButtonCode: + type: str + valid_list: + - 'A' + - 'B' + - 'C' + - 'D' + - 'E' + - '10000' + - '01000' + - '00100' + - '00010' + - '00001' + description: + de: "ButtonCode (oder Buchstabe) des Gerätes" + en: "ButtonCode (or letter) of the device" + + +item_structs: NONE + # Definition of item-structure templates for this plugin + +plugin_functions: NONE + # Definition of plugin functions defined by this plugin + +logic_parameters: NONE + # Definition of logic parameters defined by this plugin diff --git a/rcs1000n/requirements.txt b/rcs1000n/requirements.txt new file mode 100755 index 000000000..f01b3ca2c --- /dev/null +++ b/rcs1000n/requirements.txt @@ -0,0 +1 @@ +rpi-rf \ No newline at end of file diff --git a/rcs1000n/user_doc.rst b/rcs1000n/user_doc.rst new file mode 100755 index 000000000..8128802f6 --- /dev/null +++ b/rcs1000n/user_doc.rst @@ -0,0 +1,116 @@ +.. index:: Plugins; rcs1000n (Brennenstuhl RCS 1000 N) +.. index:: rcs1000n + +======== +rcs1000n +======== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + + +Anforderungen +============= +Das rcs1000n Plugin benötigt folgenden Anforderungen an Hanrdware- und Software-Komponenten. + +Notwendige Hardware +------------------- + +* Raspberry Pi +* 433 MHz Sende-Module +* schaltbare Steckdose über 433 MHz (z.B.: Brennenstuhl RCS 1000 N) + +Notwendige Software +------------------- + +* die python Library `rpi-rf` + + +Unterstützte Geräte +------------------- + +* Brennenstuhl RCS 1000 N + + +Konfiguration +============= + +Diese Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/rcs1000n` beschrieben. + + +plugin.yaml +----------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + +Hier ein Beispiel wie das Plugin konfiguriert werden kann. + +.. code-block:: yaml + + rcs1000: + plugin_name: rcs1000n + rcs1000n_gpio: '17' + rcs1000n_sendDuration: '0.1' + + + +items.yaml +---------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + +Hier ein Beispiel wie das Item konfiguriert werden kann. + +.. code-block:: yaml + + Schaltsteckdose1: + name: Funkstedose 1 Wohnen + remark: Brennenstuhl RCS 1000 N + schalten: + type: bool + rcs_SystemCode: 11010 + rcs_ButtonCode: 'A' + visu_acl: rw + struct: uzsu.child + + +.. image:: pictures/RCS1000N_switches.png + :width: 300 + :alt: Dip Schalter der RCS 1000 N + + +logic.yaml +---------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + + +Funktionen +---------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + + +Web Interface +============= + +Diese plugin verfügt über ein Webinterface. + +.. image:: pictures/rcs1000n_webif.png + :width: 300 + :alt: Webinterface des Plugins RCS 1000 N + + +Version History +=============== + +v1.0.0 +------ + +* initial version. + diff --git a/rcs1000n/webif/__init__.py b/rcs1000n/webif/__init__.py new file mode 100755 index 000000000..730e62a96 --- /dev/null +++ b/rcs1000n/webif/__init__.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2022 Frank Häfele mail@frankhaefele.de +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import datetime +import time +import os + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + self.tplenv = self.init_template_environment() + + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + tmpl = self.tplenv.get_template('index.html') + # Setting pagelength (max. number of table entries per page) for web interface + try: + pagelength = self.plugin.webif_pagelength + except Exception: + pagelength = 100 + + # get list of items with the attribute rc_SystemCode + plgin_items = [] + for item in self.items.find_items('rcs_SystemCode'): + myitem = self.items.return_item(item.id()) + i = {} + i['path'] = item.id() + i['SystemCode'] = myitem.property.rcs_SystemCode + i['ButtonCode'] = myitem.property.rcs_ButtonCode + i['value'] = item() + plgin_items.append(i) + #self.logger.info("{}".format(plgin_items)) + + # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, + items=sorted(plgin_items, key=lambda cat: str.lower(cat['path']), reverse=False), + item_count = len(plgin_items)) + + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + if dataSet is None: + # get the new data + data = {} + + #data['item'] = {} + #for i in self.plugin.items: + # data['item'][i]['value'] = self.plugin.getitemvalue(i) + + # return it as json the the web page + #try: + # return json.dumps(data) + #except Exception as e: + # self.logger.error("get_data_html exception: {}".format(e)) + return {} diff --git a/rcs1000n/webif/static/img/plugin_logo.png b/rcs1000n/webif/static/img/plugin_logo.png new file mode 100755 index 000000000..c3ad05a45 Binary files /dev/null and b/rcs1000n/webif/static/img/plugin_logo.png differ diff --git a/rcs1000n/webif/static/img/readme.txt b/rcs1000n/webif/static/img/readme.txt new file mode 100755 index 000000000..1a7c55eef --- /dev/null +++ b/rcs1000n/webif/static/img/readme.txt @@ -0,0 +1,6 @@ +This directory is for storing images that are used by the web interface. + +If you want to have your own logo on the top of the web interface, store it here and name it plugin_logo.. + +Extension can be png, svg or jpg + diff --git a/rcs1000n/webif/templates/index.html b/rcs1000n/webif/templates/index.html new file mode 100755 index 000000000..0a3fe4a74 --- /dev/null +++ b/rcs1000n/webif/templates/index.html @@ -0,0 +1,221 @@ +{% extends "base_plugin.html" %} + +{% set logo_frame = false %} + + +{% set update_interval = 0 %} + + +{% block pluginstyles %} + +{% endblock pluginstyles %} + + +{% block pluginscripts %} + + + + +{% endblock pluginscripts %} + + +{% block headtable %} + + + + + + + + + + + + + + +
GPIO-Pin:{{ p._gpio}}
Send Duration:{{ p._send_duration }}
+{% endblock headtable %} + + + +{% block buttons %} +{% if 1==2 %} +
+ +
+{% endif %} +{% endblock %} + + +{% set tabcount = 1 %} + + + +{% if item_count==0 %} + {% set start_tab = 1 %} +{% endif %} + + + +{% set tab1title = "" ~ p.get_shortname() ~ " Items (" ~ item_count ~ ")" %} +{% block bodytab1 %} +
+ {{ _('Die folgenden Items sind dieser Instanz des rcs1000n Plugins zugewiesen') }}: +
+ +
+ + + + + + + + + + + + {% for item in items %} + + + + + + + {% endfor %} + +
{{ _('Item') }}{{ _('SystemCode') }}{{ _('ButtonCode') }}{{ _('Wert') }}
{{ item.path }}{{ item.SystemCode }}{{ item.ButtonCode }}{{ item.value }}
+
+{% endblock bodytab1 %} + + + + + + + \ No newline at end of file diff --git a/resol/__init__.py b/resol/__init__.py index 049c9a9cc..5ac3d51d6 100755 --- a/resol/__init__.py +++ b/resol/__init__.py @@ -199,6 +199,9 @@ def recv(self): self.sock.settimeout(5) try: dat = self.sock.recv(1024).decode('Cp1252') + except socket.timeout: + self.logger.info("Exception in recv(): Socket reception timeout") + return None except Exception as e: self.logger.error("Exception during socket recv.decode: %s" % str(e)) return None diff --git a/resol/user_doc.rst b/resol/user_doc.rst index bfb0998ad..725daed24 100755 --- a/resol/user_doc.rst +++ b/resol/user_doc.rst @@ -1,9 +1,16 @@ .. index:: Plugins; resol (Resol Unterstützung) .. index:: resol -======== +===== resol -======== +===== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left Resol plugin, mit Unterstützung für Resol Solar Datenlogger, Frischwasserwaermetauscher und Regler. diff --git a/resol/webif/static/img/plugin_logo.png b/resol/webif/static/img/plugin_logo.png new file mode 100755 index 000000000..c3ad05a45 Binary files /dev/null and b/resol/webif/static/img/plugin_logo.png differ diff --git a/rpi_info/plugin.yaml b/rpi_info/plugin.yaml index 75e4eadc1..98c98348a 100755 --- a/rpi_info/plugin.yaml +++ b/rpi_info/plugin.yaml @@ -22,28 +22,6 @@ plugin: classname: RPi_Info # class containing the plugin parameters: - webif_pagelength: - type: int - default: 100 - valid_list: - - -1 - - 0 - - 25 - - 50 - - 100 - description: - de: 'Anzahl an Items, die standardmäßig in einer Web Interface Tabelle pro Seite angezeigt werden. - 0 = automatisch, -1 = alle' - en: 'Amount of items being listed in a web interface table per page by default. - 0 = automatic, -1 = all' - description_long: - de: 'Anzahl an Items, die standardmäßig in einer Web Interface Tabelle pro Seite angezeigt werden.\n - Bei 0 wird die Tabelle automatisch an die Höhe des Browserfensters angepasst.\n - Bei -1 werden alle Tabelleneinträge auf einer Seite angezeigt.' - en: 'Amount of items being listed in a web interface table per page by default.\n - 0 adjusts the table height automatically based on the height of the browser windows.\n - -1 shows all table entries on one page.' - poll_cycle: type: int default: 120 diff --git a/rpi_info/user_doc.rst b/rpi_info/user_doc.rst index 39066ccf8..ec7e71759 100755 --- a/rpi_info/user_doc.rst +++ b/rpi_info/user_doc.rst @@ -1,5 +1,9 @@ + +.. index:: Plugins; rpi_info +.. index:: rpi_info + ======== -RPi_Info +rpi_info ======== .. image:: webif/static/img/plugin_logo.png @@ -10,45 +14,31 @@ RPi_Info :align: left Unterstützte Geräte -~~~~~~~~~~~~~~~~~~~ +=================== Raspberry Pi Konfiguration -------------- - -plugin.yaml -~~~~~~~~~~~ - -Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. - +============= -items.yaml -~~~~~~~~~~ - -Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. - - -logic.yaml -~~~~~~~~~~ - -Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. +Diese Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/rpi_log` beschrieben. Funktionen -~~~~~~~~~~ +========== Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. Beispiele ---------- +========= Hier können ausführlichere Beispiele und Anwendungsfälle beschrieben werden. Web Interface -------------- +============= Das Plugin stellt ein WebIF zur Verfügung, in dem alle mit dem Plugin verknüpften Items gelistet sind. diff --git a/rtr2/__init__.py b/rtr2/__init__.py index e435df694..3bd98bc4a 100755 --- a/rtr2/__init__.py +++ b/rtr2/__init__.py @@ -45,7 +45,7 @@ class Rtr2(SmartPlugin): the update functions for the items """ - PLUGIN_VERSION = '2.0.0' # (must match the version specified in plugin.yaml), use '1.0.0' for your initial plugin Release + PLUGIN_VERSION = '2.2.0' # (must match the version specified in plugin.yaml), use '1.0.0' for your initial plugin Release _rtr = {} # dict containing data of the rtrs. Key is the attribute rtr2_id @@ -163,10 +163,13 @@ def parse_item(self, item): if self._rtr.get(rtr_id, None) is None: # Create a new rtr parent_item = item.return_parent() + temp_settings = [] + controller_settings = [] if self.has_iattr(parent_item.conf, 'rtr2_settings'): - self._rtr[rtr_id] = Rtr_object(self, self.get_iattr_value(parent_item.conf, 'rtr2_settings')) - else: - self._rtr[rtr_id] = Rtr_object(self, []) # use plugin parameters as defaults + temp_settings = self.get_iattr_value(parent_item.conf, 'rtr2_settings') + if self.has_iattr(parent_item.conf, 'rtr2_controller_settings'): + controller_settings = self.get_iattr_value(parent_item.conf, 'rtr2_controller_settings') + self._rtr[rtr_id] = Rtr_object(self, temp_settings, controller_settings) self._rtr[rtr_id].id = rtr_id self._rtr[rtr_id].valve_protect = self.default_valve_protect diff --git a/rtr2/plugin.yaml b/rtr2/plugin.yaml index 4c810754c..6c259ca0d 100755 --- a/rtr2/plugin.yaml +++ b/rtr2/plugin.yaml @@ -12,7 +12,7 @@ plugin: # documentation: https://github.com/smarthomeNG/smarthome/wiki/CLI-Plugin # url of documentation (wiki) page support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1586747-support-thread-für-das-rtr2-plugin - version: 2.0.0 # Plugin version (must match the version specified in __init__.py) + version: 2.2.0 # Plugin version (must match the version specified in __init__.py) sh_minversion: 1.8.0 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) py_minversion: 3.6 # minimum Python version to use for this plugin @@ -29,55 +29,55 @@ parameters: default: 4 description: de: 'Gebäuchliche Werte: Wasserheizung=4 K, Fußbodenheitzung=4 K, Split Unit=4 K' - en: '...' + en: 'Common values: Wasser heating=4 K, underfloor heating=4 K, split unit=4 K' default_Ki: type: num default: 120 description: de: 'Gebäuchliche Werte: Wasserheizung=120 Min, Fußbodenheitzung=150 Min, Split Unit=60 Min' - en: '...' + en: 'Common values: Wasser heating=120 min, underfloor heating=150 min, split unit=60 min' - #default_Kd: - # type: num - # description: - # de: '(Zukünftige Erweiterung für PID-Regler)' - # en: '...' + default_Kd: + type: num + description: + de: '[Zukünftige Erweiterung] Wird noch nicht verwendet, ist ein Parameter für den noch nicht implementierten PID Regler' + en: '[Future expansion]' comfort_temp: type: num - default: 22.0 + default: 21.0 description: de: 'Standard Vorgabe für die Temperatur im Komfort Modus' - en: '...' + en: 'Starting/default value for the temperature in comfort mode' standby_reduction: type: num - default: 1.0 + default: 1.5 description: - de: 'Standard Vorgabe für die Temperatur im Standby Modus' - en: '...' + de: 'Standard Vorgabe für die Temperaturreduktion im Standby Modus' + en: 'Starting/default value for the temperature reduction in standby mode' night_reduction: type: num default: 3.0 description: - de: 'Standard Vorgabe für die Temperatur im Nacht Modus (bei Nachtabsenkung)' - en: '...' + de: 'Standard Vorgabe für die Temperaturreduktion im Nacht Modus (bei Nachtabsenkung)' + en: 'Starting/default value for the temperature reduction in night mode' fixed_reduction: type: bool default: True description: de: 'Soll die Temperatur Differenz zwischen Komfort Modus und den anderen Modi konstant sein?' - en: '...' + en: 'if True, the temperature difference between comfort mode and the other modes is constant' frost_temp: type: num default: 7.0 description: de: 'Standard Vorgabe für die Temperatur im Frostschutz Modus' - en: '...' + en: 'Starting/default value for the temperature in frost prevention mode' hvac_mode: type: int @@ -85,15 +85,15 @@ parameters: valid_min: 1 valid_max: 4 description: - de: 'Modus Standard Vorgabe: 1=Komfort, 2=Standby, 3=Nacht, 4=Frostschutz' - en: '...' + de: 'hvac Modus Standard Vorgabe: 1=Komfort, 2=Standby, 3=Nacht, 4=Frostschutz' + en: 'Starting/default value for the hvac mode: 1=Comfort, 2=Standby, 3=Night, 4=Frost prevention' valve_protect: type: bool default: True description: de: "Standard Vorgabe für die Aktivierung der Ventilschutz Einstellung der Regler." - en: "..." + en: "Default value for the valve protection mode" max_output: type: num @@ -102,7 +102,7 @@ parameters: default: 100 description: de: "Standard Vorgabe für den maximalen Stellwert der Regler." - en: "..." + en: "Default for the maximum control value" min_output: type: num @@ -111,7 +111,7 @@ parameters: default: 0 description: de: "Standard Vorgabe für den minimalen Stellwert der Regler." - en: "..." + en: "Default for the minimum control value" item_attributes: # Definition of item attributes defined by this plugin (enter 'item_attributes: NONE', if section should be empty) @@ -195,14 +195,28 @@ item_attributes: rtr2_settings: type: list description: - de: "Initale Einstellungen für den Rtr" - en: "Inital settings for the Rtr" + de: "Initale Einstellungen für den Rtr. Bitte beachten: Wenn die Werte über dieses Attribut gesetzt werdeen, + werden sie bei jedem Neustart von SmartHomeNG wieder gesetzt. \n + \n + [, , , , , ] + " + en: "Inital settings for the Rtr. Please note: If the values are set using this attribute, they are set + on every restart of SmartHomeNG. \n + \n + [, , , , , ] + " rtr2_controller_settings: type: list description: - de: "Initale Einstellungen für den Controller, falls abweichend von den Plugin Standardwerten" - en: "Inital settings for the controller, if different from the plugin defaults" + de: "Initale Einstellungen für den Controller, falls abweichend von den Plugin Standardwerten \n + \n + [, ] + " + en: "Inital settings for the controller, if different from the plugin defaults \n + \n + [, ] + " item_structs: # Definition of item-structure templates for this plugin (enter 'item_structs: NONE', if section should be empty) @@ -259,6 +273,7 @@ item_structs: stellwert: type: num + cache: True rtr2_id@instance: ..:. rtr2_function@instance: control_output diff --git a/rtr2/user_doc.rst b/rtr2/user_doc.rst index 2530926b4..85c951486 100755 --- a/rtr2/user_doc.rst +++ b/rtr2/user_doc.rst @@ -1,8 +1,16 @@ .. index:: Plugins; rtr2 (Raumtemperatur Regler v2) -.. index:: rtr rtr2 +.. index:: rtr2 rtr +==== rtr2 -#### +==== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left Das Plugin implementiert einen oder mehrere Raumtemperatur Regler. Es ist eine komplette Neuentwicklung mit einem erweiterten Funktionsumfang gegenüber dem alten rtr Plugin: @@ -245,6 +253,42 @@ Um den Heiz-Status eines RTR mit der Id **dusche** abzufragen, muss ein Item fol rtr2_id: dusche rtr2_function: heating_status +| + +Abweichende Reglerparameter +=========================== + +Möchte man dem Regler von den globalen Einstellungen abweichende Werte übergeben, wird dies über ``rtr_settings`` und +``rtr2_controller_settings`` erledigt. Die Paramter hierfür sind wie folgt anzugeben: + +.. code-block:: yaml + + test_rtr: + rtr2_id: dusche + rtr2_settings: + - 22.0 # Temperatur Komfort Modus + - 3.0 # Temperaturreduktion Nacht Modus + - 1.0 # Temperaturreduktion Standby Modus + - True # fixed_reduction + - 2 # HVAC Modus + - 7.0 # Temperatur Frost Modus + rtr2_controller_settings: + - 4.0 # Kp + - 120 # Ki + - 3.0 # Kd (wird nicht verwendet, wird nur für den noch nicht implementierten PID Regler benötigt) + +Zu Beachten ist, dass man die letzten Parameter immer weglassen kann, aber nicht die davor. Die weggelassenen Parameter +werden dann mit den definierten Standardwerten belegt. Möchte man z.B nur eine neue Temperaturreduktion für den +Nacht Modus angeben, muss man also auch die Temperatur für den Komfort Modus angeben. Das würde wie folgt aussehen. + +.. code-block:: yaml + + test_rtr: + rtr2_id: dusche + rtr2_settings: + - 22.0 + - 2.0 + | diff --git a/rtr2/webif/__init__.py b/rtr2/webif/__init__.py index 9878daaf0..8a0ed5249 100755 --- a/rtr2/webif/__init__.py +++ b/rtr2/webif/__init__.py @@ -28,6 +28,7 @@ import datetime import time import os +import json from lib.item import Items from lib.model.smartplugin import SmartPluginWebIf @@ -78,6 +79,12 @@ def index(self, reload=None): rtr=self.plugin._rtr) + def get_value(self, param): + try: + result = param + except: + result = None + @cherrypy.expose def get_data_html(self, dataSet=None): """ @@ -90,16 +97,46 @@ def get_data_html(self, dataSet=None): """ if dataSet is None: # get the new data - data = {} - - # data['item'] = {} - # for i in self.plugin.items: - # data['item'][i]['value'] = self.plugin.getitemvalue(i) - # - # return it as json the the web page - # try: - # return json.dumps(data) - # except Exception as e: - # self.logger.error("get_data_html exception: {}".format(e)) + result = {} + + for r in self.plugin._rtr: + result[r] = {} + rtr = self.plugin._rtr[r] + # data for tab 1 'Raumtemperatur Regler' + result[r]['temp_actual'] = rtr.temp_actual_item() + result[r]['temp_set'] = rtr.temp_set_item() + result[r]['control_output'] = rtr.control_output_item() + result[r]['mode'] = str(rtr._mode) + result[r]['lock_status'] = rtr.lock_status_item() + result[r]['setting_temp_comfort'] = rtr.setting_temp_comfort_item() + result[r]['setting_temp_standby'] = rtr.setting_temp_standby_item() + result[r]['setting_temp_night'] = rtr.setting_temp_night_item() + result[r]['setting_temp_frost'] = rtr.setting_temp_frost_item() + + # data for tab 2 'Erweiterte Einstellungen' + result[r]['controller_type'] = rtr.controller.controller_type + result[r]['controller_Kp'] = rtr.controller._Kp + result[r]['controller_Ki'] = rtr.controller._Ki + if rtr.controller.controller_type == 'PID': + result[r]['controller_Kd'] = self.get_value(rtr.controller._Kd) + else: + result[r]['controller_Kd'] = '-' + result[r]['valve_protect'] = rtr.valve_protect + + result[r]['setting_standby_reduction'] = rtr.setting_standby_reduction_item() + result[r]['setting_night_reduction'] = rtr.setting_night_reduction_item() + result[r]['setting_fixed_reduction'] = rtr.setting_fixed_reduction_item() + result[r]['setting_min_output'] = rtr.setting_min_output_item() + result[r]['setting_max_output'] = rtr.setting_max_output_item() + + # send result to wen interface + try: + data = json.dumps(result) + if data: + return data + else: + return None + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") return {} diff --git a/rtr2/webif/templates/index.html b/rtr2/webif/templates/index.html index 0ad45b34a..68ee9a6ca 100755 --- a/rtr2/webif/templates/index.html +++ b/rtr2/webif/templates/index.html @@ -3,22 +3,56 @@ {% set logo_frame = false %} -{% set update_interval = 0 %} +{% set update_interval = 5000 %} +{% set reload_button = false %} {% block pluginscripts %} + + +{% endblock pluginscripts %} + + {% block headtable %} @@ -63,46 +239,17 @@ -{% set tab1title = "" ~ p.get_shortname() ~ " " ~ _('Clients') ~ " (" ~ clients|length ~ ")" %} +{% set tab1title = "" ~ p.get_shortname() ~ " " ~ _('Clients') ~ " (" ~ clients|length ~ ")" %} {% block bodytab1 %} -
-
-
- - - - - - - - - - - - - - {% if clients %} - {% for client in clients %} - - - - - - - - - - - {% endfor %} - {% else %} - - - - {% endif %} - -
{{ _('Visu Client') }}{{ _('IP') }}{{ _('Port') }}{{ _('Protokoll') }}{{ _('Client Software') }}{{ _('Browser') }}{{ '' }}
{{ client.name }}{{ client.ip }}{{ client.port }}{{ client.protocol }}{{ client.sw }} {{ client.swversion }}{{ client.browser }} {{ client.browserversion }}{{ client.hostname }}
{{ _('Keine aktiven Clients') }}
- + + + +
+
+ {% endblock %} @@ -172,10 +319,9 @@ +{% set tab4title = "" ~ p.get_shortname() ~ " " ~ _('Clients') ~ " (" ~ clients|length ~ ")" %} + {% block bodytab4 %} + {% endblock bodytab4 %} diff --git a/sml/requirements.txt b/sml/requirements.txt new file mode 100755 index 000000000..3e8ef98c9 --- /dev/null +++ b/sml/requirements.txt @@ -0,0 +1 @@ +pyserial>=3.2.1 \ No newline at end of file diff --git a/sml2/__init__.py b/sml2/__init__.py new file mode 100755 index 000000000..93ff85896 --- /dev/null +++ b/sml2/__init__.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2012-2014 Oliver Hinckel github@ollisnet.de +# Copyright 2018-2021 Bernd.Meiners@mail.de +# Copyright 2022-2022 Julian Scholle julian.scholle@googlemail.com +######################################################################### +# +# This file is part of SmartHomeNG. https://github.com/smarthomeNG// +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +######################################################################### + +import logging +import time +import re +import serial +import threading +import struct +import socket +import errno +import asyncio +import serial_asyncio +import traceback +from smllib import SmlStreamReader +from smllib import const as smlConst + +from lib.module import Modules +from lib.item import Items + +from lib.model.smartplugin import * +from .webif import WebInterface + +SML_SCHEDULER_NAME = 'Sml2' + + +class Sml2(SmartPlugin): + """ + ASYNC IO Related + """ + + def start_background_loop(self) -> None: + asyncio.set_event_loop(self.loop) + self.loop.run_forever() + + class Reader(asyncio.Protocol): + + def __init__(self): + self.buf = None + self.smlx = None + self.transport = None + + def __call__(self): + return self + + def connection_made(self, transport): + """Store the serial transport and prepare to receive data. + """ + self.transport = transport + self.buf = bytes() + self.smlx.logger.debug("Reader connection created") + self.smlx.connected = True + + def data_received(self, chunk): + """Store characters until a newline is received. + """ + self.smlx.logger.debug(f"Smartmeter is sending {len(chunk)} bytes of data") + self.buf += chunk + + if len(self.buf) < 100: + return + try: + self.smlx.stream.add(self.buf) + self.buf = bytes() + self.smlx.parse_data() + except Exception as e: + self.smlx.logger.error(f'Reading data from {self.smlx._target} failed with exception {e}') + # just in case of many errors, reset buffer + if len(self.buf) > 100000: + self.smlx.logger.error("Buffer got to large, doing buffer reset") + self.buf = bytes() + + def connection_lost(self, exc): + self.smlx.logger.error("Connection so serial device was closed") + self.smlx.connected = False + + PLUGIN_VERSION = '2.0.0' + + def __init__(self, sh): + """ + Initializes the plugin. The parameters described for this method are pulled from the entry in plugin.conf. + """ + + # Call init code of parent class (SmartPlugin) + super().__init__() + + self.cycle = self.get_parameter_value('cycle') + + self.host = self.get_parameter_value('host') # None + self.port = self.get_parameter_value('port') # 0 + self.serialport = self.get_parameter_value('serialport') # None + + self.use_polling = self.get_parameter_value('use_polling') # false + self.cycle = self.get_parameter_value('cycle') + + self.device = self.get_parameter_value('device') # raw + self.timeout = self.get_parameter_value('timeout') # 5 + self.buffersize = self.get_parameter_value('buffersize') # 1024 + self.date_offset = self.get_parameter_value('date_offset') # 0 + + self.connected = False + self.alive = False + self._serial = None + self._sock = None + self._target = None + self._items = {} + self._item_dict = {} + self._lock = threading.RLock() + self.logger = logging.getLogger(__name__) + self.reader = False + self.loop = None + self.loop_thread = None + self.stream = SmlStreamReader() + self.init_webinterface(WebInterface) + self.task = None + self.values = {} + + def run(self): + """ + Run method for the plugin + """ + self.logger.debug(f"Plugin '{self.get_fullname()}': run method called") + # Setup scheduler for device poll loop + if self.use_polling: + self.scheduler_add(SML_SCHEDULER_NAME, self.poll_device, cycle=self.cycle) + else: + self.loop = asyncio.new_event_loop() + self.loop_thread = threading.Thread(target=self.start_background_loop, args=(), daemon=True) + self.loop_thread.start() + + self.reader = self.Reader() + self.reader.smlx = self + reader = serial_asyncio.create_serial_connection(self.loop, self.reader, self.serialport, baudrate=9600, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, + timeout=self.timeout) + + self.task = asyncio.run_coroutine_threadsafe(reader, self.loop) + + self.alive = True + + def stop(self): + """ + Stop method for the plugin + """ + self.logger.debug(f"Plugin '{self.get_fullname()}': stop method called") + if self.use_polling: + self.scheduler_remove(SML_SCHEDULER_NAME) + self.alive = False + self.disconnect() + else: + self.loop.stop() + self.loop_thread.join() + self.alive = False + + def parse_item(self, item): + """ + Default plugin parse_item method. Is called when the plugin is initialized. + + :param item: The item to process. + :return: returns update_item function if changes are to be watched + """ + + if self.has_iattr(item.conf, 'sml_obis'): + obis = self.get_iattr_value(item.conf, 'sml_obis') + prop = self.get_iattr_value(item.conf, 'sml_prop') if self.has_iattr(item.conf, 'sml_prop') else 'valueReal' + if obis not in self._items: + self._items[obis] = {} + if prop not in self._items[obis]: + self._items[obis][prop] = [] + self._items[obis][prop].append(item) + self._item_dict[item] = (obis, prop) + self.logger.debug(f'Attach {item.id()} with {obis=} and {prop=}') + return None + + def parse_logic(self, logic): + pass + + def update_item(self, item, caller=None, source=None, dest=None): + """ + Item has been updated + + This method is called, if the value of an item has been updated by SmartHomeNG. + It should write the changed value out to the device (hardware/interface) that + is managed by this plugin. + + :param item: item to be updated towards the plugin + :param caller: if given it represents the callers name + :param source: if given it represents the source + :param dest: if given it represents the dest + """ + if caller != self.get_shortname(): + # Code to execute, only if the item has not been changed by this plugin: + self.logger.info("Update item: {}, item has been changed outside this plugin".format(item.id())) + pass + + def connect(self): + with self._lock: + self._target = None + try: + if self.serialport is not None: + self._target = f'serial://{self.serialport}' + self._serial = serial.Serial(self.serialport, 9600, serial.EIGHTBITS, serial.PARITY_NONE, + serial.STOPBITS_ONE, timeout=self.timeout) + + elif self.host is not None: + self._target = f'tcp://{self.host}:{self.port}' + self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._sock.settimeout(2) + self._sock.connect((self.host, self.port)) + self._sock.setblocking(False) + + except Exception as e: + self.logger.error(f'SML: Could not connect to {self._target}: {e}') + return + + self.logger.info(f'SML: Connected to {self._target}') + self.connected = True + + def disconnect(self): + with self._lock: + if self.connected: + try: + if self._serial is not None: + self._serial.close() + self._serial = None + elif self._sock is not None: + self._sock.shutdown(socket.SHUT_RDWR) + self._sock = None + except Exception: + pass + self.logger.info('SML: Disconnected!') + self.connected = False + self._target = None + + def _read(self, length): + total = bytes() + self.logger.debug('Start read') + if self._serial is not None: + while True: + ch = self._serial.read() + # self.logger.debug(f"Read {ch=}") + if len(ch) == 0: + self.logger.debug('End read') + return total + total += ch + if len(total) >= length: + self.logger.debug('End read') + return total + elif self._sock is not None: + while True: + try: + data = self._sock.recv(length) + if data: + total += data + except socket.error as e: + if e.args[0] == errno.EAGAIN or e.args[0] == errno.EWOULDBLOCK: + break + else: + raise e + + self.logger.debug('End read') + return b''.join(total) + + def poll_device(self): + """ + Polls for updates of the device, called by the scheduler. + """ + + # check if another cyclic cmd run is still active + successfully_acquired = self._lock.acquire(False) + if not successfully_acquired: + self.logger.warning( + 'Triggered cyclic poll_device, but previous cyclic run is still active. Therefore request will be skipped.') + return + + start = time.time() + + try: + self.logger.debug('Polling Smartmeter now') + self.connect() + + if not self.connected: + self.logger.error('Not connected, no query possible') + return + else: + self.logger.debug('Connected, try to query') + + start = time.time() + data = self._read(self.buffersize) + if len(data) == 0: + self.logger.error('Reading data from device returned 0 bytes!') + return + + self.logger.debug(f'Read {len(data)} bytes') + self.stream.add(data) + self.parse_data() + + except Exception as e: + self.logger.error(f'Reading data from {self._target} failed with exception {e}') + return + + finally: + cycletime = time.time() - start + self.disconnect() + self.logger.debug(f"Polling Smartmeter done. Poll cycle took {cycletime} seconds.") + self._lock.release() + + def parse_data(self): + while True: + try: + frame = self.stream.get_frame() + if frame is None: + break + + obis_values = frame.get_obis() + for sml_entry in obis_values: + obis_code = sml_entry.obis.obis_code + if obis_code not in self.values: + self.values[obis_code] = dict() + self.values[obis_code]['name'] = smlConst.OBIS_NAMES.get(sml_entry.obis) + self.values[obis_code]['unit'] = smlConst.UNITS.get(sml_entry.unit) + if obis_code in self._items: + if 'valueReal' in self._items[obis_code]: + for item in self._items[obis_code]['valueReal']: + item(sml_entry.get_value(), self.get_shortname()) + except Exception as e: + detail = traceback.format_exc() + self.logger.warning(f'Preparing and parsing data failed with exception {e}: and detail: {detail}') + + @property + def item_list(self): + return list(self._item_dict.keys()) + + @property + def log_level(self): + return self.logger.getEffectiveLevel() diff --git a/sml2/plugin.yaml b/sml2/plugin.yaml new file mode 100755 index 000000000..5ec3a4801 --- /dev/null +++ b/sml2/plugin.yaml @@ -0,0 +1,171 @@ +# Metadata for the Smart-Plugin +plugin: + # Global plugin attributes + type: gateway # plugin type (gateway, interface, protocol, system, web) + description: + de: 'Auslesen von Stromzählern mit SML-Protokoll' + en: 'Readout of smartmeter with SML protocol' + maintainer: bmxp + tester: CaeruleusAqua # Who tests this plugin? + state: ready # change to ready when done with development + keywords: sml smartmeter + documentation: https://www.smarthomeng.de/developer/plugins/smlx/user_doc.html + support: https://knx-user-forum.de/forum/supportforen/smarthome-py/39119-sml-plugin-datenblock-größenfehler + restartable: True + version: 2.0.0 # Plugin version + sh_minversion: 1.4.2 # minimum shNG version to use this plugin + #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) + multi_instance: True # plugin supports multi instance + classname: Sml2 # class containing the plugin + +parameters: + # Definition of parameters to be configured in etc/plugin.yaml + serialport: + type: str + default: '' + description: + de: 'Serieller Port an dem der Smartmeter angeschlossen ist' + en: 'Serial port the Smartmeter is attached to' + timeout: + type: int + default: 8 + description: + de: 'Maximale Wartezeit bis serielles Lesen abgebrochen wird' + en: 'Maximum delay time until serial read will end' + buffersize: + type: int + default: 1024 + description: + de: 'Größe des Lesepuffers. Mindestens doppelte Größe der maximalen Nachrichtenlänge in Bytes' + en: 'Size of read buffer. At least twice the size of maximum message length' + host: + type: str + description: + de: 'Host der eine IP Schnittstelle bereitstellt' + en: 'Host that provides an IP interface' + port: + type: int + description: + de: 'Port für die Kommunikation' + en: 'Port for communication' + + use_polling: + type: bool + default: false + description: + de: 'Soll polling statt async IO genutzt werden?' + en: 'Should polling be used instead of async IO?' + + cycle: + type: int + default: 60 + description: + de: 'Zeitlicher Abstand in Sekunden zwischen zwei Abfragen des Smartmeters' + en: 'Time in seconds between two queries of Smartmeter' + + device: + type: str + default: 'raw' + description: + de: 'Name des Gerätes' + en: 'Name of Smartmeter' + + date_offset: + type: int + default: 0 + description: + de: 'Unix timestamp der Smartmeter Inbetriebnahme' + en: 'Unix timestamp of Smartmeter start-up after installation' + + # for crc generation + poly: + type: int + default: 0x1021 + description: + de: 'Polynom für die crc Berechnung' + en: 'Polynomial for crc calculation' + + reflect_in: + type: bool + default: True + description: + de: 'Umkehren der Bitreihenfolge für die Eingabe' + en: 'Reflect the octets in the input' + + xor_in: + type: int + default: 0xffff + description: + de: 'Initialer Wert für XOR Berechnung' + en: 'Initial value for XOR calculation' + + reflect_out: + type: bool + default: True + description: + de: 'Umkehren der Bitreihenfolge der Checksumme vor der Anwendung des XOR Wertes' + en: 'Reflect the octet of checksum before application of XOR value' + + xor_out: + type: int + default: 0xffff + description: + de: 'XOR Berechnung der CRC mit diesem Wert' + en: 'XOR final CRC value with this value' + + swap_crc_bytes: + type: bool + default: False + description: + de: 'Bytereihenfolge der berechneten Checksumme vor dem Vergleich mit der vorgegeben Checksumme tauschen' + en: 'Swap bytes of calculated checksum prior to comparison with given checksum' + +item_attributes: + # Definition of item attributes defined by this plugin + sml_obis: + type: str + description: + de: 'Wert des angegebenen OBIS codes einem Item zuweisen' + en: 'Assigns the value for the given OBIS code to the item' + + sml_prop: + type: str + description: + de: 'Andere Eigenschaft des Obis Codes nutzen, z.B. unitName' + en: 'Used to assign other information for an OBIS code to the item' + default: 'valueReal' + valid_list: + - 'valueReal' # num Der echte Wert, der sich unter Berücksichtigung des Skalierungsfaktors errechnet + - 'unitName' # str Die Bezeichnung der Einheit + - 'actualTime' # str ein String mit Datum und Zeit der tatsächlichen Zeit (z.B. 'Fri Oct 18 09:34:21 2019') + - 'statRun' # bool True: Zähler in Betrieb, False: Zähler nicht in Betrieb + - 'statFraudMagnet' # bool True: magnetische Manipulation erkannt, False: ok + - 'statFraudCover' # bool True: Manipulation der Abdeckung erkannt, False: ok + - 'statEnergyTotal' # bool Stromfluss gesamt. True: -A, False: +A + - 'statEnergyL1' # bool Stromfluss L1. True: -A, False: +A + - 'statEnergyL2' # bool Stromfluss L2. True: -A, False: +A + - 'statEnergyL3' # bool Stromfluss L3. True: -A, False: +A + - 'statRotaryField' # bool True: Drehfeld nicht L1->L2->L3, False: ok + - 'statBackstop' # bool True: Backstop aktive, False: Backstop nicht aktive + - 'statCalFault' # bool True: Fataler Fehler mit Auswirkung auf Kalibrierung, False: ok + - 'statVoltageL1' # bool True: Spannung an L1 vorhanden, False: nicht vorhanden + - 'statVoltageL2' # bool True: Spannung an L2 vorhanden, False: nicht vorhanden + - 'statVoltageL3' # bool True: Spannung an L3 vorhanden, False: nicht vorhanden + - 'obis' # str OBIS Code + - 'objName' # str der OBIS Code als Zeichenkette / binäre Daten + - 'status' # str ein Statuswert + - 'valTime' # str die Zeit entsprechend dem Wert (als Sekunden der Einheit start oder als Zeitstempel) + - 'unit' # str Identifiziert die Einheit des entsprechenden Wertes (z.B. W, kWh, V, A, ...) + - 'scaler' # str der Skalierungsfaktor (10-factor shift) der benutzt wird um den echten Wert zu berechnen + - 'value' # num der Wert + - 'signature' # str Die Signatur um die Daten zu schützen + +item_structs: NONE + # Definition of item-structure templates for this plugin (enter 'item_structs: NONE', if section should be empty) + +plugin_functions: NONE + # Definition of plugin functions defined by this plugin (enter 'plugin_functions: NONE', if section should be empty) + +logic_parameters: NONE + # Definition of logic parameters defined by this plugin (enter 'logic_parameters: NONE', if section should be empty) + diff --git a/sml2/requirements.txt b/sml2/requirements.txt new file mode 100755 index 000000000..086f48da0 --- /dev/null +++ b/sml2/requirements.txt @@ -0,0 +1,3 @@ +pyserial>=3.2.1 +SmlLib>=1.2 +pyserial-asyncio>=0.6 diff --git a/sml2/user_doc.rst b/sml2/user_doc.rst new file mode 100755 index 000000000..7ef5082b9 --- /dev/null +++ b/sml2/user_doc.rst @@ -0,0 +1,250 @@ +.. index:: Plugins; sml2 +.. index:: sml2 + +==== +sml2 +==== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + + +Anforderungen +============= + +Normalerweise sendet die Hardware eines Smartmeter alle paar Sekunden Statusinformationen, +die über eine Schnittstelle ausgelesen werden können. + +Für diese Plugin wird je nach Schnittstelle des Smartmeter entweder ein +`IR Lesekopf `__ +an einer USB Schnittstelle oder ein RS485 zu USB Konverter benötigt. + +Die vom Smartmeter gesendeten Daten werden entsprechend dem SML Protokoll (Smart Message Language) codiert und übertragen. +Nähere Informationen finden sich `hier `__ + +Unter den übermittelten Daten befinden sich Statusinformationen die mittels +`OBIS Kennzahlen `__ den verschiedenen +Messungen im Smartmeter zugeordnet werden können. +Zusätzlich geben die Datensätze noch Metadaten wie Einheit, Zeitstempel etc. an. +Sowohl die Messwerte wie auch die Metadaten können Items zugewiesen werden. + +Die implementierten Algorithmen um die Checksummen zu berechnen wurden von +`PyCRC `__ +verwendet. + +Notwendige Software +------------------- + +Es wird das Paket pyserial benötigt das in ``requirements.txt`` aufgeführt ist und ab SHNG 1.8 automatisch beim Start installiert wird. + +Benutzer berichten davon, das es unter Linux notwendig ist den User smarthome den Gruppen dialout und tty hinzuzufügen. +Weiterhin kann es notwendig sein eine Regel zur automatischen Zuordnung der Schnittstelle zu einem festen Port einzurichten. +Die Vorgehensweise ist beim DLMS Plugin beschrieben + +Unterstützte Geräte +------------------- + +Folgende Zähler wurden mit dem Plugin bisher ausgelesen: + +- Hager EHZ363Z5 +- Hager EHZ363W5 +- EHM eHZ-GW8 E2A 500 AL1 +- EHM eHZ-ED300L +- Holley DTZ541 (2018 Modell mit fehlerhafter CRC Implementation) +- Landis & Gyr E220 + +Konfiguration +============= + +Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/sml2` beschrieben. + +plugin.yaml +----------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + +.. code-block:: yaml + + smlx: + class_name: Smlx + class_path: plugins.smlx + serialport: /dev/ttyUSB0 + # host: 192.168.2.1 + # port: 1234 + # device: raw | hex | + # for CRC generation + # poly: 0x1021 + # reflect_in: True + # xor_in: 0xffff + # reflect_out: True + # xor_in: 0xffff + # swap_crc_bytes: False + # date_offset: 0 + +Das ``date_offset`` Attribut definiert den Zeitpunkt des ersten Starts des +Smartmeter nach dem dieser installiert wurde. Es ist ein Integer und repräsentiert +die Sekunden nach `Unix Epoch `__ (1.1.1970, 0:00 Uhr, UTC) +Es wird vom Plugin benutzt um den tatsächlichen Zeitpunkt eines OBIS Datenpunktes zu berechnen. + +Wenn man Datum und Zeit nicht kennt kann wie folgt vorgegangen werden: + +Konfigurieren des Loggings von SmartHomeNG so, das das smlx Plugin Debug Informationen ausgibt. +Die Logs dann auf ``DEBUG plugins.smlx`` zeilen prüfen. +Suchen nach ``Entry`` Zeilen die ``valTime`` enthalten. +Wenn sich ein Eintrag ``valTime`` findet der aussieht wie z.B. ``[None, 8210754]``, +dann notieren der Integerzahl (hier: ``8210754``). +Ganz links im Logeintrag findet sich Uhrzeit und Datum des Logging Eintrags. +Über einen `Unix Zeit Konverter `__ dann diese Uhrzeit und Datum in einen Unix Zeitstempel umwandeln. +Daraus lässt sich ``date_offset`` berechnen als ``Unix timestamp`` - ``valTime``. + +Beispiel Eintrag aus dem Log: + +"2019-10-18 09:34:35 DEBUG plugins.smlx Entry {'objName': '1-0:1.8.0*255', 'status': 1839364, 'valTime': [None, 8210754], 'unit': 30, 'scaler': -1, 'value': 560445, 'signature': None, 'obis': '1-0:1.8.0*255', 'valueReal': 56044.5, 'unitName': 'Wh', 'realTime': 'Fri Oct 18 09:34:21 2019'}" + +Umwandeln von '*2019-10-18 09:34:35*' in einen Unix Zeitstempel liefert das Ergebnis *1571384075*. + +Mit ``valTime`` = *8210754* aus dem Log ergibt sich ``date_offset`` = 1571384075 - 8210754 = *1563173321‬*. + +Wenn ein SmartMeter für ``valTime`` keinen gültigen Wert liefert (``valTime: None``), +dann ist ``date_offset`` nutzlos und kann weggelassen werden. + + +items.yaml +---------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + +Items können mit Hilfe einer OBIS-Kennung einen vom Plugin abgerufenen Wert oder eine Eigenschaft zuweisen. + +Folgend eine Liste von nützlichen OBIS Codes die mit dem Attribut ``sml_obis`` verwendet werden können: + +- 129-129:199.130.3*255 - Hersteller +- 1-0:0.0.9*255 - ServerId / Seriennummer +- 1-0:1.8.0*255 - Total Bezug [kWh] +- 1-0:1.8.1*255 - Tarif 1 Bezug [kWh] +- 1-0:1.8.2*255 - Tarif 2 Bezug [kWh] +- 1-0:2.8.0*255 - Total Einspeisung [kWh] +- 1-0:2.8.1*255 - Tarif 1 Einspeisung [kWh] +- 1-0:2.8.2*255 - Tarif 2 Einspeisung [kWh] +- 1-0:16.7.0*255 - Momentane Leistung [W] + +Anstatt den (Mess-) Wert für einen bestimmten OBIS-Code zuzuweisen, kann auch eine weitere Eigenschaft +des Datenpunktes zugewiesen werden. Das kann durch ein zusätzliches Attribut ``sml_prop`` +erreicht werden. +Hat ein Item ein Attribut ``sml_obis`` zugewiesen, aber kein Attribut ``sml_prop``, so wird + +Die folgenden Eigenschaften für ``sml_prop`` können verwendet werden: + +- ``objName`` - der OBIS Code als Zeichenkette / binäre Daten +- ``status`` - Ein Statuswert +- ``valTime`` - die Zeit entsprechend dem Wert (als Sekunden der Einheit start oder als Zeitstempel) +- ``unit`` - Identifiziert die Einheit des entsprechenden Wertes (z.B. W, kWh, V, A, ...) +- ``scaler`` - der Skalierungsfaktor (10-factor shift) der benutzt wird um den echten Wert zu berechnen +- ``value`` - der Wert +- ``signature`` - Die Signatur um die Daten zu schützen + +Zusätzlich können die folgenden Eigenschaften für ``sml_prop`` verwendet werden, die bei Bedarf berechnet werden: + +- ``obis`` - Der OBIS Code als Zeichenkette +- ``valueReal`` - Der echte Wert, der sich unter Berücksichtigung des Skalierungsfaktors errechnet +- ``unitName`` - Die Bezeichnung der Einheit +- ``actualTime`` - ein String mit Datum und Zeit der tatsächlichen Zeit (z.B. 'Fri Oct 18 09:34:21 2019') + + +Der Status des Smartmeter ist ein String mit binären Daten. +Die ersten 8 Bits sind immer 0000 0100 +Alle anderen Bits haben eine spezielle Bedeutung und werden in folgende Attribute dekodiert: + +- ``statRun`` - True: Smartmeter zählt, False: Stillstand +- ``statFraudMagnet`` - True: Manipulation mit Magneten entdeckt, False: Alles ok +- ``statFraudCover`` - True: Manipulation der Abdeckung entdeckt, False: Alles ok +- ``statEnergyTotal`` - Energiefluss total. True: -A, False: +A +- ``statEnergyL1`` - Energiefluss L1. True: -A, False: +A +- ``statEnergyL2`` - Energiefluss L2. True: -A, False: +A +- ``statEnergyL3`` - Energiefluss L3. True: -A, False: +A +- ``statRotaryField`` - True Drehfeld nicht L1->L2->L3, False: Alles ok +- ``statBackstop`` - True backstop aktiv, False: backstop nicht aktive +- ``statCalFault`` - True Kalibrationsfehler, False: ok +- ``statVoltageL1`` - True Spannung L1 vorhanden, False: nicht vorhanden +- ``statVoltageL2`` - True Spannung L2 vorhanden, False: nicht vorhanden +- ``statVoltageL3`` - True Spannung L3 vorhanden, False: nicht vorhanden + +Beispiel +~~~~~~~~ + +.. code:: yaml + + power: + + home: + + total: + type: num + sml_obis: 1-0:1.8.0*255 + + current: + type: num + sml_obis: 1-0:16.7.0*255 + + unit: + type: num + sml_obis: 1-0:16.7.0*255 + sml_prop: unitName + + +logic.yaml +---------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + + +Funktionen +---------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + + +Beispiele +========= + +Hier ist noch was zu tun + + +Web Interface +============= + +iefert eine Reihe Komponenten von Drittherstellern mit, die für die Gestaltung des Webinterfaces genutzt werden können. Erweiterungen dieser Komponenten usw. finden sich im Ordner ``/modules/http/webif/gstatic``. + +Wenn das Plugin darüber hinaus noch Komponenten benötigt, werden diese im Ordner ``webif/static`` des Plugins abgelegt. + + +Besonderheiten bestimmter Hardware +================================== + + +Holley DTZ541 +------------- + +Normalerweise sollte es nicht notwendig sein die CRC Prüfsummenbildung zu ändern. +Aber zumindest das Holley DTZ541 nutzt falsche Parameter. Daher sind folgende Einstellungen +für dieses Gerät in der ``plugin.yaml`` vorzunehmen + +.. code-block:: yaml + + HolleyDTZ541: + plugin_name: smlx + serialport: /dev/ttyUSB0 + buffersize: 1500 + poly: 0x1021 + reflect_in: true + xor_in: 0x0000 + reflect_out: true + xor_out: 0x0000 + swap_crc_bytes: True + date_offset: 1563173307 + +Die Werte für ``serialport``, ``buffersize`` und ``date_offset`` müssen dabei auf die lokalen Gegebenheiten angepasst werden diff --git a/sml2/webif/__init__.py b/sml2/webif/__init__.py new file mode 100755 index 000000000..631616238 --- /dev/null +++ b/sml2/webif/__init__.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2022- Michael Wenzel wenzel_michael@web.de +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# This plugin supports sml and reads meter. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import datetime +import time +import os +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + tmpl = self.tplenv.get_template('index.html') + # Setting pagelength (max. number of table entries per page) for web interface + try: + pagelength = self.plugin.webif_pagelength + except Exception: + pagelength = 100 + # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, + items=sorted(self.plugin.item_list, key=lambda k: str.lower(k['_path'])), + item_count=len(self.plugin.item_list), + plugin_shortname=self.plugin.get_shortname(), + plugin_version=self.plugin.get_version(), + plugin_info=self.plugin.get_info(), + maintenance=True if self.plugin.log_level <= 20 else False, + ) + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + if dataSet is None: + # get the new data + data = dict() + + data['items'] = {} + for item in self.plugin.item_list: + data['items'][item.id()] = {} + data['items'][item.id()]['value'] = item.property.value + data['items'][item.id()]['last_update'] = item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') + data['items'][item.id()]['last_change'] = item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') + try: + return json.dumps(data, default=str) + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") diff --git a/sml2/webif/static/img/plugin_logo.png b/sml2/webif/static/img/plugin_logo.png new file mode 100755 index 000000000..33cc361f3 Binary files /dev/null and b/sml2/webif/static/img/plugin_logo.png differ diff --git a/sml2/webif/static/img/readme.txt b/sml2/webif/static/img/readme.txt new file mode 100755 index 000000000..1a7c55eef --- /dev/null +++ b/sml2/webif/static/img/readme.txt @@ -0,0 +1,6 @@ +This directory is for storing images that are used by the web interface. + +If you want to have your own logo on the top of the web interface, store it here and name it plugin_logo.. + +Extension can be png, svg or jpg + diff --git a/sml2/webif/templates/index.html b/sml2/webif/templates/index.html new file mode 100755 index 000000000..bc5db2265 --- /dev/null +++ b/sml2/webif/templates/index.html @@ -0,0 +1,359 @@ +{% extends "base_plugin.html" %} +{% set logo_frame = false %} +{% set update_interval = 15000 %} + + +{% block pluginstyles %} + +{% endblock pluginstyles %} + + +{% block pluginscripts %} + + + + + +{% endblock pluginscripts %} + + +{% block headtable %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Anschluss{% if p.host %}{{ p.host }}{{ _(':') }}{{ p.port }}{% else %}{{ (p.serialport) }}{% endif %}Timeout{{ p.timeout }}s
Buffer Size{{ p.buffersize }}Date Offset{{ p.date_offset }}
Verbunden{% if p.connected %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %}Zykluszeit{% if p.use_polling %}{{ p.cycle }}s{% else %}{{ _('AsyncIO') }}{% endif %}
+{% endblock headtable %} + + + +{% block buttons %} +{% if 1==2 %} +
+ +
+{% endif %} +{% endblock %} + + +{% set tabcount = 4 %} + + +{% if item_count == 0 %} + {% set start_tab = 2 %} +{% endif %} + + +{% set tab1title = "" ~ p.get_shortname() ~ " Items (" ~ item_count ~ ")" %} +{% set tab2title = "" ~ p.get_shortname() ~ " Obis Data" %} +{% if '1-0:1.8.0*255' in p.values %} + {% set tab3title = "" ~ p.get_shortname() ~ " Zählerstatus" %} +{% else %} + {% set tab3title = "hidden" %} +{% endif %} +{% if maintenance %} + {% set tab4title = "" ~ p.get_shortname() ~ " Maintenance" %} +{% else %} + {% set tab4title = "hidden" %} +{% endif %} + + + +{% block bodytab1 %} +
+ + + + + + + + + + + + + {% for item in items %} + + + + + + + + + {% endfor %} + +
{{ _('Item') }}{{ _('Attribut') }}{{_('Typ')}}{{_('Wert')}}{{_('Letztes Update')}}{{_('Letzter Change')}}
{{ item._path }}{{ p.get_iattr_value(item.conf, 'sml_obis') }}{{ item._type }}.{{ item._value }}{{ item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') }}{{ item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') }}
+ +
+{% endblock bodytab1 %} + + +{% block bodytab2 %} +
+
+ OBIS Raw data + + + + + + + + + + {% for entry in p.values %} + + + + + + {% endfor %} + +
{{ _('OBIS Codes') }}{{ _('Data') }}{{ _('Unit') }}
{{ entry }}{{ p.values[entry]['name'] }}{{ p.values[entry]['unit'] }}
+
+{% endblock bodytab2 %} + + +{% block bodytab3 %} +
+ {% if '1-0:1.8.0*255' in p.values %} + + + + + + + + + {% if 'statRun' in p.values['1-0:1.8.0*255'] %} + + + + + {% endif %} + {% if 'statFraudMagnet' in p.values['1-0:1.8.0*255'] %} + + + + + {% endif %} + {% if 'statFraudCover' in p.values['1-0:1.8.0*255'] %} + + + + + {% endif %} + {% if 'statEnergyTotal' in p.values['1-0:1.8.0*255'] %} + + + + + {% endif %} + {% if 'statEnergyL1' in p.values['1-0:1.8.0*255'] %} + + + + + {% endif %} + {% if 'statEnergyL2' in p.values['1-0:1.8.0*255'] %} + + + + + {% endif %} + {% if 'statEnergyL3' in p.values['1-0:1.8.0*255'] %} + + + + + {% endif %} + {% if 'statVoltageL1' in p.values['1-0:1.8.0*255'] %} + + + + + {% endif %} + {% if 'statVoltageL2' in p.values['1-0:1.8.0*255'] %} + + + + + {% endif %} + {% if 'statVoltageL3' in p.values['1-0:1.8.0*255'] %} + + + + + {% endif %} + {% if 'statRotaryField' in p.values['1-0:1.8.0*255'] %} + + + + + {% endif %} + {% if 'statBackstop' in p.values['1-0:1.8.0*255'] %} + + + + + {% endif %} + {% if 'statCalFault' in p.values['1-0:1.8.0*255'] %} + + + + + {% endif %} + +
{{ _('Status') }}{{ _('Value') }}
{{ _('Zähler in Betrieb') }}{% if p.values['1-0:1.8.0*255']['statRun'] %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %}
{{ _('magnetische Manipulation') }}{% if p.values['1-0:1.8.0*255']['statFraudMagnet'] %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %}
{{ _('Manipulation der Abdeckung') }}{% if p.values['1-0:1.8.0*255']['statFraudCover'] %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %}
{{ _('Stromfluss gesamt') }}{% if p.values['1-0:1.8.0*255']['statEnergyTotal'] %}{{ _('-A') }}{% else %}{{ _('+A') }}{% endif %}
{{ _('Stromfluss L1') }}{% if p.values['1-0:1.8.0*255']['statEnergyL1'] %}{{ _('-A') }}{% else %}{{ _('+A') }}{% endif %}
{{ _('Stromfluss L2') }}{% if p.values['1-0:1.8.0*255']['statEnergyL2'] %}{{ _('-A') }}{% else %}{{ _('+A') }}{% endif %}
{{ _('Stromfluss L3') }}{% if p.values['1-0:1.8.0*255']['statEnergyL3'] %}{{ _('-A') }}{% else %}{{ _('+A') }}{% endif %}
{{ _('Spannung an L1') }}{% if p.values['1-0:1.8.0*255']['statVoltageL1'] %}{{ _('OK') }}{% else %}{{ _('NOK') }}{% endif %}
{{ _('Spannung an L2') }}{% if p.values['1-0:1.8.0*255']['statVoltageL2'] %}{{ _('OK') }}{% else %}{{ _('NOK') }}{% endif %}
{{ _('Spannung an L3') }}{% if p.values['1-0:1.8.0*255']['statVoltageL3'] %}{{ _('OK') }}{% else %}{{ _('NOK') }}{% endif %}
{{ _('Drehfeld') }}{% if p.values['1-0:1.8.0*255']['statRotaryField'] %}{{ _('NOK') }}{% else %}{{ _('OK') }}{% endif %}
{{ _('Backstop') }}{% if p.values['1-0:1.8.0*255']['statBackstop'] %}{{ _('Active') }}{% else %}{{ _('Nein') }}{% endif %}
{{ _('Fataler Fehler') }}{% if p.values['1-0:1.8.0*255']['statCalFault'] %}{{ _('FAULT') }}{% else %}{{ _('Keiner') }}{% endif %}
+ {% endif %} +
+{% endblock bodytab3 %} + + +{% block bodytab4 %} +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{{ _('dict/list') }}{{ _('count') }}{{ _('content') }}
{{ _('_items') }}{{ len(p._items) }}{{ p._items }}
{{ _('_item_dict') }}{{ len(p._item_dict) }}{{ p._item_dict }}
{{ _('values') }}{{ len(p.values) }}{{ p.values }}
+
+{% endblock bodytab4 %} diff --git a/smlx/__init__.py b/smlx/__init__.py index ea880981a..174add5fc 100755 --- a/smlx/__init__.py +++ b/smlx/__init__.py @@ -2,7 +2,8 @@ # vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab ######################################################################### # Copyright 2012-2014 Oliver Hinckel github@ollisnet.de -# Copyright 2018-2021 Bernd.Meiners@mail.de +# Copyright 2018-2022 Bernd Meiners Bernd.Meiners@mail.de +# Copyright 2022- Michael Wenzel wenzel_michael@web.de ######################################################################### # # This file is part of SmartHomeNG. https://github.com/smarthomeNG// @@ -21,7 +22,6 @@ # along with SmartHomeNG. If not, see . ######################################################################### -import logging import time import re import serial @@ -30,82 +30,37 @@ import socket import errno -from lib.module import Modules +from lib.model.smartplugin import SmartPlugin from lib.item import Items -from lib.model.smartplugin import * - -if __name__ == '__main__': - logger = logging.getLogger(__name__) - logger.debug("Init standalone {}".format(__name__)) - logging.getLogger().setLevel(logging.DEBUG) - ch = logging.StreamHandler() - ch.setLevel(logging.DEBUG) - # just like print - formatter = logging.Formatter('%(message)s') - ch.setFormatter(formatter) - # add the handlers to the logger - logging.getLogger().addHandler(ch) -else: - logger = logging.getLogger() - logger.debug("Init plugin component {}".format(__name__)) - from . import algorithms from .webif import WebInterface -def to_Hex(data): - """ - Returns the hex representation of the given data - """ - # try: - # return data.hex() - # except: - # return "".join("%02x " % b for b in data).rstrip() - # logger.debug("Hextype: {}".format(type(data))) - if isinstance(data, int): - return hex(data) - - return "".join("%02x " % b for b in data).rstrip() - - -def swap16(x): - return (((x << 8) & 0xFF00) | - ((x >> 8) & 0x00FF)) - - -def swap32(x): - return (((x << 24) & 0xFF000000) | - ((x << 8) & 0x00FF0000) | - ((x >> 8) & 0x0000FF00) | - ((x >> 24) & 0x000000FF)) - - -# start_sequence = bytearray.fromhex('1B 1B 1B 1B 01 01 01 01') -# end_sequence = bytearray.fromhex('1B 1B 1B 1B 1A') +START_SEQUENCE = bytearray.fromhex('1B 1B 1B 1B 01 01 01 01') +END_SEQUENCE = bytearray.fromhex('1B 1B 1B 1B 1A') +UNITS = { # Blue book @ http://www.dlms.com/documentation/overviewexcerptsofthedlmsuacolouredbooks/index.html + 1: 'a', 2: 'mo', 3: 'wk', 4: 'd', 5: 'h', 6: 'min.', 7: 's', 8: '°', 9: '°C', 10: 'currency', + 11: 'm', 12: 'm/s', 13: 'm³', 14: 'm³', 15: 'm³/h', 16: 'm³/h', 17: 'm³/d', 18: 'm³/d', 19: 'l', 20: 'kg', + 21: 'N', 22: 'Nm', 23: 'Pa', 24: 'bar', 25: 'J', 26: 'J/h', 27: 'W', 28: 'VA', 29: 'var', 30: 'Wh', + 31: 'WAh', 32: 'varh', 33: 'A', 34: 'C', 35: 'V', 36: 'V/m', 37: 'F', 38: 'Ω', 39: 'Ωm²/h', 40: 'Wb', + 41: 'T', 42: 'A/m', 43: 'H', 44: 'Hz', 45: 'Rac', 46: 'Rre', 47: 'Rap', 48: 'V²h', 49: 'A²h', 50: 'kg/s', + 51: 'Smho' + } SML_SCHEDULER_NAME = 'Smlx' - class Smlx(SmartPlugin): """ Main class of the Plugin. Does all plugin specific stuff and provides the update functions for the items """ - PLUGIN_VERSION = '1.1.6' + PLUGIN_VERSION = '1.1.7' - _units = { # Blue book @ http://www.dlms.com/documentation/overviewexcerptsofthedlmsuacolouredbooks/index.html - 1 : 'a', 2 : 'mo', 3 : 'wk', 4 : 'd', 5 : 'h', 6 : 'min.', 7 : 's', 8 : '°', 9 : '°C', 10 : 'currency', - 11 : 'm', 12 : 'm/s', 13 : 'm³', 14 : 'm³', 15 : 'm³/h', 16 : 'm³/h', 17 : 'm³/d', 18 : 'm³/d', 19 : 'l', 20 : 'kg', - 21 : 'N', 22 : 'Nm', 23 : 'Pa', 24 : 'bar', 25 : 'J', 26 : 'J/h', 27 : 'W', 28 : 'VA', 29 : 'var', 30 : 'Wh', - 31 : 'WAh', 32 : 'varh', 33 : 'A', 34 : 'C', 35 : 'V', 36 : 'V/m', 37 : 'F', 38 : 'Ω', 39 : 'Ωm²/h', 40 : 'Wb', - 41 : 'T', 42 : 'A/m', 43 : 'H', 44 : 'Hz', 45 : 'Rac', 46 : 'Rre', 47 : 'Rap', 48 : 'V²h', 49 : 'A²h', 50 : 'kg/s', - 51 : 'Smho' - } # Lookup table for smartmeter names to data format _devices = { - 'smart-meter-gateway-com-1' : 'hex' - } + 'smart-meter-gateway-com-1' : 'hex' + } def __init__(self, sh): """ @@ -116,14 +71,13 @@ def __init__(self, sh): super().__init__() self.cycle = self.get_parameter_value('cycle') - - self.host = self.get_parameter_value('host') # None - self.port = self.get_parameter_value('port') # 0 - self.serialport = self.get_parameter_value('serialport') # None - device = self.get_parameter_value('device') # raw - self.timeout = self.get_parameter_value('timeout') # 5 - self.buffersize = self.get_parameter_value('buffersize') # 1024 - self.date_offset = self.get_parameter_value('date_offset') # 0 + self.host = self.get_parameter_value('host') # None + self.port = self.get_parameter_value('port') # 0 + self.serialport = self.get_parameter_value('serialport') # None + device = self.get_parameter_value('device') # raw + self.timeout = self.get_parameter_value('timeout') # 5 + self.buffersize = self.get_parameter_value('buffersize') # 1024 + self.date_offset = self.get_parameter_value('date_offset') # 0 # Get base values for CRC calculation self.poly = self.get_parameter_value('poly') # 0x1021 @@ -139,11 +93,10 @@ def __init__(self, sh): self._sock = None self._target = None self._dataoffset = 0 - self._cyclic_update_active = False self._items = {} self._item_dict = {} self._lock = threading.Lock() - self.logger = logging.getLogger(__name__) + self._parse_lock = threading.Lock() if device in self._devices: device = self._devices[device] @@ -155,7 +108,9 @@ def __init__(self, sh): else: self.logger.warning(f"Device type \"{device}\" not supported - defaulting to \"raw\"") self._prepare = self._prepareRaw + self.logger.debug(f"Using CRC params poly={self.poly}, reflect_in={self.reflect_in}, xor_in={self.xor_in}, reflect_out={self.reflect_out}, xor_out={self.xor_out}, swap_crc_bytes={self.swap_crc_bytes}") + self.init_webinterface(WebInterface) def run(self): @@ -194,7 +149,7 @@ def parse_item(self, item): self._items[obis][prop] = [] self._items[obis][prop].append(item) self._item_dict[item] = (obis, prop) - self.logger.debug(f'Attach {item.id()} with {obis=} and {prop=}') + self.logger.debug(f'Attach {item.id()} with obis={obis} and prop={prop}') return None def parse_logic(self, logic): @@ -290,101 +245,92 @@ def poll_device(self): """ # check if another cyclic cmd run is still active - if self._cyclic_update_active: - self.logger.warning('Triggered cyclic poll_device, but previous cyclic run is still active. Therefore request will be skipped.') - return - - # set lock - self._cyclic_update_active = True - - self.logger.debug('Polling Smartmeter now') - start_sequence = bytearray.fromhex('1B 1B 1B 1B 01 01 01 01') - end_sequence = bytearray.fromhex('1B 1B 1B 1B 1A') - - self.connect() + if self._parse_lock.acquire(timeout=1): + try: + self.logger.debug('Polling Smartmeter now') - if not self.connected: - self.logger.error('Not connected, no query possible') - return - else: - self.logger.debug('Connected, try to query') + self.connect() + if not self.connected: + self.logger.error('Not connected, no query possible') + return + else: + self.logger.debug('Connected, try to query') - start = time.time() - data_is_valid = False - try: - data = self._read(self.buffersize) - if len(data) == 0: - self.logger.error('Reading data from device returned 0 bytes!') - return - else: - self.logger.debug(f'Read {len(data)} bytes') - - if start_sequence in data: - prev, _, data = data.partition(start_sequence) - self.logger.debug('Start sequence marker {} found'.format(''.join(' {:02x}'.format(x) for x in start_sequence))) - if end_sequence in data: - data, _, rest = data.partition(end_sequence) - self.logger.debug('End sequence marker {} found'.format(''.join(' {:02x}'.format(x) for x in end_sequence))) - self.logger.debug(f'Packet size is {len(data)}') - if len(rest) > 3: - filler = rest[0] - self.logger.debug(f'{filler} fill byte(s) ') - checksum = int.from_bytes(rest[1:3], byteorder='little') - self.logger.debug(f'Checksum is {to_Hex(checksum)}') - buffer = bytearray() - buffer += start_sequence + data + end_sequence + rest[0:1] - self.logger.debug(f'Buffer length is {len(buffer)}') - self.logger.debug('Buffer: {}'.format(''.join(' {:02x}'.format(x) for x in buffer))) - crc16 = algorithms.Crc(width=16, poly=self.poly, - reflect_in=self.reflect_in, xor_in=self.xor_in, - reflect_out=self.reflect_out, xor_out=self.xor_out) - crc_calculated = crc16.table_driven(buffer) - if not self.swap_crc_bytes: - self.logger.debug(f'Calculated checksum is {to_Hex(crc_calculated)}, given CRC is {to_Hex(checksum)}') - data_is_valid = crc_calculated == checksum + start = time.time() + data_is_valid = False + try: + data = self._read(self.buffersize) + if len(data) == 0: + self.logger.error('Reading data from device returned 0 bytes!') + return + else: + self.logger.debug(f'Read {len(data)} bytes') + + if START_SEQUENCE in data: + prev, _, data = data.partition(START_SEQUENCE) + self.logger.debug('Start sequence marker {} found'.format(''.join(' {:02x}'.format(x) for x in START_SEQUENCE))) + if END_SEQUENCE in data: + data, _, rest = data.partition(END_SEQUENCE) + self.logger.debug('End sequence marker {} found'.format(''.join(' {:02x}'.format(x) for x in END_SEQUENCE))) + self.logger.debug(f'Packet size is {len(data)}') + if len(rest) > 3: + filler = rest[0] + self.logger.debug(f'{filler} fill byte(s) ') + checksum = int.from_bytes(rest[1:3], byteorder='little') + self.logger.debug(f'Checksum is {to_Hex(checksum)}') + buffer = bytearray() + buffer += START_SEQUENCE + data + END_SEQUENCE + rest[0:1] + self.logger.debug(f'Buffer length is {len(buffer)}') + self.logger.debug('Buffer: {}'.format(''.join(' {:02x}'.format(x) for x in buffer))) + crc16 = algorithms.Crc(width=16, poly=self.poly, reflect_in=self.reflect_in, xor_in=self.xor_in, reflect_out=self.reflect_out, xor_out=self.xor_out) + crc_calculated = crc16.table_driven(buffer) + if not self.swap_crc_bytes: + self.logger.debug(f'Calculated checksum is {to_Hex(crc_calculated)}, given CRC is {to_Hex(checksum)}') + data_is_valid = crc_calculated == checksum + else: + self.logger.debug(f'Calculated and swapped checksum is {to_Hex(swap16(crc_calculated))}, given CRC is {to_Hex(checksum)}') + data_is_valid = swap16(crc_calculated) == checksum + else: + self.logger.debug('Not enough bytes read at end to satisfy checksum calculation') + return else: - self.logger.debug(f'Calculated and swapped checksum is {to_Hex(swap16(crc_calculated))}, given CRC is {to_Hex(checksum)}') - data_is_valid = swap16(crc_calculated) == checksum + self.logger.debug('No End sequence marker found in data') else: - self.logger.debug('Not enough bytes read at end to satisfy checksum calculation') - return + self.logger.debug('No Start sequence marker found in data') + except Exception as e: + self.logger.error(f'Reading data from {self._target} failed with exception {e}') + return + + if data_is_valid: + self.logger.debug("Checksum was ok, now parse the data_package") + try: + values = self._parse(self._prepare(data)) + except Exception as e: + self.logger.error(f'Preparing and parsing data failed with exception {e}') + else: + for obis in values: + self.logger.debug(f'Entry {values[obis]}') + + if obis in self._items: + for prop in self._items[obis]: + for item in self._items[obis][prop]: + try: + value = values[obis][prop] + except Exception: + pass + else: + item(value, self.get_shortname()) else: - self.logger.debug('No End sequence marker found in data') - else: - self.logger.debug('No Start sequence marker found in data') - except Exception as e: - self.logger.error(f'Reading data from {self._target} failed with exception {e}') - return - - if data_is_valid: - self.logger.debug("Checksum was ok, now parse the data_package") - try: - values = self._parse(self._prepare(data)) - except Exception as e: - self.logger.error(f'Preparing and parsing data failed with exception {e}') - else: - for obis in values: - self.logger.debug(f'Entry {values[obis]}') - - if obis in self._items: - for prop in self._items[obis]: - for item in self._items[obis][prop]: - try: - value = values[obis][prop] - except Exception: - pass - else: - item(value, self.get_shortname()) - else: - self.logger.debug("Checksum was not ok, will not parse the data_package") - - cycletime = time.time() - start + self.logger.debug("Checksum was not ok, will not parse the data_package") - self.disconnect() - self.logger.debug(f"Polling Smartmeter done. Poll cycle took {cycletime} seconds.") + cycletime = time.time() - start - # release lock - self._cyclic_update_active = False + self.logger.debug(f"Polling Smartmeter done. Poll cycle took {cycletime} seconds.") + finally: + self.disconnect() + self._parse_lock.release() + else: + self.logger.warning('Triggered poll_device, but could not acquire lock. Request will be skipped.') def _parse(self, data): # Search SML List Entry sequences like: @@ -434,7 +380,7 @@ def _parse(self, data): # Add additional calculated fields entry['obis'] = f"{entry['objName'][0]}-{entry['objName'][1]}:{entry['objName'][2]}.{entry['objName'][3]}.{entry['objName'][4]}*{entry['objName'][5]}" entry['valueReal'] = round(entry['value'] * 10 ** entry['scaler'], 1) if entry['scaler'] is not None else entry['value'] - entry['unitName'] = self._units[entry['unit']] if entry['unit'] is not None and entry['unit'] in self._units else None + entry['unitName'] = UNITS[entry['unit']] if entry['unit'] is not None and entry['unit'] in UNITS else None entry['actualTime'] = time.ctime(self.date_offset + entry['valTime'][1]) if entry['valTime'] is not None else None # Decodes valTime into date/time string # For a Holley DTZ541 with faulty Firmware remove the ^[1] from this line ^. @@ -522,7 +468,7 @@ def _prepareRaw(self, data): return data def _prepareHex(self, data): - data = data.decode("iso-8859-1").lower(); + data = data.decode("iso-8859-1").lower() data = re.sub("[^a-f0-9]", " ", data) data = re.sub("( +[a-f0-9]|[a-f0-9] +)", "", data) data = data.encode() @@ -535,3 +481,34 @@ def item_list(self): @property def log_level(self): return self.logger.getEffectiveLevel() + +########################################################## +# Helper Functions +########################################################## + + +def to_Hex(data): + """ + Returns the hex representation of the given data + """ + # try: + # return data.hex() + # except: + # return "".join("%02x " % b for b in data).rstrip() + # logger.debug("Hextype: {}".format(type(data))) + if isinstance(data, int): + return hex(data) + + return "".join("%02x " % b for b in data).rstrip() + + +def swap16(x): + return (((x << 8) & 0xFF00) | + ((x >> 8) & 0x00FF)) + + +def swap32(x): + return (((x << 24) & 0xFF000000) | + ((x << 8) & 0x00FF0000) | + ((x >> 8) & 0x0000FF00) | + ((x >> 24) & 0x000000FF)) diff --git a/smlx/plugin.yaml b/smlx/plugin.yaml index ba2f8d86b..6767ef5b5 100755 --- a/smlx/plugin.yaml +++ b/smlx/plugin.yaml @@ -12,9 +12,11 @@ plugin: documentation: https://www.smarthomeng.de/developer/plugins/smlx/user_doc.html support: https://knx-user-forum.de/forum/supportforen/smarthome-py/39119-sml-plugin-datenblock-größenfehler restartable: True - version: 1.1.6 # Plugin version + version: 1.1.7 # Plugin version sh_minversion: 1.4.2 # minimum shNG version to use this plugin - #sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) +# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) +# py_minversion: 3.6 # minimum Python version to use for this plugin +# py_maxversion: # maximum Python version to use for this plugin (leave empty if latest) multi_instance: True # plugin supports multi instance classname: Smlx # class containing the plugin diff --git a/solarforecast/__init__.py b/solarforecast/__init__.py index bccbce5c0..7350f3523 100755 --- a/solarforecast/__init__.py +++ b/solarforecast/__init__.py @@ -24,13 +24,15 @@ from lib.model.smartplugin import * from lib.item import Items +from .webif import WebInterface + import requests import json import datetime class Solarforecast(SmartPlugin): - PLUGIN_VERSION = '1.9.0' + PLUGIN_VERSION = '1.9.1' def __init__(self, sh, *args, **kwargs): """ @@ -52,6 +54,7 @@ def __init__(self, sh, *args, **kwargs): self.azimuth = self.get_parameter_value('azimuth') self.kwp = self.get_parameter_value('kwp') self.service = self.get_parameter_value('service') + self.webif_pagelength = self.get_parameter_value('webif_pagelength') if self.latitude is None or \ self.longitude is None or \ @@ -64,7 +67,7 @@ def __init__(self, sh, *args, **kwargs): self.logger.error(f"Service {self.service} is not supported yet.") self.logger.debug("Init completed.") - self.init_webinterface() + self.init_webinterface(WebInterface) self._items = {} return @@ -109,11 +112,16 @@ def poll_backend(self): #self.logger.debug(f"DEBUG URL: {urlService + functionURL}") - sessionrequest_response = self.session.get( - urlService + functionURL, - headers={'content-type': 'application/json'}, timeout=10, verify=False) + try: + sessionrequest_response = self.session.get( + urlService + functionURL, + headers={'content-type': 'application/json'}, timeout=10, verify=False) -# self.logger.debug(f"Session request response: {sessionrequest_response.text}") +# self.logger.debug(f"Session request response: {sessionrequest_response.text}") + except Exception as e: + self.logger.error(f"Exception during get command: {str(e)}") + return + statusCode = sessionrequest_response.status_code if statusCode == 200: pass @@ -173,124 +181,8 @@ def poll_backend(self): def get_items(self): return self._items - def init_webinterface(self): - """" - Initialize the web interface for this plugin - - This method is only needed if the plugin is implementing a web interface - """ - try: - self.mod_http = Modules.get_instance().get_module( - 'http') # try/except to handle running in a core version that does not support modules - except: - self.mod_http = None - if self.mod_http == None: - self.logger.error("Not initializing the web interface") - return False - - import sys - if not "SmartPluginWebIf" in list(sys.modules['lib.model.smartplugin'].__dict__): - self.logger.warning("Web interface needs SmartHomeNG v1.5 and up. Not initializing the web interface") - return False - - # set application configuration for cherrypy - webif_dir = self.path_join(self.get_plugin_dir(), 'webif') - config = { - '/': { - 'tools.staticdir.root': webif_dir, - }, - '/static': { - 'tools.staticdir.on': True, - 'tools.staticdir.dir': 'static' - } - } - - # Register the web interface as a cherrypy app - self.mod_http.register_webif(WebInterface(webif_dir, self), - self.get_shortname(), - config, - self.get_classname(), self.get_instance_name(), - description='') - - return True - - -# ------------------------------------------ -# Webinterface of the plugin -# ------------------------------------------ - -import cherrypy -from jinja2 import Environment, FileSystemLoader - - -class WebInterface(SmartPluginWebIf): - - def __init__(self, webif_dir, plugin): - """ - Initialization of instance of class WebInterface - - :param webif_dir: directory where the webinterface of the plugin resides - :param plugin: instance of the plugin - :type webif_dir: str - :type plugin: object - """ - self.logger = logging.getLogger(__name__) - self.webif_dir = webif_dir - self.plugin = plugin - self.tplenv = self.init_template_environment() - - self.items = Items.get_instance() - - @cherrypy.expose - def index(self, reload=None, action=None, email=None, hashInput=None, code=None, tokenInput=None, mapIDInput=None): - """ - Build index.html for cherrypy - - Render the template and return the html file to be delivered to the browser - - :return: contents of the template after beeing rendered - """ - calculatedHash = '' - codeRequestSuccessfull = None - token = '' - configWriteSuccessfull = None - resetAlarmsSuccessfull = None - boundaryListSuccessfull = None - - - if action is not None: - self.logger.error("Unknown command received via webinterface") - tmpl = self.tplenv.get_template('index.html') - # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) - return tmpl.render(p=self.plugin, - items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path']))) - @cherrypy.expose - def get_data_html(self, dataSet=None): - """ - Return data to update the webpage - - For the standard update mechanism of the web interface, the dataSet to return the data for is None - - :param dataSet: Dataset for which the data should be returned (standard: None) - :return: dict with the data needed to update the web page. - """ - if dataSet is None: - # get the new data - data = {} - - # data['item'] = {} - # for i in self.plugin.items: - # data['item'][i]['value'] = self.plugin.getitemvalue(i) - # - # return it as json the the web page - # try: - # return json.dumps(data) - # except Exception as e: - # self.logger.error("get_data_html exception: {}".format(e)) - return {} - diff --git a/solarforecast/plugin.yaml b/solarforecast/plugin.yaml index 12e5eef6b..3658ecac8 100755 --- a/solarforecast/plugin.yaml +++ b/solarforecast/plugin.yaml @@ -6,13 +6,13 @@ plugin: de: 'Plugin zur Anbindung an eine Web-basierte Solaretragsvorhersage' en: 'Plugin to connect to a web-based solar forecast service' maintainer: Alexander Schwithal (aschwith) - tester: aschwith + tester: henfri state: develop # change to ready when done with development keywords: solar.forecast, solar -# documentation: https://github.com/smarthomeng/plugins/blob/develop/solarforecast/README.md -# support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1417295-support-thread-plugin-solarforecast +# documentation: + support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1842817-support-thread-f%C3%BCr-das-solarforecast-plugin - version: 1.9.0 # Plugin version + version: 1.9.1 # Plugin version sh_minversion: 1.8.0 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance @@ -63,6 +63,7 @@ parameters: de: 'Webservice fuer Vorhersage' en: 'Webservice for forecast' + item_attributes: # Definition of item attributes defined by this plugin (enter 'item_attributes: NONE', if section should be empty) solarforecast_attribute: @@ -89,7 +90,7 @@ item_structs: NONE #item_structs: NONE plugin_functions: NONE - + logic_parameters: NONE # Definition of logic parameters defined by this plugin (enter 'logic_parameters: NONE', if section should be empty) diff --git a/solarforecast/user_doc.rst b/solarforecast/user_doc.rst index 7c3b95a80..26100f5c5 100755 --- a/solarforecast/user_doc.rst +++ b/solarforecast/user_doc.rst @@ -5,6 +5,13 @@ solarforecast ============= +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + Dieses Plugin unterstützt Solare.forecast Vorhersagen von Solaretrag (Leistung). Für weitere Informationen empfiehlt sich die Lektüre der offiziellen @@ -56,11 +63,6 @@ Web Interface Das solarforecast Plugin verfügt über ein Webinterface. -.. important:: - - Das Webinterface des Plugins kann mit SmartHomeNG v1.4.2 und davor **nicht** genutzt werden. - Es wird dann nicht geladen. Diese Einschränkung gilt nur für das Webinterface. Ansonsten gilt - für das Plugin die in den Metadaten angegebene minimale SmartHomeNG Version. Aufruf des Webinterfaces diff --git a/solarforecast/webif/__init__.py b/solarforecast/webif/__init__.py new file mode 100755 index 000000000..cad31b9d8 --- /dev/null +++ b/solarforecast/webif/__init__.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2020- +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.5 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import datetime +import time +import os +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + tmpl = self.tplenv.get_template('index.html') + # try to get the webif pagelength from the module.yaml configuration + global_pagelength = cherrypy.config.get("webif_pagelength") + if global_pagelength: + pagelength = global_pagelength + self.logger.debug("Global pagelength {}".format(pagelength)) + # try to get the webif pagelength from the plugin specific plugin.yaml configuration + try: + pagelength = self.plugin.webif_pagelength + self.logger.debug("Plugin pagelength {}".format(pagelength)) + except Exception: + pass + # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, + items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path'])), + item_count=0) + + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + # if dataSets are used, define them here + if dataSet == 'overview': + # get the new data from the plugin variable called _webdata + data = self.plugin._webdata + try: + data = json.dumps(data) + return data + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") + if dataSet is None: + # get the new data + data = {} + + # data['item'] = {} + # for i in self.plugin.items: + # data['item'][i]['value'] = self.plugin.getitemvalue(i) + # + # return it as json the the web page + # try: + # return json.dumps(data) + # except Exception as e: + # self.logger.error("get_data_html exception: {}".format(e)) + return {} diff --git a/solarforecast/webif/static/img/plugin_logo.png b/solarforecast/webif/static/img/plugin_logo.png new file mode 100755 index 000000000..c3ad05a45 Binary files /dev/null and b/solarforecast/webif/static/img/plugin_logo.png differ diff --git a/solarlog/user_doc.rst b/solarlog/user_doc.rst index c1384468c..82deb500c 100755 --- a/solarlog/user_doc.rst +++ b/solarlog/user_doc.rst @@ -1,4 +1,9 @@ -Solarlog + +.. index:: Plugins; solarlog +.. index:: solarlog + +======== +solarlog ======== Dieses Plugin kann eine Webseite vom SolarLog-Protokolliergerät lesen und Werte zurückgeben. @@ -7,20 +12,24 @@ Es wurde 2017 von klab für SolarLog-Geräte mit Firmware >= 3.x neu geschrieben Christian Michels in das alte Plugin integriert. Requirements ------------- +============ Dieses Plugin hat keine Anforderungen oder Abhängigkeiten. Todo ----- +==== Webinterface mit den geparsten Daten aufbereiten Konfiguration -------------- +============= + +Diese Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/solarlog` beschrieben. + plugin.yaml -~~~~~~~~~~~ +----------- .. code-block:: yaml :caption: logic.yaml @@ -30,14 +39,14 @@ plugin.yaml host: http://solarlog.fritz.box/ Attribute -^^^^^^^^^ +~~~~~~~~~ - ``fw2x``: Gibt an, ob Firmware <= 2.x ist. - ``host``: Gibt den Hostnamen des SolarLog an. - ``cycle``: Bestimmt den Zyklus für die Abfrage des SolarLog. items.yaml -~~~~~~~~~~ +---------- Die Format Details des SolarLog müssen bekannt sein, um die gültigen Werte für dieses Plugin zu definieren. Das Plugin fordert lediglich die JavaScript-Dateien vom Gerät an und analysiert sie. @@ -46,12 +55,12 @@ Eine Beschreibung des Formats und der entsprechenden Variablen findet sich hier: https://www.photonensammler.de/wiki/doku.php?id=solarlog_datenformat solarlog -^^^^^^^^ +~~~~~~~~ Dies ist das einzige Attribut für Items. Um Werte aus dem SolarLog-Datenformat abzurufen, müssen lediglich die Variablennamen wie auf der oben beschriebenen Site verwendet werden. -Wenn Werte aus einer Array-Struktur wie den PDC-Wert aus dem Sekundenstring des ersten Inverters verwendet +Wenn Werte aus einer Array-Struktur wie den PDC-Wert aus dem Sekundenstring des ersten Inverters verwendet werden soll, muss der Variablenname underscore inverter-1 underscore string-1 verwendet werden: ``var [\_ inverter [\_ string]]`` @@ -154,11 +163,11 @@ Das ``database: yes`` impliziert, dass auch ein Datenbank-Plugin konfiguriert is Dienst zur Anzeige von Messwerten innerhalb einer Visu. logic.yaml -~~~~~~~~~~ +---------- Derzeit gibt es keine Logik Konfiguration für dieses Plugin. Funktionen ----------- +========== Momentan werden von diesem Plugin keine Funktionen bereitgestellt. diff --git a/sonos/README.md b/sonos/README.md index 9386170ff..a1e6e06ce 100755 --- a/sonos/README.md +++ b/sonos/README.md @@ -409,6 +409,17 @@ after the url was added to the Sonos speaker. If you set this item to ```True``` immediately, ```False``` otherwise. (see example item configuration). You can omit this child item, the default setting is 'True'. This is a group command and effects all speakers in the group. +#### play_sharelink +```write``` + +Plays a given sharelink, e.g. a Spotify sharelink. You need a Spotify premium account to play links. The free account does not support sharelinks. + +_child item_ ```start_after```: +If you add an child item (type ```bool```) with an attribute ```sonos_attrib: start_after``` you can control the behaviour +after the sharelink was added to the Sonos speaker. If you set this item to ```True```, the speaker starts playing +immediately, ```False``` otherwise. (see example item configuration). You can omit this child item, the default +setting is 'True'. This is a group command and effects all speakers in the group. + #### previous ```write``` ```visu``` diff --git a/sonos/__init__.py b/sonos/__init__.py index 41b1eba22..3b36f3a8e 100755 --- a/sonos/__init__.py +++ b/sonos/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab ######################################################################### -# Copyright 2016 pfischi # +# Copyright 2016- pfischi, aschwith, sisamiwe # ######################################################################### # This file is part of SmartHomeNG. # @@ -31,50 +31,52 @@ import socketserver import subprocess import threading +import time +import sys +import requests + +from requests.utils import quote from collections import OrderedDict from http.server import HTTPServer, BaseHTTPRequestHandler from queue import Empty -import sys from urllib.parse import unquote -try: - import requests - import xmltodict - from requests.utils import quote - from tinytag import TinyTag - REQUIRED_PACKAGE_IMPORTED = True -except: - REQUIRED_PACKAGE_IMPORTED = False - -from plugins.sonos.soco.exceptions import SoCoUPnPException -from plugins.sonos.soco.music_services import MusicService -from lib.item import Items -from lib.module import Modules -from plugins.sonos.soco import * -from lib.model.smartplugin import SmartPlugin -from lib.model.smartplugin import SmartPluginWebIf -#from lib.model.smartplugin import * - +from . import utils -from plugins.sonos.soco.data_structures import to_didl_string, DidlItem, DidlMusicTrack -from plugins.sonos.soco.events import event_listener -from plugins.sonos.soco.music_services.data_structures import get_class -from plugins.sonos.soco.snapshot import Snapshot -from plugins.sonos.soco.xml import XML -import time +from .soco import * +from .soco.exceptions import SoCoUPnPException +from .soco.music_services import MusicService +from .soco.data_structures import to_didl_string, DidlItem, DidlMusicTrack +from .soco.events import event_listener +from .soco.music_services.data_structures import get_class +from .soco.snapshot import Snapshot +from .soco.xml import XML +from .soco.plugins.sharelink import ShareLinkPlugin +import xmltodict +from tinytag import TinyTag from gtts import gTTS -from plugins.sonos.utils import file_size, get_tts_local_file_path, get_free_diskspace, get_folder_size -_create_speaker_lock = threading.Lock() # make speaker object creation thread-safe -sonos_speaker = {} +from lib.model.smartplugin import SmartPlugin +from lib.item import Items + +from .webif import WebInterface + +_create_speaker_lock = threading.Lock() # make speaker object creation thread-safe +sonos_speaker = {} # dict to hold all speaker information with soco objects + +# Planned Enhancement +###################### +# ToDo: Itemattribute einführen, dass immer mit dem abgespielten Inhalt gefüllt wird +# ToDo: Methode implementieren, die TTS auf allen verfügbaren Speakern abspielt "play_tts_all" / PartyMode +# ToDo: Operating on All Speakers: Using _all_ as Speaker name class WebserviceHttpHandler(BaseHTTPRequestHandler): webroot = None def __init__(self, request, client_address, server): - self.logger = logging.getLogger('sonos') # get a unique logger for the plugin and provide it internally + self.logger = logging.getLogger('Sonos-WebserviceHttpHandler') # get a unique logger for the plugin and provide it internally super().__init__(request, client_address, server) def _get_mime_type_by_filetype(self, file_path): @@ -85,7 +87,7 @@ def _get_mime_type_by_filetype(self, file_path): "audio/mpeg": "mp3", "audio/ogg": "ogg", "audio/wav": "wav", - } + } filename, extension = os.path.splitext(file_path) extension = extension.strip('.').lower() @@ -95,8 +97,8 @@ def _get_mime_type_by_filetype(self, file_path): return mime_type raise Exception(f"Cannot determine mime-type for extension '{extension}'.") - except Exception as err: - self.logger.warning(f"Exception in _get_mime_type_by_filetype: {err}") + except Exception as e: + self.logger.warning(f"Exception in _get_mime_type_by_filetype: {e}") return None def do_GET(self): @@ -124,9 +126,8 @@ def do_GET(self): self.send_error(406, 'File with unsupported media type : %s' % self.path) return - client = "{ip}:{port}".format(ip=self.client_address[0], port=self.client_address[1]) - self.logger.debug("Webservice: delivering file '{path}' to client ip {client}.".format(path=file_path, - client=client)) + client = f"{self.client_address[0]}:{self.client_address[1]}" + self.logger.debug(f"Webservice: delivering file '{file_path}' to client ip {client}.") file = open(file_path, 'rb').read() self.send_response(200) self.send_header('Content-Type', mime_type) @@ -135,9 +136,18 @@ def do_GET(self): self.wfile.write(file) except ConnectionResetError: self.logger.debug("Connection reset by partner") + except IOError as ex: + if ex.errno == errno.EPIPE: + # EPIPE error + self.logger.error(f"EPipe exception occurred while delivering file {file_path}") + self.logger.error(f"Exception: {ex}") + else: + # Other error + self.logger.error(f"Error delivering file {file_path}") + self.logger.error(f"Exception: {ex}") except Exception as ex: self.logger.error(f"Error delivering file {file_path}") - self.logger.error(f"Exception: {ex}") + self.logger.error(f"do_GET: Exception: {ex}") finally: self.connection.close() @@ -169,14 +179,12 @@ def stop(self): self.waitForThread() -def renew_error_callback(exception): # events_twisted: failure - msg = 'Error received on autorenew: {}'.format(str(exception)) - # Redundant, as the exception will be logged by the events module - self.logger.error(msg) +def renew_error_callback(exception): # events_twisted: failure + msg = f'Error received on autorenew: {exception}' + # Redundant, as the exception will be logged by the events module + self.logger.error(msg) - # ToDo possible improvement: - # Do not do periodic renew but do prober disposal on renew failure here instead. - # sub.renew(requested_timeout=10) + # ToDo possible improvement: Do not do periodic renew but do prober disposal on renew failure here instead. sub.renew(requested_timeout=10) class SubscriptionHandler(object): @@ -191,14 +199,14 @@ def __init__(self, endpoint, service, logger, threadName): self._threadName = threadName def subscribe(self): - self.logger.info("Debug: start subscribe for endpoint {0}".format(self._endpoint)) + self.logger.debug(f"start subscribe for endpoint {self._endpoint}") with self._lock: self._signal = threading.Event() try: -# self._event = self._service.subscribe(auto_renew=True) + # self._event = self._service.subscribe(auto_renew=True) self._event = self._service.subscribe(auto_renew=False) - except Exception as err: - self.logger.warning("Exception in subscribe(): {err}".format(err=err)) + except Exception as e: + self.logger.warning(f"Exception in subscribe(): {e}") if self._event: self._event.auto_renew_fail = renew_error_callback self._thread = threading.Thread(target=self._endpoint, name=self._threadName, args=(self,)) @@ -206,25 +214,23 @@ def subscribe(self): self._thread.start() def unsubscribe(self): - self.logger.info("Debug: start unsubscribe for endpoint {0}".format(self._endpoint)) + self.logger.debug(f"start unsubscribe for endpoint {self._endpoint}") with self._lock: if self._event: # try to unsubscribe first try: self._event.unsubscribe() - except Exception as err: - self.logger.warning("Exception in unsubscribe(): {err}".format(err=err)) + except Exception as e: + self.logger.warning(f"Exception in unsubscribe(): {e}") self._signal.set() if self._thread: - self.logger.info("Debug: Preparing to terminate thread") + self.logger.debug("Preparing to terminate thread") self._thread.join(2) if not self._thread.is_alive(): - self.logger.info("Debug: Thread killed") + self.logger.debug("Thread killed") else: - self.logger.warning("Debug: Thread is still alive") - - self.logger.info("Event {event} unsubscribed and thread terminated".format( - event=self._endpoint)) + self.logger.warning("Thread is still alive") + self.logger.info(f"Event {self._endpoint} unsubscribed and thread terminated") @property def signal(self): @@ -246,8 +252,9 @@ def is_subscribed(self): class Speaker(object): - def __init__(self, uid, logger): + def __init__(self, uid, logger, plugin_shortname): self.logger = logger + self.plugin_shortname = plugin_shortname self.uid_items = [] self._uid = "" self._soco = None @@ -322,6 +329,8 @@ def __init__(self, uid, logger): self._stream_content = "" self.stream_content_items = [] self.sonos_playlists_items = [] + self.sonos_favorites_items = [] + self.favorite_radio_stations_items = [] self._is_initialized = False self.is_initialized_items = [] self._snippet_queue_lock = threading.Lock() @@ -340,7 +349,7 @@ def uid(self): def uid(self, value): self._uid = value for item in self.uid_items: - item(self.uid, 'Sonos') + item(self.uid, self.plugin_shortname) @property def soco(self): @@ -355,26 +364,26 @@ def soco(self, value): self.uid = self.soco.uid.lower() self.household_id = self.soco.household_id - #self.logger.info("Debug: uid: {uid}: soco set to {value}".format(uid=self.uid, value=value)) + # self.logger.debug(f"uid: {self.uid}: soco set to {value}") if self._soco: self.render_subscription = \ SubscriptionHandler(endpoint=self._rendering_control_event, service=self._soco.renderingControl, - logger=self.logger, threadName="sonos_{uid}_eventRenderingControl".format(uid=self.uid)) + logger=self.logger, threadName=f"sonos_{self.uid}_eventRenderingControl") self.av_subscription = \ SubscriptionHandler(endpoint=self._av_transport_event, service=self._soco.avTransport, - logger=self.logger, threadName="sonos_{uid}_eventAvTransport".format(uid=self.uid)) + logger=self.logger, threadName=f"sonos_{self.uid}_eventAvTransport") self.system_subscription = \ SubscriptionHandler(endpoint=self._system_properties_event, service=self._soco.systemProperties, - logger=self.logger, threadName="sonos_{uid}_eventSystemProperties".format(uid=self.uid)) + logger=self.logger, threadName=f"sonos_{self.uid}_eventSystemProperties") self.zone_subscription = \ SubscriptionHandler(endpoint=self._zone_topology_event, service=self._soco.zoneGroupTopology, - logger=self.logger, threadName="sonos_{uid}_eventZoneTopology".format(uid=self.uid)) + logger=self.logger, threadName=f"sonos_{self.uid}_eventZoneTopology") self.alarm_subscription = \ SubscriptionHandler(endpoint=self._alarm_event, service=self._soco.alarmClock, - logger=self.logger, threadName="sonos_{uid}_eventAlarmEvent".format(uid=self.uid)) + logger=self.logger, threadName=f"sonos_{self.uid}_eventAlarmEvent") self.device_subscription = \ SubscriptionHandler(endpoint=self._device_properties_event, service=self._soco.deviceProperties, - logger=self.logger, threadName="sonos_{uid}_eventDeviceProperties".format(uid=self.uid)) + logger=self.logger, threadName=f"sonos_{self.uid}_eventDeviceProperties") # just to have a list for disposing all events self._events = [ @@ -390,7 +399,7 @@ def dispose(self): """ clean-up all things here """ - self.logger.info("Debug: {uid}: disposing".format(uid=self.uid)) + self.logger.debug(f"{self.uid}: disposing") if not self._soco: return @@ -399,7 +408,7 @@ def dispose(self): try: subscription.unsubscribe() except Exception as error: - self.logger.warning("Exception in dispose(): {error}".format(error=error)) + self.logger.warning(f"Exception in dispose(): {error}") continue self._soco = None @@ -407,7 +416,7 @@ def dispose(self): def subscribe_base_events(self): if not self._soco: return - self.logger.info("Debug: Start subscribe base event fct") + self.logger.debug("Start subscribe base event fct") self.zone_subscription.unsubscribe() self.zone_subscription.subscribe() @@ -423,7 +432,6 @@ def subscribe_base_events(self): self.render_subscription.unsubscribe() self.render_subscription.subscribe() - def refresh_static_properties(self) -> None: """ This function is called by the plugins discover function. This is typically called every 180sec. @@ -439,10 +447,12 @@ def refresh_static_properties(self) -> None: self.status_light = self.get_status_light() self.buttons_enabled = self.get_buttons_enabled() self.sonos_playlists() + self.sonos_favorites() + self.favorite_radio_stations() def check_subscriptions(self) -> None: - self.logger.info("Debug: Start check_subscriptions fct") + self.logger.debug("Start check_subscriptions fct") self.zone_subscription.unsubscribe() self.zone_subscription.subscribe() @@ -463,7 +473,7 @@ def check_subscriptions(self) -> None: self.zone_subscription.unsubscribe() self.zone_subscription.subscribe() - self.logger.debug("Sonos: {uid}: Event subscriptions done".format(uid=self.uid)) + self.logger.debug(f"{self.uid}: Event subscriptions done") # Event Handler routines ########################################################################################### @@ -473,7 +483,7 @@ def _rendering_control_event(self, sub_handler: SubscriptionHandler) -> None: :param sub_handler: SubscriptionHandler for the rendering control event """ try: - self.logger.debug("Sonos: {uid}: rendering control event handler active".format(uid=self.uid)) + self.logger.debug(f"{self.uid}: rendering control event handler active") while not sub_handler.signal.wait(1): try: event = sub_handler.event.events.get(timeout=0.5) @@ -490,13 +500,13 @@ def _rendering_control_event(self, sub_handler: SubscriptionHandler) -> None: self.night_mode = event.variables['night_mode'] if 'dialog_mode' in event.variables: self.dialog_mode = event.variables['dialog_mode'] - self.logger.debug(f"Debug Sonos: {self.uid}: event variables: {event.variables}") + self.logger.debug(f"{self.uid}: event variables: {event.variables}") sub_handler.event.events.task_done() del event except Empty: pass except Exception as ex: - self.logger.error(ex) + self.logger.error(f"_rendering_control_event: Error {ex} occurred.") def _alarm_event(self, sub_handler: SubscriptionHandler) -> None: """ @@ -504,17 +514,17 @@ def _alarm_event(self, sub_handler: SubscriptionHandler) -> None: :param sub_handler: SubscriptionHandler for the alarm event """ try: - self.logger.debug("Sonos: {uid}: alarm clock event handler active".format(uid=self.uid)) + self.logger.debug(f"{self.uid}: alarm clock event handler active") while not sub_handler.signal.wait(1): try: event = sub_handler.event.events.get(timeout=0.5) - #self.logger.debug(f"Debug Sonos alarms: {self.uid}: event variables: {event.variables}") + # self.logger.debug(f"Sonos alarms: {self.uid}: event variables: {event.variables}") sub_handler.event.events.task_done() del event except Empty: pass except Exception as ex: - self.logger.error(ex) + self.logger.error(f"_alarm_event: Error {ex} occurred.") def _system_properties_event(self, sub_handler: SubscriptionHandler) -> None: """ @@ -522,17 +532,17 @@ def _system_properties_event(self, sub_handler: SubscriptionHandler) -> None: :param sub_handler: SubscriptionHandler for the system properties event """ try: - self.logger.debug("Sonos: {uid}: system properties event handler active".format(uid=self.uid)) + self.logger.debug(f"{self.uid}: system properties event handler active") while not sub_handler.signal.wait(1): try: event = sub_handler.event.events.get(timeout=0.5) - #self.logger.debug(f"Debug Sonos props: {self.uid}: event variables: {event.variables}") + # self.logger.debug(f"Sonos props: {self.uid}: event variables: {event.variables}") sub_handler.event.events.task_done() del event except Empty: pass except Exception as ex: - self.logger.error(ex) + self.logger.error(f"_system_properties_event: Error {ex} occurred.") def _device_properties_event(self, sub_handler: SubscriptionHandler) -> None: """ @@ -540,7 +550,7 @@ def _device_properties_event(self, sub_handler: SubscriptionHandler) -> None: :param sub_handler: SubscriptionHandler for the device properties event """ try: - self.logger.debug("Sonos: {uid}: device properties event handler active".format(uid=self.uid)) + self.logger.debug(f"{self.uid}: device properties event handler active") while not sub_handler.signal.wait(1): try: event = sub_handler.event.events.get(timeout=0.5) @@ -554,7 +564,7 @@ def _device_properties_event(self, sub_handler: SubscriptionHandler) -> None: except Empty: pass except Exception as ex: - self.logger.error(ex) + self.logger.error(f"_device_properties_event: Error {ex} occurred.") def _zone_topology_event(self, sub_handler: SubscriptionHandler) -> None: """ @@ -562,20 +572,20 @@ def _zone_topology_event(self, sub_handler: SubscriptionHandler) -> None: :param sub_handler: SubscriptionHandler for the zone topology event """ try: - self.logger.debug("Sonos: {uid}: topology event handler active".format(uid=self.uid)) + self.logger.debug(f"{self.uid}: topology event handler active") while not sub_handler.signal.wait(1): try: event = sub_handler.event.events.get(timeout=0.5) if 'zone_group_state' in event.variables: tree = XML.fromstring(event.variables['zone_group_state'].encode('utf-8')) - #find group where our uid is located + # find group where our uid is located for group_element in tree.find('ZoneGroups').findall('ZoneGroup'): coordinator_uid = group_element.attrib['Coordinator'].lower() zone_group_member = [] uid_found = False for member_element in group_element.findall('ZoneGroupMember'): member_uid = member_element.attrib['UUID'].lower() - _initialize_speaker(member_uid, self.logger) + _initialize_speaker(member_uid, self.logger, self.plugin_shortname) zone_group_member.append(sonos_speaker[member_uid]) if member_uid == self._uid: uid_found = True @@ -591,15 +601,17 @@ def _zone_topology_event(self, sub_handler: SubscriptionHandler) -> None: # get some other properties self.status_light = self.get_status_light() - self.buttons_enabledt = self.get_buttons_enabled() + self.buttons_enabled = self.get_buttons_enabled() self.sonos_playlists() + self.sonos_favorites() + self.favorite_radio_stations() sub_handler.event.events.task_done() del event except Empty: pass except Exception as ex: - self.logger.error(ex) + self.logger.error(f"_zone_topology_event: Error {ex} occurred.") def _av_transport_event(self, sub_handler: SubscriptionHandler) -> None: """ @@ -607,10 +619,11 @@ def _av_transport_event(self, sub_handler: SubscriptionHandler) -> None: :param sub_handler: SubscriptionHandler for the av transport event """ try: - self.logger.debug("Sonos: {uid}: av transport event handler active".format(uid=self.uid)) + self.logger.debug(f"_av_transport_event: {self.uid}: av transport event handler active.") while not sub_handler.signal.wait(1): try: event = sub_handler.event.events.get(timeout=0.5) + self._av_transport_event = event # set streaming type if self.soco.is_playing_line_in: @@ -729,9 +742,13 @@ def _av_transport_event(self, sub_handler: SubscriptionHandler) -> None: # we need the title from 'enqueued_transport_uri_meta_data' if 'enqueued_transport_uri_meta_data' in event.variables: radio_metadata = event.variables['enqueued_transport_uri_meta_data'] - if hasattr(radio_metadata, 'title'): - if self.streamtype == 'radio': - self.radio_station = str(radio_metadata.title) + if isinstance(radio_metadata, str): + radio_station = radio_metadata[radio_metadata.find('') + 10:radio_metadata.find('')] + elif hasattr(radio_metadata, 'title'): + radio_station = str(radio_metadata.title) + else: + radio_station = "" + self.radio_station = radio_station else: self.radio_station = '' @@ -740,18 +757,17 @@ def _av_transport_event(self, sub_handler: SubscriptionHandler) -> None: except Empty: pass except Exception as ex: - self.logger.error(ex) + self.logger.error(f"_av_transport_event: Error {ex} occurred.") def _check_property(self): if not self.is_initialized: - self.logger.warning(f"Speaker {self.uid} is not initialized.") + self.logger.warning(f"Speaker '{self.uid}' is not initialized.") return False if not self.coordinator: - self.logger.warning(f"Speaker {self.uid}: coordinator is empty".format(uid=self.uid)) + self.logger.warning(f"Speaker '{self.uid}': coordinator is empty") return False if self.coordinator not in sonos_speaker: - self.logger.warning("Sonos: {uid}: coordinator '{coordinator}' is not a valid speaker.".format - (uid=self.uid, coordinator=self.coordinator)) + self.logger.warning(f"{self.uid}: coordinator '{self.coordinator}' is not a valid speaker.") return False return True @@ -780,14 +796,14 @@ def _zone_group_members(self): @_zone_group_members.setter def _zone_group_members(self, value): if not isinstance(value, list): - self.logger.warning("Sonos: {uid}: value [{value]] for setter _zone_group_members must be type of list." - .format(uid=self.uid, value=value)) + self.logger.warning(f"{self.uid}: value={value} for setter _zone_group_members must be type of list.") return self._zone_group = value # set zone_group_members (string representation) members = [] for member in self._zone_group: - members.append(member.uid) + if member.uid != '': + members.append(member.uid) self.zone_group_members = members # External ######################################################################################################### @@ -813,7 +829,7 @@ def is_initialized(self, is_initialized: bool) -> None: return self._is_initialized = is_initialized for item in self.is_initialized_items: - item(self.is_initialized, 'Sonos') + item(self.is_initialized, self.plugin_shortname) @property def player_name(self) -> str: @@ -834,7 +850,7 @@ def player_name(self, player_name: str) -> None: return self._player_name = player_name for item in self.player_name_items: - item(self.player_name, 'Sonos') + item(self.player_name, self.plugin_shortname) @property def household_id(self) -> str: @@ -855,7 +871,7 @@ def household_id(self, household_id: str) -> None: return self._household_id = household_id for item in self.household_id_items: - item(self.household_id, 'Sonos') + item(self.household_id, self.plugin_shortname) @property def night_mode(self) -> bool: @@ -877,7 +893,7 @@ def night_mode(self, night_mode: bool) -> None: return self._night_mode = night_mode for item in self.night_mode_items: - item(self.night_mode, 'Sonos') + item(self.night_mode, self.plugin_shortname) def set_night_mode(self, night_mode: bool) -> bool: """ @@ -892,7 +908,7 @@ def set_night_mode(self, night_mode: bool) -> bool: self.night_mode = night_mode return True except Exception as ex: - self.logger.warning("Sonos: {uid}: can't set night mode. Not supported.".format(uid=self.uid)) + self.logger.warning(f"set_night_mode: can't set night mode for {self.uid}. Not supported. Error {ex}") return False @property @@ -915,12 +931,11 @@ def buttons_enabled(self, buttons_enabled: bool) -> None: return self._buttons_enabled = buttons_enabled for item in self.buttons_enabled_items: - item(self.buttons_enabled, 'Sonos') + item(self.buttons_enabled, self.plugin_shortname) def set_buttons_enabled(self, buttons_enabled: bool) -> bool: """ - Calls the SoCo functionality buttons_enabled to set this setting to the speaker. This mode is not available for - non visible speakers (e.g. stereo slaves). + Calls the SoCo functionality buttons_enabled to set this setting to the speaker. This mode is not available for non-visible speakers (e.g. stereo slaves). :rtype: bool :param buttons_enabled: True or False :return: 'True' if success, 'False' otherwise @@ -930,7 +945,7 @@ def set_buttons_enabled(self, buttons_enabled: bool) -> bool: self.buttons_enabled = buttons_enabled return True except Exception as ex: - self.logger.warning("Sonos: {uid}: can't set buttons enabled state. Not supported.".format(uid=self.uid)) + self.logger.warning(f"set_buttons_enabled: Can't set buttons enabled state for {self.uid}. Not supported. Error {ex} occurred.") return False def get_buttons_enabled(self) -> bool: @@ -942,7 +957,7 @@ def get_buttons_enabled(self) -> bool: try: return self.soco.buttons_enabled except Exception as ex: - self.logger.error(ex) + self.logger.error(f"get_buttons_enabled: Error {ex} occurred.") return False @property @@ -965,7 +980,7 @@ def dialog_mode(self, dialog_mode: bool) -> None: return self._dialog_mode = dialog_mode for item in self.dialog_mode_items: - item(self.dialog_mode, 'Sonos') + item(self.dialog_mode, self.plugin_shortname) def set_dialog_mode(self, dialog_mode: bool) -> bool: """ @@ -980,7 +995,7 @@ def set_dialog_mode(self, dialog_mode: bool) -> bool: self.dialog_mode = dialog_mode return True except Exception as ex: - self.logger.warning("Sonos: {uid}: can't set dialog mode. Not supported.".format(uid=self.uid)) + self.logger.warning(f"set_dialog_mode: Can't set dialog mode for {self.uid}. Not supported. Error {ex} occurred.") return False @property @@ -1003,7 +1018,7 @@ def loudness(self, loudness: bool) -> None: return self._loudness = loudness for item in self.loudness_items: - item(self.loudness, 'Sonos') + item(self.loudness, self.plugin_shortname) def set_loudness(self, loudness: bool, group_command: bool = False) -> bool: """ @@ -1023,7 +1038,7 @@ def set_loudness(self, loudness: bool, group_command: bool = False) -> bool: self.loudness = loudness return True except Exception as ex: - self.logger.error(ex) + self.logger.error(f"set_loudness: Error {ex} occurred.") return False @property @@ -1046,7 +1061,7 @@ def treble(self, treble: int) -> None: return self._treble = treble for item in self.treble_items: - item(self.treble, 'Sonos') + item(self.treble, self.plugin_shortname) def set_treble(self, treble: int, group_command: bool = False) -> bool: """ @@ -1059,7 +1074,7 @@ def set_treble(self, treble: int, group_command: bool = False) -> bool: try: # check value if treble not in range(-10, 11, 1): - raise Exception('Sonos: Treble has to be an integer between -10 and 10.') + raise Exception('Treble has to be an integer between -10 and 10.') if group_command: for member in self.zone_group_members: sonos_speaker[member].soco.treble = treble @@ -1069,7 +1084,7 @@ def set_treble(self, treble: int, group_command: bool = False) -> bool: self.treble = treble return True except Exception as ex: - self.logger.error(ex) + self.logger.error(f"set_treble: Error {ex} occurred.") return False @property @@ -1092,7 +1107,7 @@ def bass(self, bass: int) -> None: return self._bass = bass for item in self.bass_items: - item(self.bass, 'Sonos') + item(self.bass, self.plugin_shortname) def set_bass(self, bass: int, group_command: bool = False) -> bool: """ @@ -1105,7 +1120,7 @@ def set_bass(self, bass: int, group_command: bool = False) -> bool: try: # check value if bass not in range(-10, 11, 1): - raise Exception('Sonos: Bass has to be an integer between -10 and 10.') + raise Exception('Bass has to be an integer between -10 and 10.') if group_command: for member in self.zone_group_members: sonos_speaker[member].soco.bass = bass @@ -1115,7 +1130,7 @@ def set_bass(self, bass: int, group_command: bool = False) -> bool: self.bass = bass return True except Exception as ex: - self.logger.error(ex) + self.logger.error(f"set_bass: Error {ex} occurred.") return False @property @@ -1140,7 +1155,7 @@ def volume(self, value: int) -> None: self._volume = value for item in self.volume_items: - item(self.volume, 'Sonos') + item(self.volume, self.plugin_shortname) def _check_max_volume_exceeded(self, volume: int, max_volume: int) -> bool: """ @@ -1171,25 +1186,24 @@ def set_volume(self, volume: int, group_command: bool = False, max_volume: int = return # don ot raise error here polluting the log file # dpt3 handling can trigger negative values - # raise Exception('Sonos: Volume has to be an integer between 0 and 100.') + # raise Exception('Volume has to be an integer between 0 and 100.') if self._check_max_volume_exceeded(volume, max_volume): - self.logger.debug("Volume to set [{volume}] exceeds max volume [{max_volume}].".format( - volume=volume, max_volume=max_volume - )) + self.logger.debug(f"Volume to set [{volume}] exceeds max volume [{max_volume}].") volume = max_volume if group_command: for member in self.zone_group_members: - self.logger.debug(f"Debug set_volume: Setting {member} to volume {volume}") - sonos_speaker[member].soco.volume = volume - sonos_speaker[member].volume = volume + if member != '': + self.logger.debug(f"set_volume: Setting {member} to volume {volume}") + sonos_speaker[member].soco.volume = volume + sonos_speaker[member].volume = volume else: self.soco.volume = volume self.volume = volume return True except Exception as ex: - self.logger.error(ex) + self.logger.error(f"set_volume: Error {ex} occurred.") return False def switch_to_tv(self) -> bool: @@ -1200,7 +1214,7 @@ def switch_to_tv(self) -> bool: try: return self.soco.switch_to_tv() except Exception as ex: - self.logger.warning("Sonos: {uid}: can't switch to TV. Not supported.".format(uid=self.uid)) + self.logger.warning(f"switch_to_tv: Can't switch {self.uid} to TV. Not supported. Error {ex} occurred.") return False def switch_to_line_in(self) -> bool: @@ -1211,7 +1225,7 @@ def switch_to_line_in(self) -> bool: try: return self.soco.switch_to_line_in() except Exception as ex: - self.logger.warning("Sonos: {uid}: can't switch to line-in. Not supported.".format(uid=self.uid)) + self.logger.warning(f"switch_to_line_in: : Can't switch {self.uid} to line-in. Not supported. Error {ex} occurred.") return False @property @@ -1233,7 +1247,7 @@ def status_light(self, value: bool) -> None: return self._status_light = value for item in self.status_light_items: - item(self.status_light, 'Sonos') + item(self.status_light, self.plugin_shortname) def set_status_light(self, value: bool) -> bool: """ @@ -1245,21 +1259,33 @@ def set_status_light(self, value: bool) -> bool: self.soco.status_light = value return True except Exception as ex: - self.logger.debug(ex) + self.logger.debug(f"set_status_light: Error {ex} occurred.") return False def get_status_light(self) -> bool: """ - Calls the SoCo function to get the led of the speaker. + Calls the SoCo function to get the LED status of the speaker. :rtype: bool :return: 'True' for Led on, 'False' for Led off or Exception """ try: return self.soco.status_light except Exception as ex: - self.logger.error(ex) + self.logger.error(f"get_status_light: Error {ex} occurred.") return False + def get_reboot_count(self) -> int: + """ + Calls the SoCo function to get the number of reboots. + :rtype: int + :return: number of reboots + """ + try: + return self.soco.boot_seqnum + except Exception as ex: + self.logger.error(f"get_reboot_count: Error {ex} occurred.") + return 0 + @property def coordinator(self) -> str: """ @@ -1277,7 +1303,7 @@ def coordinator(self, value: str) -> None: """ self._coordinator = value for item in self.coordinator_items: - item(self.coordinator, 'Sonos') + item(self.coordinator, self.plugin_shortname) @property def zone_group_members(self) -> list: @@ -1294,25 +1320,29 @@ def zone_group_members(self, value: list) -> None: :param value: list with uids to set as group members """ if not isinstance(value, list): - self.logger.warning("Sonos: {uid}: value [{value]] for setter zone_group_members must be type of list." - .format(uid=self.uid, value=value)) + self.logger.warning(f"zone_group_members: {self.uid}: value={value} for setter zone_group_members must be type of list.") return self._members = value for item in self.zone_group_members_items: - item(self.zone_group_members, 'Sonos') + item(self.zone_group_members, self.plugin_shortname) # if we are the coordinator: un-register av events for slave speakers # re-init subscriptions for the master if self.is_coordinator: for member in self._zone_group_members: + self.logger.debug(f"****zone_group_members: {member=}") if member is not self: - self.logger.info("Debug: Unsubscribe av event for uid {0} in fct zone_group_members".format(self.uid)) - member.av_subscription.unsubscribe() + try: + self.logger.debug(f"Unsubscribe av event for uid '{self.uid}' in fct zone_group_members") + member.av_subscription.unsubscribe() + except Exception as e: + self.logger.info(f"Unsubscribe av event for uid '{self.uid}' in fct zone_group_members caused error {e}") + pass else: # Why are the member speakers un- and subscribed again? - self.logger.info("Debug: Un/Subscribe av event for uid {0} in fct zone_group_members".format(self.uid)) + self.logger.debug(f"Un/Subscribe av event for uid '{self.uid}' in fct zone_group_members") member.av_subscription.unsubscribe() member.av_subscription.subscribe() @@ -1339,7 +1369,7 @@ def streamtype(self, streamtype: str) -> None: if self.is_coordinator: for member in self.zone_group_members: for item in sonos_speaker[member].streamtype_items: - item(self.streamtype, 'Sonos') + item(self.streamtype, self.plugin_shortname) @property def track_uri(self) -> str: @@ -1371,11 +1401,11 @@ def track_uri(self, track_uri: str) -> None: if self.is_coordinator: for member in self.zone_group_members: for item in sonos_speaker[member].track_uri_items: - item(self.track_uri, 'Sonos') + item(self.track_uri, self.plugin_shortname) # slave call, update just the slave else: for item in self.track_uri_items: - item(self.track_uri, 'Sonos') + item(self.track_uri, self.plugin_shortname) @property def play(self) -> bool: @@ -1406,7 +1436,7 @@ def play(self, value: bool) -> None: self._play = value for member in self.zone_group_members: for item in sonos_speaker[member].play_items: - item(value, 'Sonos') + item(value, self.plugin_shortname) def set_play(self) -> bool: """ @@ -1451,7 +1481,7 @@ def pause(self, value: bool) -> None: self._pause = value for member in self.zone_group_members: for item in sonos_speaker[member].pause_items: - item(value, 'Sonos') + item(value, self.plugin_shortname) def set_pause(self) -> bool: """ @@ -1461,7 +1491,7 @@ def set_pause(self) -> bool: if not self._check_property(): return False try: - ret = sonos_speaker[self.coordinator].soco.pause() + ret = sonos_speaker[self.coordinator].soco.pause() except Exception as e: self.logger.warning(f"Exception during set_pause: {e}") return False @@ -1503,7 +1533,7 @@ def stop(self, value: bool) -> None: self._stop = value for member in self.zone_group_members: for item in sonos_speaker[member].stop_items: - item(value, 'Sonos') + item(value, self.plugin_shortname) def set_stop(self) -> bool: """ @@ -1529,9 +1559,8 @@ def set_next(self, next_track: bool) -> None: if next_track: try: sonos_speaker[self.coordinator].soco.next() - except: - self.logger.debug("Sonos: {uid}: can't go to next track. Maybe the end of the playlist " - "reached?".format(uid=self.uid)) + except Exception: + self.logger.debug(f"{self.uid}: can't go to next track. Maybe the end of the playlist reached?") def set_previous(self, previous: bool) -> None: """ @@ -1544,9 +1573,8 @@ def set_previous(self, previous: bool) -> None: if previous: try: sonos_speaker[self.coordinator].soco.previous() - except: - self.logger.debug("Sonos: {uid}: can't go back to the previously played track. Already the first " - "track in the playlist?".format(uid=self.uid)) + except Exception: + self.logger.debug(f"{self.uid}: can't go back to the previously played track. Already the first track in the playlist?") @property def mute(self) -> bool: @@ -1576,23 +1604,29 @@ def mute(self, value: bool) -> None: self._mute = value for member in self.zone_group_members: for item in sonos_speaker[member].mute_items: - item(value, 'Sonos') + item(value, self.plugin_shortname) - def set_mute(self, value: bool) -> bool: + def set_mute(self, value: bool, group_command: bool = True) -> bool: """ - Calls the SoCo mute method and mutes /un-mutes the speaker. - :param value: True for mute, False for un-mute + Calls the SoCo mute method and mutes / unmutes the speaker. + :param value: True for mute, False for unmute + :param group_command: Should the mute command be set to all speaker of the group? Default: True :return: True, if successful, otherwise False. """ - #self.logger.info("Debug: set_mute: check_property: {0}".format(self._check_property())) - self.logger.info("Debug: set_mute: self.coordinator: {0}".format(self.coordinator)) + self.logger.debug(f"set_mute: self.coordinator: {self.coordinator}, check_property: {self._check_property()}") try: if not self._check_property(): - return False - sonos_speaker[self.coordinator].soco.mute = value + return False + + if group_command: + for member in self.zone_group_members: + sonos_speaker[member].soco.mute = value + else: + sonos_speaker[self.coordinator].soco.mute = value + return True except Exception as ex: - self.logger.error(ex) + self.logger.error(f"set_mute: Error {ex} occurred.") return False @property @@ -1623,11 +1657,11 @@ def cross_fade(self, cross_fade: bool) -> None: self._cross_fade = cross_fade for member in self.zone_group_members: for item in sonos_speaker[member].cross_fade_items: - item(cross_fade, 'Sonos') + item(cross_fade, self.plugin_shortname) def set_cross_fade(self, cross_fade: bool) -> bool: """ - Calls the SoCo cross_fade method and sets the cross fade setting for the speaker. + Calls the SoCo cross_fade method and sets the cross-fade setting for the speaker. :param cross_fade: 'True' for cross_fade on, 'False' for cross_fade off :return: True, if successful, otherwise False. """ @@ -1637,7 +1671,7 @@ def set_cross_fade(self, cross_fade: bool) -> bool: sonos_speaker[self.coordinator].soco.cross_fade = cross_fade return True except Exception as ex: - self.logger.error(ex) + self.logger.error(f"set_cross_fade: Error {ex} occurred.") return False @property @@ -1669,7 +1703,7 @@ def snooze(self, snooze: int) -> None: self._snooze = snooze for member in self.zone_group_members: for item in sonos_speaker[member].snooze_items: - item(snooze, 'Sonos') + item(snooze, self.plugin_shortname) def set_snooze(self, snooze: int) -> bool: """ @@ -1684,7 +1718,7 @@ def set_snooze(self, snooze: int) -> bool: sonos_speaker[self.coordinator].soco.set_sleep_timer(snooze) return True except Exception as ex: - self.logger.error(ex) + self.logger.error(f"set_snooze: Error {ex} occurred.") return False def get_snooze(self) -> int: @@ -1699,7 +1733,7 @@ def get_snooze(self) -> int: return 0 return sonos_speaker[self.coordinator].soco.get_sleep_timer() except Exception as ex: - self.logger.error(ex) + self.logger.error(f"get_snooze: Error {ex} occurred.") return 0 @property @@ -1731,7 +1765,7 @@ def play_mode(self, play_mode: str) -> None: self._play_mode = play_mode for member in self.zone_group_members: for item in sonos_speaker[member].play_mode_items: - item(play_mode, 'Sonos') + item(play_mode, self.plugin_shortname) def set_play_mode(self, play_mode: str) -> bool: """ @@ -1745,7 +1779,7 @@ def set_play_mode(self, play_mode: str) -> bool: sonos_speaker[self.coordinator].soco.play_mode = play_mode return True except Exception as ex: - self.logger.error(ex) + self.logger.error(f"set_play_mode: Error {ex} occurred.") return False @property @@ -1764,7 +1798,7 @@ def is_coordinator(self, value: bool) -> None: """ self._is_coordinator = value for item in self.is_coordinator_items: - item(self._is_coordinator, 'Sonos') + item(self._is_coordinator, self.plugin_shortname) @property def current_track(self) -> int: @@ -1793,7 +1827,7 @@ def current_track(self, current_track: int) -> None: self._current_track = current_track for member in self.zone_group_members: for item in sonos_speaker[member].current_track_items: - item(current_track, 'Sonos') + item(current_track, self.plugin_shortname) @property def number_of_tracks(self) -> int: @@ -1822,7 +1856,7 @@ def number_of_tracks(self, number_of_tracks: int) -> None: self._number_of_tracks = number_of_tracks for member in self.zone_group_members: for item in sonos_speaker[member].number_of_tracks_items: - item(number_of_tracks, 'Sonos') + item(number_of_tracks, self.plugin_shortname) @property def current_track_duration(self) -> str: @@ -1851,7 +1885,7 @@ def current_track_duration(self, current_track_duration: str) -> None: self._current_track_duration = current_track_duration for member in self.zone_group_members: for item in sonos_speaker[member].current_track_duration_items: - item(self.current_track_duration, 'Sonos') + item(self.current_track_duration, self.plugin_shortname) @property def current_transport_actions(self) -> str: @@ -1883,7 +1917,7 @@ def current_transport_actions(self, current_transport_actions: str) -> None: self._current_transport_actions = current_transport_actions for member in self.zone_group_members: for item in sonos_speaker[member].current_transport_actions_items: - item(self.current_transport_actions, 'Sonos') + item(self.current_transport_actions, self.plugin_shortname) @property def current_valid_play_modes(self) -> str: @@ -1911,7 +1945,7 @@ def current_valid_play_modes(self, current_valid_play_modes: str) -> None: self._current_valid_play_modes = current_valid_play_modes for member in self.zone_group_members: for item in sonos_speaker[member].current_valid_play_modes_items: - item(self.current_valid_play_modes, 'Sonos') + item(self.current_valid_play_modes, self.plugin_shortname) @property def track_artist(self) -> str: @@ -1939,7 +1973,7 @@ def track_artist(self, track_artist: str) -> None: self._track_artist = track_artist for member in self.zone_group_members: for item in sonos_speaker[member].track_artist_items: - item(self.track_artist, 'Sonos') + item(self.track_artist, self.plugin_shortname) @property def track_title(self) -> str: @@ -1967,7 +2001,7 @@ def track_title(self, track_title: str) -> None: self._track_title = track_title for member in self.zone_group_members: for item in sonos_speaker[member].track_title_items: - item(self.track_title, 'Sonos') + item(self.track_title, self.plugin_shortname) @property def track_album(self) -> str: @@ -1996,7 +2030,7 @@ def track_album(self, track_album: str) -> None: self._track_album = track_album for member in self.zone_group_members: for item in sonos_speaker[member].track_album_items: - item(self.track_album, 'Sonos') + item(self.track_album, self.plugin_shortname) @property def track_album_art(self) -> str: @@ -2025,7 +2059,7 @@ def track_album_art(self, track_album_art: str) -> None: self._track_album_art = track_album_art for member in self.zone_group_members: for item in sonos_speaker[member].track_album_art_items: - item(self.track_album_art, 'Sonos') + item(self.track_album_art, self.plugin_shortname) @property def radio_station(self) -> str: @@ -2054,7 +2088,7 @@ def radio_station(self, radio_station: str) -> None: self._radio_station = radio_station for member in self.zone_group_members: for item in sonos_speaker[member].radio_station_items: - item(self.radio_station, 'Sonos') + item(self.radio_station, self.plugin_shortname) @property def radio_show(self) -> str: @@ -2083,7 +2117,7 @@ def radio_show(self, radio_show: str) -> None: self._radio_show = radio_show for member in self.zone_group_members: for item in sonos_speaker[member].radio_show_items: - item(self.radio_show, 'Sonos') + item(self.radio_show, self.plugin_shortname) @property def stream_content(self) -> str: @@ -2112,94 +2146,181 @@ def stream_content(self, stream_content: str) -> None: self._stream_content = stream_content for member in self.zone_group_members: for item in sonos_speaker[member].stream_content_items: - item(self.stream_content, 'Sonos') + item(self.stream_content, self.plugin_shortname) def play_tunein(self, station_name: str, start: bool = True) -> None: """ - Plays a radio station by a given radio name. If more than one radio station are found, the first result will be - played. + Plays a radio station from TuneIn by a given radio name. If more than one radio station are found, + the first result will be played. + :param station_name: radio station name + :param start: Start playing after setting the radio stream? Default: True + :return: None + """ + + if not self._check_property(): + return + + if not self.is_coordinator: + sonos_speaker[self.coordinator].play_tunein(station_name, start) + else: + result, msg = self._play_radio(station_name=station_name, music_service='TuneIn', start=start) + if not result: + self.logger.warning(msg) + return False + return True + + def play_sonos_radio(self, station_name: str, start: bool = True) -> None: + """ + Plays a radio station from Sonos Radio by a given radio name. If more than one radio station are found, + the first result will be played. :param station_name: radio station name :param start: Start playing after setting the radio stream? Default: True :return: None """ - # ------------------------------------------------------------------------------------------------------------ # + if not self._check_property(): + return + + if not self.is_coordinator: + sonos_speaker[self.coordinator].play_sonos_radio(station_name, start) + else: + result, msg = self._play_radio(station_name=station_name, music_service='Sonos Radio', start=start) + if not result: + self.logger.warning(msg) + return False + return True + + def _play_radio(self, station_name: str, music_service: str = 'TuneIn', start: bool = True) -> tuple: + """ + Plays a radio station by a given radio name at a given music service. If more than one radio station are found, + the first result will be played. + :param music_service: music service name Default: TuneIn + :param station_name: radio station name + :param start: Start playing after setting the radio stream? Default: True + :return: None + """ + + meta_template = """ + + + {title} + {station_logo} + object.item.audioItem.audioBroadcast + + {service} + + + ' + """ + + # get all music services + all_music_services_names = MusicService.get_all_music_services_names() + + # check if given music service is available + if music_service not in all_music_services_names: + return False, f"Requested Music Service '{music_service}' not available" + + # get music service instance + music_service = MusicService(music_service) + + # adapt station_name for optimal search results + if " " in station_name: + station_name_for_search = station_name.split(" ", 1)[0] + elif station_name[-1].isdigit(): + station_name_for_search = station_name[:-1] + else: + station_name_for_search = station_name + + # do search + search_result = music_service.search(category='stations', term=station_name_for_search, index=0, count=100) + + # get station object from search result + the_station = None + # Strict match + for station in search_result: + if station_name in station.title: + self.logger.info(f"Strict match '{station.title}' found") + the_station = station + break + + # Fuzzy match + if not the_station: + station_name = station_name.lower() + for station in search_result: + if station_name in station.title.lower(): + self.logger.info(f"Fuzzy match '{station.title}' found") + the_station = station + break + + # Very fuzzy match // handle StationNames ending on digit and add space in front + if not the_station: + last_char = len(station_name) - 1 + if station_name[last_char].isdigit(): + station_name = f"{station_name[0:last_char]} {station_name[last_char:]}" + for station in search_result: + if station_name in station.title.lower(): + self.logger.info(f"Very fuzzy match '{station.title}' found") + the_station = station + break + + if not the_station: + return False, f"No match for requested radio station {station_name}. Check spaces in station name" + + uri = music_service.get_media_uri(the_station.id) + + station_logo = the_station.stream_metadata.logo + if "%" in station_logo: + station_logo = unquote(station_logo) + if station_logo.startswith('https://sali.sonos.radio'): + station_logo = station_logo.split("image=")[1].split("&partnerId")[0] - # This code here is a quick workaround for issue https://github.com/SoCo/SoCo/issues/557 and will be fixed - # if a patch is applied. + metadata = meta_template.format(title=the_station.title, service=the_station.desc, station_logo=station_logo) - # ------------------------------------------------------------------------------------------------------------ # + self.logger.info(f"Trying 'play_uri()': URI={uri}, Metadata={metadata}") + self.soco.play_uri(uri=uri, meta=metadata, title=the_station.title, start=start, force_radio=True) + return True, "" + def play_sharelink(self, url: str, start: bool = True) -> None: + """ + Plays a sharelink from a given url + :param start: Start playing after setting the url? Default: True + :param url: url to be played + :return: None + """ if not self._check_property(): return + if not self.is_coordinator: - sonos_speaker[self.coordinator].play_tunein(station_name, start) + try: + device = sonos_speaker[self.coordinator] + share_link = ShareLinkPlugin(device) + + if not share_link.is_share_link(url): + self.logger.warning(f"Url: {url} is not a valid share link") + return False + + queue_position = share_link.add_share_link_to_queue(url) + sonos_speaker[self.coordinator].play_from_queue(index=queue_position) + except SoCoUPnPException as ex: + self.logger.warning(f"Exception in play_from_queue() a): {ex}") + return else: + try: + device = self.soco + share_link = ShareLinkPlugin(device) - data = 'anon' \ - 'Sonos' \ - 'search:station{search}' \ - '0100'.format( - search=station_name) - - headers = { - "SOAPACTION": "http://www.sonos.com/Services/1.1#search", - "USER-AGENT": "Linux UPnP/1.0 Sonos/40.5-49250 (WDCR:Microsoft Windows NT 10.0.16299)", - "CONTENT-TYPE": 'text/xml; charset="utf-8"' - } - - response = requests.post("http://legato.radiotime.com/Radio.asmx", data=data.encode("utf-8"), - headers=headers) - schema = XML.fromstring(response.content) - body = schema.find("{http://schemas.xmlsoap.org/soap/envelope/}Body")[0] - - response = list(xmltodict.parse(XML.tostring(body), process_namespaces=True, - namespaces={'http://www.sonos.com/Services/1.1': None}).values())[0] - - items = [] - # The result to be parsed is in either searchResult or getMetadataResult - if 'searchResult' in response: - response = response['searchResult'] - elif 'getMetadataResult' in response: - response = response['getMetadataResult'] - else: - raise ValueError('"response" should contain either the key ' - '"searchResult" or "getMetadataResult"') - - for result_type in ('mediaCollection', 'mediaMetadata'): - # Upper case the first letter (used for the class_key) - result_type_proper = result_type[0].upper() + result_type[1:] - raw_items = response.get(result_type, []) - # If there is only 1 result, it is not put in an array - if isinstance(raw_items, OrderedDict): - raw_items = [raw_items] - - for raw_item in raw_items: - # Form the class_key, which is a unique string for this type, - # formed by concatenating the result type with the item type. Turns - # into e.g: MediaMetadataTrack - class_key = result_type_proper + raw_item['itemType'].title() - cls = get_class(class_key) - #from plugins.sonos.soco.music_services.token_store import JsonFileTokenStore - items.append( - cls.from_music_service(MusicService(service_name='TuneIn'), raw_item)) - #cls.from_music_service(MusicService(service_name='TuneIn', token_store=JsonFileTokenStore()), raw_item)) - - if not items: - exit(0) - - item_id = items[0].metadata['id'] - sid = 254 # hard-coded TuneIn service id ? - sn = 0 - meta = to_didl_string(items[0]) - - uri = "x-sonosapi-stream:{0}?sid={1}&flags=8224&sn={2}".format(item_id, sid, sn) - - self.soco.avTransport.SetAVTransportURI([('InstanceID', 0), - ('CurrentURI', uri), ('CurrentURIMetaData', meta)]) - if start: - self.soco.play() + if not share_link.is_share_link(url): + self.logger.warning(f"Url: {url} is not a valid share link") + return False + + queue_position = share_link.add_share_link_to_queue(url) + self.soco.play_from_queue(index=queue_position) + except SoCoUPnPException as ex: + self.logger.warning(f"Exception in play_sharelink() b): {ex}") + return def play_url(self, url: str, start: bool = True) -> None: """ @@ -2226,12 +2347,10 @@ def join(self, uid: str) -> None: return uid = uid.lower() if uid not in sonos_speaker: - self.logger.warning("Sonos: Cannot join ... no speaker found with uid {uid}.".format(uid=uid)) + self.logger.warning(f"Cannot join ... no speaker found with uid {uid}.") return speaker_to_join = sonos_speaker[uid] - self.logger.debug( - 'Sonos: Joining [{uid}] to [uid: {to_join}, master: {master}]'.format( - uid=uid, to_join=speaker_to_join.uid, master=speaker_to_join.coordinator)) + self.logger.debug(f'Joining [{uid}] to [uid: {speaker_to_join.uid}, master: {speaker_to_join.coordinator}]') self.soco.join(sonos_speaker[speaker_to_join.coordinator].soco) def unjoin(self, unjoin: bool, start: bool = False) -> None: @@ -2251,136 +2370,159 @@ def unjoin(self, unjoin: bool, start: bool = False) -> None: def sonos_playlists(self) -> None: """ - Gets all Sonos playlist items. + Gets all Sonos playlist and put result to items. """ try: playlists = self.soco.get_sonos_playlists() except Exception as e: - self.logger.info("Error during soco.get_sonos_playlists(): {0}".format(e)) + self.logger.info(f"Error during soco.get_sonos_playlists(): {e}") return - p_l = [] + self.logger.debug(f"sonos_playlists: {playlists=}") + + sonos_playlist_list = [] for value in playlists: - p_l.append(value.title) + sonos_playlist_list.append(value.title) for item in self.sonos_playlists_items: - item(p_l, 'Sonos') + item(sonos_playlist_list, self.plugin_shortname) - def _play_snippet(self, file_path: str, webservice_url: str, volume: int = -1, duration_offset: float = 0, fade_in=False) -> None: - self.logger.debug(f"Debug _play_snippet with volume {volume}") - if not self._check_property(): + def sonos_favorites(self) -> None: + """ + Gets all Sonos favorites. + """ + try: + favorites = self.soco.music_library.get_sonos_favorites(complete_result=True) + except Exception as e: + self.logger.info(f"Error during soco.music_library.get_sonos_favorites(): {e}") + return + self.logger.debug(f"sonos_favorites: {favorites=}") + + sonos_favorite_list = [] + for favorite in favorites: + sonos_favorite_list.append(favorite.title) + for item in self.sonos_favorites_items: + item(sonos_favorite_list, self.plugin_shortname) + + def favorite_radio_stations(self) -> None: + """ + Gets all Sonos favorites radio stations items. + """ + try: + radio_stations = self.soco.music_library.get_favorite_radio_stations() + except Exception as e: + self.logger.info(f"Error during soco.music_library.get_favorite_radio_stations(): {e}") return + self.logger.debug(f"favorite_radio_stations: {radio_stations=}") + + favorite_radio_station_list = [] + for favorite in radio_stations: + favorite_radio_station_list.append(favorite.title) + for item in self.favorite_radio_stations_items: + item(favorite_radio_station_list, self.plugin_shortname) + + def _play_snippet(self, file_path: str, webservice_url: str, volume: int = -1, duration_offset: float = 0, fade_in: bool = False) -> None: + self.logger.debug(f"_play_snippet with volume {volume}") + + # Already done in method which called this one + # if not self._check_property(): + # return + if not os.path.isfile(file_path): - self.logger.error("Cannot find snipped file {0}".format(file_path)) - return - if not self.is_coordinator: - sonos_speaker[self.coordinator]._play_snippet(file_path, webservice_url, volume, duration_offset, fade_in) - else: + self.logger.error(f"Cannot find snipped file {file_path}") + return - # Check if stop() is part of currently supported transport actions. - # For example, stop() is not available when the speakter is in TV mode. - currentActions = self.current_transport_actions - self.logger.debug("play_snippet: checking transport actions: {0}".format(currentActions)) - - with self._snippet_queue_lock: - snap = None - volumes = {} - # save all volumes from zone_member - for member in self.zone_group_members: + # Check if stop() is part of currently supported transport actions. + # For example, stop() is not available when the speaker is in TV mode. + currentActions = self.current_transport_actions + self.logger.debug(f"play_snippet: checking transport actions: {currentActions}") + + with self._snippet_queue_lock: + snap = None + volumes = {} + # save all volumes from zone_member + for member in self.zone_group_members: + if member != '': volumes[member] = sonos_speaker[member].volume - tag = TinyTag.get(file_path) - self.logger.debug("Debug: tagduration {0}, duration_offset {1}".format(tag.duration,duration_offset)) - if not tag.duration: - self.logger.error("TinyTag duration is none.") + tag = TinyTag.get(file_path) + self.logger.debug(f"tag-duration {tag.duration}, duration_offset {duration_offset}") + if not tag.duration: + self.logger.error("TinyTag duration is none.") + else: + duration = round(tag.duration) + duration_offset + self.logger.debug(f"TTS track duration: {duration}s, TTS track duration offset: {duration_offset}s") + file_name = quote(os.path.split(file_path)[1]) + snippet_url = f"{webservice_url}/{file_name}" + + # was GoogleTTS the last track? do not snapshot + last_station = self.radio_station.lower() + if last_station != "snippet": + snap = Snapshot(self.soco) + snap.snapshot() + + time.sleep(0.5) + if 'Stop' in currentActions: + self.set_stop() + if volume == -1: + self.logger.debug(f"_play_snippet, volume is -1, reset to {self.volume}") + volume = self.volume + + self.set_volume(volume, group_command=True) + self.soco.play_uri(snippet_url, title="snippet") + time.sleep(duration) + if 'Stop' in currentActions: + self.set_stop() + + # Restore the Sonos device back to its previous state + if last_station != "snippet": + if snap is not None: + snap.restore() else: - duration = round(tag.duration) + duration_offset - self.logger.debug("Sonos: TTS track duration offset is: {offset}s".format(offset=duration_offset)) - self.logger.debug("Sonos: TTS track duration: {duration}s".format(duration=duration)) - file_name = quote(os.path.split(file_path)[1]) - snippet_url = "{url}/{file}".format(url=webservice_url, file=file_name) - - # was GoogleTTS the last track? do not snapshot - last_station = self.radio_station.lower() - if last_station != "snippet": - snap = Snapshot(self.soco) - snap.snapshot() - - time.sleep(0.5) - if 'Stop' in currentActions: - self.set_stop() - if volume == -1: - self.logger.debug(f"Debug _play_snippet, volume is -1, reset to {self.volume}") - volume = self.volume - - self.set_volume(volume, group_command=True) - self.soco.play_uri(snippet_url, title="snippet") - time.sleep(duration) - if 'Stop' in currentActions: - self.set_stop() - - # Restore the Sonos device back to it's previous state - if last_station != "snippet": - if snap is not None: - snap.restore() - else: - self.radio_station = "" - for member in self.zone_group_members: - if member in volumes: - if fade_in: - vol_to_ramp = volumes[member] - sonos_speaker[member].soco.volume = 0 - sonos_speaker[member].soco.renderingControl.RampToVolume( - [('InstanceID', 0), ('Channel', 'Master'), - ('RampType', 'SLEEP_TIMER_RAMP_TYPE'), - ('DesiredVolume', vol_to_ramp), - ('ResetVolumeAfter', False), ('ProgramURI', '')]) - else: - sonos_speaker[member].set_volume(volumes[member], group_command=False) + self.radio_station = "" + for member in self.zone_group_members: + if member in volumes: + if fade_in: + vol_to_ramp = volumes[member] + sonos_speaker[member].soco.volume = 0 + sonos_speaker[member].soco.renderingControl.RampToVolume( + [('InstanceID', 0), ('Channel', 'Master'), + ('RampType', 'SLEEP_TIMER_RAMP_TYPE'), + ('DesiredVolume', vol_to_ramp), + ('ResetVolumeAfter', False), ('ProgramURI', '')]) + else: + sonos_speaker[member].set_volume(volumes[member], group_command=False) - def play_snippet(self, audio_file, local_webservice_path_snippet: str, webservice_url: str, volume: int = -1, duration_offset: float = 0, - fade_in=False) -> None: + def play_snippet(self, audio_file, local_webservice_path_snippet: str, webservice_url: str, volume: int = -1, duration_offset: float = 0, fade_in=False) -> None: if not self._check_property(): return if not self.is_coordinator: - sonos_speaker[self.coordinator].play_snippet(audio_file, local_webservice_path_snippet, webservice_url, volume, duration_offset, - fade_in) + sonos_speaker[self.coordinator].play_snippet(audio_file, local_webservice_path_snippet, webservice_url, volume, duration_offset, fade_in) else: - if "tinytag" not in sys.modules: - self.logger.error("Sonos: TinyTag module not installed. Please install the module with 'sudo pip3 " - "install tinytag'.") - return file_path = os.path.join(local_webservice_path_snippet, audio_file) if not os.path.exists(file_path): - self.logger.error("Sonos: Snippet file '{file_path}' does not exists.".format(file_path=file_path)) + self.logger.error(f"Snippet file '{file_path}' does not exists.") return self._play_snippet(file_path, webservice_url, volume, duration_offset, fade_in) - def play_tts(self, tts: str, tts_language: str, local_webservice_path: str, webservice_url: str, volume: int = -1, duration_offset: float = 0, - fade_in=False) -> None: + def play_tts(self, tts: str, tts_language: str, local_webservice_path: str, webservice_url: str, volume: int = -1, duration_offset: float = 0, fade_in=False) -> None: if not self._check_property(): return if not self.is_coordinator: - sonos_speaker[self.coordinator].play_tts(tts, tts_language, local_webservice_path, webservice_url, - volume, duration_offset, fade_in) + sonos_speaker[self.coordinator].play_tts(tts, tts_language, local_webservice_path, webservice_url, volume, duration_offset, fade_in) else: - if "tinytag" not in sys.modules: - self.logger.error("Sonos: TinyTag module not installed. Please install the module with 'sudo pip3 " - "install tinytag'.") - return - file_path = get_tts_local_file_path(local_webservice_path, tts, tts_language) + file_path = utils.get_tts_local_file_path(local_webservice_path, tts, tts_language) # only do a tts call if file not exists if not os.path.exists(file_path): tts = gTTS(tts, lang=tts_language) try: tts.save(file_path) - except Exception as err: - self.logger.error("Sonos: Could not obtain TTS file from Google. Error: {ex}".format(ex=err)) + except Exception as ex: + self.logger.error(f"Could not obtain TTS file from Google. Error: {ex}") return else: - self.logger.debug("Sonos: File {file} already exists. No TTS request necessary.".format( - file=file_path)) + self.logger.debug(f"File {file_path} already exists. No TTS request necessary.") self._play_snippet(file_path, webservice_url, volume, duration_offset, fade_in) def load_sonos_playlist(self, name: str, start: bool = False, clear_queue: bool = False, track: int = 0) -> None: @@ -2400,7 +2542,7 @@ def load_sonos_playlist(self, name: str, start: bool = False, clear_queue: bool else: try: if not name: - self.logger.warning("Sonos: A valid playlist name must be provided.") + self.logger.warning("A valid playlist name must be provided.") return playlist = self.soco.get_sonos_playlist_by_attr('title', name) if playlist: @@ -2410,200 +2552,306 @@ def load_sonos_playlist(self, name: str, start: bool = False, clear_queue: bool try: track = int(track) except TypeError: - self.logger.warning("Sonos: Could not cast track [{track}] to 'int'.") + self.logger.warning("Could not cast track [{track}] to 'int'.") return try: self.soco.play_from_queue(track, start) except SoCoUPnPException as ex: - self.logger.warning("Exception in play_from_queue(): {ex}".format(ex=ex)) + self.logger.warning(f"Exception in play_from_queue(): {ex}") return # bug here? no event, we have to trigger it manually if start: self.play = True - except Exception as ex: - self.logger.warning("Sonos: No Sonos playlist found with title '{title}'.".format(title=name)) - + except Exception: + self.logger.warning(f"load_sonos_playlist: No Sonos playlist found with title '{name}'.") -class Sonos(SmartPlugin): - ALLOW_MULTIINSTANCE = False - PLUGIN_VERSION = "1.6.5" + def _play_favorite(self, favorite_title: str = None, favorite_number: int = None) -> tuple: + """Core of the play_favorite action, but doesn't exit on failure""" - def __init__(self, sh, *args, **kwargs): - """ - Initalizes the plugin. + favorites = self.soco.music_library.get_sonos_favorites(complete_result=True) - """ + if favorite_number is not None: + err_msg = f"Favorite number must be integer between 1 and {len(favorites)}" + try: + favorite_number = int(favorite_number) + except ValueError: + return False, err_msg + if not 0 < favorite_number <= len(favorites): + return False, err_msg - # Call init code of parent class (SmartPlugin) - super().__init__(**kwargs) + # List must be sorted by title to match the output of 'list_favorites' + favorites.sort(key=lambda x: x.title) + the_fav = favorites[favorite_number - 1] + self.logger.info(f"Favorite number {favorite_number} is '{the_fav.title}'") - self._sh = sh - self.zero_zone = False # sometime a discovery scan fails, so try it two times; we need to save the state - self._sonos_dpt3_step = 2 # default value for dpt3 volume step (step(s) per time period) - self._sonos_dpt3_time = 1 # default value for dpt3 volume time (time period per step in seconds) - self._tts = self.to_bool(self.get_parameter_value("tts"), default=False) - self._local_webservice_path = self.get_parameter_value("local_webservice_path") - self._snippet_duration_offset = float(self.get_parameter_value("snippet_duration_offset")) - self.SoCo_nr_speakers = 0 - self.zones = {} - - from bin.smarthome import VERSION - if '.'.join(VERSION.split('.', 2)[:2]) <= '1.5': - self.logger = logging.getLogger(__name__) - #else: - # self.logger = logging.getLogger('sonos') # get a unique logger for the plugin and provide it internally + else: + the_fav = None + # Strict match + for f in favorites: + if favorite_title == f.title: + self.logger.info(f"Strict match '{f.title}' found") + the_fav = f + break + + # Fuzzy match + if not the_fav: + favorite_title = favorite_title.lower() + for f in favorites: + if favorite_title in f.title.lower(): + self.logger.info(f"Fuzzy match '{f.title}' found") + the_fav = f + break - self.logger.debug("init {} version: {ver}".format(__name__, ver='.'.join(VERSION.split('.', 2)[:2]))) + if the_fav: + # play_uri works for some favorites + try: + uri = the_fav.get_uri() + metadata = the_fav.resource_meta_data + self.logger.info(f"Trying 'play_uri()': URI={uri}, Metadata={metadata}") + self.soco.play_uri(uri=uri, meta=metadata) + return True, "" + except Exception as e: + e1 = e - # Exit if the required package(s) could not be imported - if not REQUIRED_PACKAGE_IMPORTED: - self.logger.error("{}: Unable to import required external python packages. Please install.".format(self.get_fullname())) + # Other favorites will be added to the queue, then played + try: + self.logger.info("Trying 'add_to_queue()'") + index = self.soco.add_to_queue(the_fav, as_next=True) + self.soco.play_from_queue(index, start=True) + return True, "" + except Exception as e2: + msg = f"1: {e1} | 2: {e2}" + return False, msg + msg = f"Favorite '{favorite_title}' not found" + return False, msg + + def play_favorite_title(self, favorite_title: str) -> bool: + """ + Play Sonos favorite by title + :param favorite_title: + :return: + """ + if not self._check_property(): return - - # see documentation: if no exclusive snippet path is set, we use the global one - local_webservice_path_snippet = self.get_parameter_value("local_webservice_path_snippet") - if local_webservice_path_snippet == '': - self._local_webservice_path_snippet = self._local_webservice_path + if not self.is_coordinator: + sonos_speaker[self.coordinator].play_favorite_title(favorite_title) else: - self._local_webservice_path_snippet = local_webservice_path_snippet - self.logger.debug("Set local webservice snippet path to {0}".format(self._local_webservice_path_snippet)) + self.logger.info(f"Playing favorite {favorite_title}") + result, msg = self._play_favorite(favorite_title=favorite_title) + if not result: + self.logger.warning(msg) + return False + return True - get_param_func = getattr(self, "get_parameter_value", None) - if callable(get_param_func): - speaker_ips = self.get_parameter_value("speaker_ips") + def play_favorite_number(self, favorite_number: int) -> bool: + """ + Play Sonos favorite by list index + :param favorite_number: + :return: + """ + if not self._check_property(): + return + if not self.is_coordinator: + sonos_speaker[self.coordinator].play_favorite_number(favorite_number) else: - speaker_ips = re.findall(r'[0-9]+(?:\.[0-9]+){3}', speaker_ips) + self.logger.info(f"Playing favorite number {favorite_number}") + result, msg = self._play_favorite(favorite_number=favorite_number) + if not result: + self.logger.warning(msg) + return False + return True - self._speaker_ips = [] - if speaker_ips: - self.logger.debug("Sonos: User-defined speaker IPs set. Auto-discover disabled.") - # check user specified sonos speaker ips - if speaker_ips: - for ip in speaker_ips: - if self.is_ip(ip): - self._speaker_ips.append(ip) - else: - self.logger.warning("Sonos: Invalid Sonos speaker ip '{ip}'. Ignoring.".format(ip=ip)) + def _play_favorite_radio(self, station_title: str = None, station_number: int = None, preset: int = 0, limit: int = 99) -> tuple: + """Core of the play_favorite_radio action, but doesn't exit on failure""" - # unique items in list - self._speaker_ips = utils.unique_list(self._speaker_ips) - auto_ip = utils.get_local_ip_address() - if auto_ip == '0.0.0.0': - self.logger.error("Automatic detection of local IP not sucessfull") - return + stations = self.soco.music_library.get_favorite_radio_stations(preset, limit) - webservice_ip = self.get_parameter_value("webservice_ip") - if not webservice_ip == '' and not webservice_ip =='0.0.0.0': - if self.is_ip(webservice_ip): - self._webservice_ip = webservice_ip - else: - self.logger.error("Your webservice_ip parameter is invalid. '{ip}' is not a vaild ip address. " - "Disabling TTS.".format(ip=webservice_ip)) - self._tts = False + # get station_title by station_number + if station_number is not None: + err_msg = f"Favorite station number must be integer between 1 and {len(stations)}" + try: + station_number = int(station_number) + except ValueError: + return False, err_msg + if not 0 < station_number <= len(stations): + return False, err_msg + + # List must be sorted by title to match the output of 'list_favorites' + station_titles = sorted([s.title for s in stations]) + self.logger.info(f"Sorted station titles are: {station_titles}") + + station_title = station_titles[station_number - 1] + self.logger.info(f"Requested station is '{station_title}'") + + # get station object + the_fav = None + # Strict match + for f in stations: + if station_title == f.title: + self.logger.info(f"Strict match '{f.title}' found") + the_fav = f + break + + # Fuzzy match + station_title = station_title.lower() + if not the_fav: + for f in stations: + if station_title in f.title.lower(): + self.logger.info(f"Fuzzy match '{f.title}' found") + the_fav = f + break + + # set to play + if the_fav: + uri = the_fav.get_uri() + meta_template = """ + + + {title} + object.item.audioItem.audioBroadcast + + {service} + + + ' + """ + tunein_service = "SA_RINCON65031_" + uri = uri.replace("&", "&") + metadata = meta_template.format(title=the_fav.title, service=tunein_service) + self.logger.info(f"Trying 'play_uri()': URI={uri}, Metadata={metadata}") + self.soco.play_uri(uri=uri, meta=metadata) + return True, "" + + msg = f"Favorite Radio Station '{station_title}' not found" + return False, msg + + def play_favorite_radio_number(self, station_number: int) -> bool: + """ + Play Sonos radio favorite by list index + :param station_number: + :return: + """ + if not self._check_property(): + return + if not self.is_coordinator: + sonos_speaker[self.coordinator].play_favorite_radio_number(station_number) else: - self._webservice_ip = auto_ip - self.logger.debug("Webservice IP is not specified. Using auto IP instead ({0}).".format(self._webservice_ip)) + self.logger.info(f"Playing favorite station number {station_number}") + result, msg = self._play_favorite_radio(station_number=station_number) + if not result: + self.logger.warning(msg) + return False + return True - webservice_port = self.get_parameter_value("webservice_port") - if utils.is_valid_port(str(webservice_port)): - self._webservice_port = int(webservice_port) - if not utils.is_open_port(self._webservice_port): - self.logger.error("Sonos: Your chosen webservice port {port} is already in use. " - "TTS disabled!".format(port=self._webservice_port)) - self._tts = False + def play_favorite_radio_title(self, station_title: str) -> bool: + """ + Play Sonos favorite radio station by title + :param station_title: + :return: + """ + if not self._check_property(): + return + if not self.is_coordinator: + sonos_speaker[self.coordinator].play_favorite_radio_title(station_title) else: - self.logger.error("Sonos: Your webservice_port parameter is invalid. '{port}' is not within port range " - "1024-65535. TTS disabled!".format(port=webservice_port)) - self._tts = False + self.logger.info(f"Playing radio favorite {station_title}") + result, msg = self._play_favorite_radio(station_title=station_title) + if not result: + self.logger.warning(msg) + return False + return True - self.webservice = None - if self._tts: - if self._local_webservice_path_snippet: - # we just need an existing path with read rights, this can be done by the user while shNG is running - # just throw some warnings - if not os.path.exists(self._local_webservice_path_snippet): - self.logger.warning("Sonos: Local webservice snippet path was set to '{path}' but doesn't " - "exists".format(path=self._local_webservice_path_snippet)) - if not os.access(self._local_webservice_path_snippet, os.R_OK): - self.logger.warning("Sonos: Local webservice snippet path '{path}' is not readable.".format( - path=self._local_webservice_path_snippet)) - - if self._local_webservice_path: - # check access rights - try: - os.makedirs(self._local_webservice_path, exist_ok=True) - if os.path.exists(self._local_webservice_path): - self.logger.debug("Sonos: Local webservice path set to '{path}'".format( - path=self._local_webservice_path)) - if os.access(self._local_webservice_path, os.W_OK): - self.logger.debug("Sonos: Write permissions ok for tts on path {path}".format( - path=self._local_webservice_path)) - - free_diskspace = get_free_diskspace(self._local_webservice_path) - human_readable_diskspace = file_size(free_diskspace) - self.logger.debug("Sonos: Free diskspace: {disk}".format(disk=human_readable_diskspace)) - - self._webservice_url = "http://{ip}:{port}".format(ip=self._webservice_ip, - port=self._webservice_port) - self.logger.debug("Sonos: Starting webservice for TTS on {url}".format( - url=self._webservice_url)) - self.webservice = SimpleHttpServer(self._webservice_ip, - self._webservice_port, - self._local_webservice_path, - self._local_webservice_path_snippet) - -# self.logger.debug("Sonos Debug: ip {0}, port {1}, path {2}, snippet {3}".format(self._webservice_ip, -# self._webservice_port, -# self._local_webservice_path, -# self._local_webservice_path_snippet)) - self.webservice.start() - else: - self.logger.warning( - "Sonos: Local webservice path '{path}' is not writeable for current user. " - "TTS disabled!".format(path=self._local_webservice_path)) - else: - self.logger.warning("Sonos: Local webservice path '{path}' for TTS not exists. " - "TTS disabled!".format(path=self._local_webservice_path)) - except OSError: - self.logger.warning("Sonos: Could not create local webserver path '{path}'. Wrong permissions? " - "TTS disabled!".format(path=self._local_webservice_path)) - else: - self.logger.debug("Sonos: Local webservice path for TTS has to be set. TTS disabled!") - else: - self.logger.debug("Sonos: TTS disabled") - - self._discover_cycle = int(self.get_parameter_value("discover_cycle")) - self.logger.info("Sonos: Setting discover cycle to {val} seconds.".format(val=self._discover_cycle)) - # Read SoCo Version: - src = io.open('plugins/sonos/soco/__init__.py', encoding='utf-8').read() - metadata = dict(re.findall("__([a-z]+)__ = \"([^\"]+)\"", src)) - self.SoCo_version = metadata['version'] - self.logger.info("Loading SoCo version {0}.".format(self.SoCo_version)) +class Sonos(SmartPlugin): + """ + Main class of the Plugin. Does all plugin specific stuff + """ + PLUGIN_VERSION = "1.8.1" + + def __init__(self, sh): + """Initializes the plugin.""" - # Configure log level of different SoCo modules: - #logging.getLogger('plugins.sonos.soco.events_base').setLevel(logging.WARNING) - #logging.getLogger('plugins.sonos.soco.events').setLevel(logging.WARNING) - logging.getLogger('plugins.sonos.soco.discovery').setLevel(logging.WARNING) - logging.getLogger('plugins.sonos.soco.services').setLevel(logging.WARNING) - self.logger.info("Set all SoCo loglevel to WARNING") + # call init code of parent class (SmartPlugin) + super().__init__() - self.init_webinterface() + # get the parameters for the plugin (as defined in metadata plugin.yaml): + try: + self._tts = self.get_parameter_value("tts") + self._snippet_duration_offset = float(self.get_parameter_value("snippet_duration_offset")) + self._discover_cycle = self.get_parameter_value("discover_cycle") + self.webif_pagelength = self.get_parameter_value('webif_pagelength') + local_webservice_path = self.get_parameter_value("local_webservice_path") + local_webservice_path_snippet = self.get_parameter_value("local_webservice_path_snippet") + webservice_ip = self.get_parameter_value("webservice_ip") + webservice_port = self.get_parameter_value("webservice_port") + speaker_ips = self.get_parameter_value("speaker_ips") + except KeyError as e: + self.logger.critical(f"Plugin '{self.get_shortname()}': Inconsistent plugin (invalid metadata definition: {e} not defined)") + self._init_complete = False + return + + # define further properties + self.zero_zone = False # sometimes a discovery scan fails, so try it two times; we need to save the state + self._sonos_dpt3_step = 2 # default value for dpt3 volume step (step(s) per time period) + self._sonos_dpt3_time = 1 # default value for dpt3 volume time (time period per step in seconds) + self.SoCo_nr_speakers = 0 # number of discovered online speaker / zones + self._uid_lookup_levels = 4 # iterations of return_parent() on lookup for item uid + self._speaker_ips = [] # list of fixed speaker ips + self.zones = {} # dict to hold zone information via soco objects + self.item_list = [] # list of all items, used by / linked to that plugin + self.alive = False # plugin alive property + self.webservice = None # webservice thread + + # handle fixed speaker ips + if speaker_ips: + self.logger.debug("User-defined speaker IPs set. Auto-discover disabled.") + self._speaker_ips = self._parse_speaker_ips(speaker_ips) + + # init TTS + if self._tts: + if self._init_tts(webservice_ip, webservice_port, local_webservice_path, local_webservice_path_snippet): + self.logger.info(f"TTS successful enabled") + else: + self.logger.info(f"TTS initialisation failed.") + + # read SoCo version: + self.SoCo_version = self.get_soco_version() + self.logger.info(f"Loading SoCo version {self.SoCo_version}.") + + # configure log level of SoCo modules: + self._set_soco_logger('WARNING') + + # init webinterface + self.init_webinterface(WebInterface) + return def run(self): self.logger.debug("Run method called") - self._sh.scheduler.add("sonos_discover_scheduler", self._discover, prio=3, cron=None, - cycle=self._discover_cycle, value=None, offset=None, next=None) + + # do initial speaker discovery and set scheduler + self._discover() + self.scheduler_add("sonos_discover_scheduler", self._discover, prio=3, cron=None, cycle=self._discover_cycle, value=None, offset=None, next=None) + + # set plugin to alive self.alive = True def stop(self): - self.logger.debug("Sonos: stop method called") + self.logger.debug("Stop method called") + if self.webservice: self.webservice.stop() - self.scheduler_remove('sonos_discover_scheduler') + + if self.scheduler_get('sonos_discover_scheduler'): + self.scheduler_remove('sonos_discover_scheduler') + for uid, speaker in sonos_speaker.items(): speaker.dispose() + event_listener.stop() + self.alive = False def parse_item(self, item: Items) -> object: @@ -2615,37 +2863,40 @@ def parse_item(self, item: Items) -> object: uid = None if self.has_iattr(item.conf, 'sonos_recv') or self.has_iattr(item.conf, 'sonos_send'): - self.logger.debug("parse item: {0}".format(item)) + self.logger.debug(f"parse item: {item.id()}") # get uid from parent item uid = self._resolve_uid(item) if not uid: - self.logger.error("Sonos: No uid found for {item}.".format(item=item)) + self.logger.error(f"No uid found for {item.id()}.") return if self.has_iattr(item.conf, 'sonos_recv'): # create Speaker instance if not exists - _initialize_speaker(uid, self.logger) + _initialize_speaker(uid, self.logger, self.get_shortname()) # to make code smaller, map sonos_cmd value to the Speaker property by name - item_name = self.get_iattr_value(item.conf, 'sonos_recv') + item_attribute = self.get_iattr_value(item.conf, 'sonos_recv') + list_name = f"{item_attribute}_items" try: - list_name = '{item_name}_items'.format(item_name=item_name) attr = getattr(sonos_speaker[uid], list_name) - self.logger.debug( - "Sonos: Adding item {item} to {uid}: list {list}".format(item=item, uid=uid, list=list_name)) + self.logger.debug(f"Adding item {item.id()} to {uid}: list {list_name}") attr.append(item) - except: - self.logger.warning("Sonos: No item list available for sonos_cmd '{item_name}'." - .format(item_name=item_name)) + if item not in self.item_list: + self.item_list.append(item) + except Exception: + self.logger.warning(f"No item list available for sonos_cmd '{item_attribute}'.") if self.has_iattr(item.conf, 'sonos_send'): - self.logger.debug("Sonos: {item} registered to send Sonos commands.".format(item=item)) + self.logger.debug(f"Item {item.id()} registered to 'sonos_send' commands.") + if item not in self.item_list: + self.item_list.append(item) return self.update_item # some special handling for dpt3 volume - if self.has_iattr(item.conf, 'sonos_attrib'): if self.get_iattr_value(item.conf, 'sonos_attrib') != 'vol_dpt3': + if item not in self.item_list: + self.item_list.append(item) return # check, if a volume parent item exists @@ -2654,9 +2905,9 @@ def parse_item(self, item: Items) -> object: if parent_item is not None: if self.has_iattr(parent_item.conf, 'sonos_recv'): if self.get_iattr_value(parent_item.conf, 'sonos_recv').lower() != 'volume': - self.logger.warning("Sonos: volume_dpt3 item has no volume parent item. Ignoring!") + self.logger.warning("volume_dpt3 item has no volume parent item. Ignoring!") else: - self.logger.warning("Sonos: volume_dpt3 item has no volume parent item. Ignoring!") + self.logger.warning("volume_dpt3 item has no volume parent item. Ignoring!") return item.conf['volume_parent'] = parent_item @@ -2670,25 +2921,25 @@ def parse_item(self, item: Items) -> object: break if child_helper is None: - self.logger.warning("Sonos: volume_dpt3 item has no helper item. Ignoring!") + self.logger.warning("volume_dpt3 item has no helper item. Ignoring!") return item.conf['helper'] = child_helper if not self.has_iattr(item.conf, 'sonos_dpt3_step'): item.conf['sonos_dpt3_step'] = self._sonos_dpt3_step - self.logger.debug("Sonos: No sonos_dpt3_step defined, using default value {step}.". - format(step=self._sonos_dpt3_step)) + self.logger.debug(f"No sonos_dpt3_step defined, using default value {self._sonos_dpt3_step}.") if not self.has_iattr(item.conf, 'sonos_dpt3_time'): item.conf['sonos_dpt3_time'] = self._sonos_dpt3_time - self.logger.debug("Sonos: no sonos_dpt3_time defined, using default value {time}.". - format(time=self._sonos_dpt3_time)) + self.logger.debug(f"No sonos_dpt3_time defined, using default value {self._sonos_dpt3_time}.") + if item not in self.item_list: + self.item_list.append(item) return self._handle_dpt3 def _handle_dpt3(self, item, caller=None, source=None, dest=None): - if caller != 'Sonos': + if caller != self.get_shortname(): volume_item = self.get_iattr_value(item.conf, 'volume_parent') volume_helper = self.get_iattr_value(item.conf, 'helper') vol_max = self._resolve_max_volume_command(item) @@ -2717,10 +2968,190 @@ def _handle_dpt3(self, item, caller=None, source=None, dest=None): volume_helper(int(volume_helper() + 1)) volume_helper(int(volume_helper() - 1)) + def _check_webservice_ip(self, webservice_ip: str) -> bool: + if not webservice_ip == '' and not webservice_ip == '0.0.0.0': + if self.is_ip(webservice_ip): + self._webservice_ip = webservice_ip + else: + self.logger.error(f"Your webservice_ip parameter is invalid. '{webservice_ip}' is not a valid ip address. Disabling TTS.") + return False + else: + auto_ip = utils.get_local_ip_address() + if auto_ip == '0.0.0.0': + self.logger.error("Automatic detection of local IP not successful.") + return False + self._webservice_ip = auto_ip + self.logger.debug(f"Webservice IP is not specified. Using auto IP instead ({self._webservice_ip}).") + + return True + + def _check_webservice_port(self, webservice_port: int) -> bool: + if utils.is_valid_port(str(webservice_port)): + self._webservice_port = int(webservice_port) + if not utils.is_open_port(self._webservice_port): + self.logger.error(f"Your chosen webservice port '{self._webservice_port}' is already in use. TTS disabled!") + return False + else: + self.logger.error(f"Your webservice_port parameter is invalid. '{webservice_port}' is not within port range 1024-65535. TTS disabled!") + return False + + return True + + def _check_local_webservice_path(self, local_webservice_path: str) -> bool: + + # if path is not given, raise error log and disable TTS + if local_webservice_path == '': + self.logger.warning(f"Mandatory path for local webserver for TTS not given in Plugin parameters. TTS disabled!") + return False + + # if path is given, check avilability, create and check access rights + try: + os.makedirs(local_webservice_path, exist_ok=True) + except OSError: + self.logger.warning(f"Could not create local webserver path '{local_webservice_path}'. Wrong permissions? TTS disabled!") + return False + else: + if os.path.exists(local_webservice_path): + self.logger.debug(f"Local webservice path set to '{local_webservice_path}'") + else: + self.logger.warning(f"Local webservice path '{local_webservice_path}' for TTS not exists. TTS disabled!") + return False + + if os.access(local_webservice_path, os.W_OK): + self.logger.debug(f"Write permissions ok for tts on path {local_webservice_path}") + self._local_webservice_path = local_webservice_path + else: + self.logger.warning(f"Local webservice path '{local_webservice_path}' is not writeable for current user. TTS disabled!") + return False + + return True + + def _check_local_webservice_path_snippet(self, local_webservice_path_snippet: str) -> bool: + + # if path is not given, set local_webservice_path_snippet to _local_webservice_path + if local_webservice_path_snippet == '': + self._local_webservice_path_snippet = self._local_webservice_path + return True + + # if path is given, check avilability, create and check access rights + try: + os.makedirs(local_webservice_path_snippet, exist_ok=True) + except OSError: + self.logger.warning(f"Could not create local webserver path for snippets '{local_webservice_path_snippet}'. Wrong permissions? TTS disabled!") + return False + else: + if os.path.exists(local_webservice_path_snippet): + self.logger.debug(f"Local webservice path for snippets set to '{local_webservice_path_snippet}'") + else: + self.logger.warning(f"Local webservice path for snippets '{local_webservice_path_snippet}' for TTS not exists. TTS disabled!") + return False + + if os.access(local_webservice_path_snippet, os.W_OK): + self.logger.debug(f"Write permissions ok for tts on path {local_webservice_path_snippet}") + self._local_webservice_path_snippet = local_webservice_path_snippet + else: + self.logger.warning(f"Local webservice path for snippets '{local_webservice_path_snippet}' is not writeable for current user. TTS disabled!") + return False + + return True + + def _get_free_diskspace(self) -> None: + """ + get free diskspace and put it to logger + :return: + """ + + free_diskspace = utils.get_free_diskspace(self._local_webservice_path) + human_readable_diskspace = utils.file_size(free_diskspace) + self.logger.debug(f"Free diskspace: {human_readable_diskspace}") + + def _init_webservice(self) -> None: + """ + Init the Webservice-Server + :return: + """ + + self._webservice_url = f"http://{self._webservice_ip}:{self._webservice_port}" + self.logger.debug(f"Starting webservice for TTS on {self._webservice_url}") + self.webservice = SimpleHttpServer(self._webservice_ip, + self._webservice_port, + self._local_webservice_path, + self._local_webservice_path_snippet) + self.logger.debug(f"Webservice init done with: ip={self._webservice_ip}, port={self._webservice_port}, path={self._local_webservice_path}, snippet_path={self._local_webservice_path_snippet}") + self.webservice.start() + + def _init_tts(self, webservice_ip: str, webservice_port: int, local_webservice_path: str, local_webservice_path_snippet: str) -> bool: + """ + Init the TTS service + :param webservice_ip: + :param webservice_port: + :param local_webservice_path: + :param local_webservice_path_snippet: + :return: + """ + # Check local webservice settings + if not (self._check_webservice_ip(webservice_ip) and + self._check_webservice_port(webservice_port) and + self._check_local_webservice_path(local_webservice_path) and + self._check_local_webservice_path_snippet(local_webservice_path_snippet)): + self.logger.warning(f"Local webservice settings not correct. TTS disabled.") + return False + + # Check diskspace + self._get_free_diskspace() + + # Init webservice + self._init_webservice() + + return True + + def _parse_speaker_ips(self, speaker_ips: list) -> list: + """ + check user specified sonos speaker ips + """ + + for ip in speaker_ips: + if self.is_ip(ip): + self._speaker_ips.append(ip) + else: + self.logger.warning(f"Invalid Sonos speaker ip '{ip}'. Ignoring.") + # return unique items in list + return utils.unique_list(self._speaker_ips) + + def get_soco_version(self) -> str: + """ + Get version of used Soco and return it + """ + + try: + src = io.open('plugins/sonos/soco/__init__.py', encoding='utf-8').read() + metadata = dict(re.findall("__([a-z]+)__ = \"([^\"]+)\"", src)) + except Exception: + self.logger.warning(f"Version of used Soco module not available") + return '' + else: + soco_version = metadata['version'] + return soco_version + + def _set_soco_logger(self, level: str = 'WARNING') -> None: + """ + set all soco loggers to given level + """ + + level = level.upper() + log_level = logging.getLevelName(level) + + logging.getLogger('plugins.sonos.soco.events_base').setLevel(log_level) + logging.getLogger('plugins.sonos.soco.events').setLevel(log_level) + logging.getLogger('plugins.sonos.soco.discovery').setLevel(log_level) + logging.getLogger('plugins.sonos.soco.services').setLevel(log_level) + + self.logger.info(f"Set all SoCo loglevel to {level}") + def parse_logic(self, logic): pass - def update_item(self, item: Items, caller: object = str, source: object = str, dest: object = str) -> None: + def update_item(self, item: Items, caller: object, source: object, dest: object) -> None: """ Write items values :param item: item to be updated towards the plugin @@ -2728,105 +3159,122 @@ def update_item(self, item: Items, caller: object = str, source: object = str, d :param source: if given it represents the source :param dest: if given it represents the dest """ - if caller != 'Sonos': + + if self.alive and caller != self.get_fullname(): if self.has_iattr(item.conf, 'sonos_send'): - # get uid from parent item uid = self._resolve_uid(item) - if not uid: - self.logger.error("Sonos: No uid found for {item}.".format(item=item)) - return - command = self.get_iattr_value(item.conf, "sonos_send").lower() if command == "play": - if item(): - sonos_speaker[uid].set_play() - else: - sonos_speaker[uid].set_pause() - if command == "stop": - if item(): - sonos_speaker[uid].set_stop() - else: - sonos_speaker[uid].set_play() - if command == "pause": - if item(): - sonos_speaker[uid].set_pause() - else: - sonos_speaker[uid].set_play() - if command == "mute": + sonos_speaker[uid].set_play() if item() else sonos_speaker[uid].set_pause() + elif command == "stop": + sonos_speaker[uid].set_stop() if item() else sonos_speaker[uid].set_play() + elif command == "pause": + sonos_speaker[uid].set_pause() if item() else sonos_speaker[uid].set_play() + elif command == "mute": sonos_speaker[uid].set_mute(item()) - if command == "status_light": + elif command == "status_light": sonos_speaker[uid].set_status_light(item()) - if command == "volume": + elif command == "volume": group_command = self._resolve_group_command(item) max_volume = self._resolve_max_volume_command(item) sonos_speaker[uid].set_volume(item(), group_command, max_volume) - if command == "bass": + elif command == "bass": group_command = self._resolve_group_command(item) sonos_speaker[uid].set_bass(item(), group_command) - if command == "treble": + elif command == "treble": group_command = self._resolve_group_command(item) sonos_speaker[uid].set_treble(item(), group_command) - if command == "loudness": + elif command == "loudness": group_command = self._resolve_group_command(item) sonos_speaker[uid].set_loudness(item(), group_command) - if command == "night_mode": + elif command == "night_mode": sonos_speaker[uid].set_night_mode(item()) - if command == "buttons_enabled": + elif command == "buttons_enabled": sonos_speaker[uid].set_buttons_enabled(item()) - if command == "dialog_mode": + elif command == "dialog_mode": sonos_speaker[uid].set_dialog_mode(item()) - if command == "cross_fade": + elif command == "cross_fade": sonos_speaker[uid].set_cross_fade(item()) - if command == "snooze": + elif command == "snooze": sonos_speaker[uid].set_snooze(item()) - if command == "play_mode": + elif command == "play_mode": sonos_speaker[uid].set_play_mode(item()) - if command == "next": + elif command == "next": sonos_speaker[uid].set_next(item()) - if command == "previous": + elif command == "previous": sonos_speaker[uid].set_previous(item()) - if command == "switch_linein": - if item(): - sonos_speaker[uid].switch_to_line_in() - if command == "switch_tv": - if item(): - sonos_speaker[uid].switch_to_tv() - if command == "play_tunein": + elif command == "switch_linein": + sonos_speaker[uid].switch_to_line_in() if item() else None + elif command == "switch_tv": + sonos_speaker[uid].switch_to_tv() if item() else None + elif command == "play_tunein": start = self._resolve_child_command_bool(item, 'start_after') sonos_speaker[uid].play_tunein(item(), start) - if command == "play_url": + elif command == "play_url": start = self._resolve_child_command_bool(item, 'start_after') sonos_speaker[uid].play_url(item(), start) - if command == "join": + elif command == "play_sharelink": + start = self._resolve_child_command_bool(item, 'start_after') + sonos_speaker[uid].play_sharelink(item(), start) + elif command == "join": sonos_speaker[uid].join(item()) - if command == "unjoin": + elif command == "unjoin": start = self._resolve_child_command_bool(item, 'start_after') sonos_speaker[uid].unjoin(item(), start) - if command == 'load_sonos_playlist': + elif command == 'load_sonos_playlist': start = self._resolve_child_command_bool(item, 'start_after') clear_queue = self._resolve_child_command_bool(item, 'clear_queue') track = self._resolve_child_command_int(item, 'start_track') sonos_speaker[uid].load_sonos_playlist(item(), start, clear_queue, track) - if command == 'play_tts': + + elif command == 'play_tts': if item() == "": + self.logger.error("No item value when executing 'play_tts' command") return language = self._resolve_child_command_str(item, 'tts_language', 'de') volume = self._resolve_child_command_int(item, 'tts_volume', -1) fade_in = self._resolve_child_command_bool(item, 'tts_fade_in') - sonos_speaker[uid].play_tts(item(), language, self._local_webservice_path, self._webservice_url, - volume, self._snippet_duration_offset, fade_in) - if command == 'play_snippet': + sonos_speaker[uid].play_tts(item(), language, self._local_webservice_path, self._webservice_url, volume, self._snippet_duration_offset, fade_in) + + elif command == 'play_snippet': if item() == "": - self.logger.error("No item value when executing play_snippet command") + self.logger.error("No item value when executing 'play_snippet' command") return volume = self._resolve_child_command_int(item, 'snippet_volume', -1) - self.logger.debug(f"Debug: play_snippet on uid {uid} with volume {volume}") + self.logger.debug(f"play_snippet on uid {uid} with volume {volume}") fade_in = self._resolve_child_command_bool(item, 'snippet_fade_in') - sonos_speaker[uid].play_snippet(item(), self._local_webservice_path_snippet, self._webservice_url, volume, self._snippet_duration_offset, - fade_in) + sonos_speaker[uid].play_snippet(item(), self._local_webservice_path_snippet, self._webservice_url, volume, self._snippet_duration_offset, fade_in) + + elif command == 'play_favorite_title': + if item() == "": + self.logger.error("No item value when executing 'play_favorite_title' command") + return + sonos_speaker[uid].play_favorite_title(item()) + + elif command == 'play_favorite_number': + if item() == "": + self.logger.error("No item value when executing 'play_favorite_number' command") + return + sonos_speaker[uid].play_favorite_number(item()) - def _resolve_child_command_str(self, item: Items, child_command, default_value="") -> str: + elif command == 'play_favorite_radio_number': + if item() == "": + self.logger.error("No item value when executing 'play_favorite_radio_number' command") + return + sonos_speaker[uid].play_favorite_radio_number(item()) + + elif command == 'play_favorite_radio_title': + if item() == "": + self.logger.error("No item value when executing 'play_favorite_radio_title' command") + return + sonos_speaker[uid].play_favorite_radio_title(item()) + + elif command == "play_sonos_radio": + start = self._resolve_child_command_bool(item, 'start_after') + sonos_speaker[uid].play_sonos_radio(item(), start) + + def _resolve_child_command_str(self, item: Items, child_command: str, default_value: str = "") -> str: """ Resolves a child command of type str for an item :type child_command: The sonos_attrib name for the child @@ -2835,6 +3283,7 @@ def _resolve_child_command_str(self, item: Items, child_command, default_value=" :rtype: str :return: String value of the child item or the given default value. """ + for child in item.return_children(): if self.has_iattr(child.conf, 'sonos_attrib'): if self.get_iattr_value(child.conf, 'sonos_attrib') == child_command: @@ -2843,7 +3292,7 @@ def _resolve_child_command_str(self, item: Items, child_command, default_value=" return child() return default_value - def _resolve_child_command_bool(self, item: Items, child_command) -> bool: + def _resolve_child_command_bool(self, item: Items, child_command: str) -> bool: """ Resolves a child command of type bool for an item :type child_command: The sonos_attrib name for the child @@ -2851,13 +3300,14 @@ def _resolve_child_command_bool(self, item: Items, child_command) -> bool: :rtype: bool :return: 'True' or 'False' """ + for child in item.return_children(): if self.has_iattr(child.conf, 'sonos_attrib'): if self.get_iattr_value(child.conf, 'sonos_attrib') == child_command: return child() return False - def _resolve_child_command_int(self, item: Items, child_command, default_value=0) -> int: + def _resolve_child_command_int(self, item: Items, child_command: str, default_value: int = 0) -> int: """ Resolves a child command of type int for an item :type default_value: the default value, if the child not exists or an error occurred @@ -2866,23 +3316,24 @@ def _resolve_child_command_int(self, item: Items, child_command, default_value=0 :rtype: int :return: value as int or if no item was found the given default value """ + try: for child in item.return_children(): if self.has_iattr(child.conf, 'sonos_attrib'): if self.get_iattr_value(child.conf, 'sonos_attrib') == child_command: return int(child()) return default_value - except: - self.logger.warning("Sonos: Could not cast value [{val}] to 'int', using default value '0'") + except Exception: + self.logger.warning(f"Could not cast value [{child()}] to 'int', using default value '0'") return default_value def _resolve_group_command(self, item: Items) -> bool: """ Resolves a group_command child for an item - :rtype: bool :param item: The item for which a child item is to be searched :return: 'True' or 'False' (whether the command should execute as a group command or not) """ + # special handling for dpt_volume if self.get_iattr_value(item.conf, 'sonos_attrib') == 'vol_dpt3': group_item = self.get_iattr_value(item.conf, 'volume_parent') @@ -2896,6 +3347,11 @@ def _resolve_group_command(self, item: Items) -> bool: return False def _resolve_max_volume_command(self, item: Items) -> int: + """ + Resolves a max_volume_command child for an item + :param item: + :return: + """ if self.get_iattr_value(item.conf, 'sonos_attrib') == 'vol_dpt3': volume_item = self.get_iattr_value(item.conf, 'volume_parent') @@ -2908,52 +3364,54 @@ def _resolve_max_volume_command(self, item: Items) -> int: try: return int(child()) except Exception as ex: - self.logger.error(ex) + self.logger.error(f":_resolve_max_volume_command: Error {ex} occurred.") return -1 return -1 def _resolve_uid(self, item: Items) -> str: """ - Tries to find the uuid (typically the parent item) of an item - :rtype: str - :param item: item to search for the uuid - :return: the speakers uuid + Get UID of device from item.conf """ - parent_item = None - # some special handling for dpt3 helper item - if self.has_iattr(item.conf, 'sonos_attrib'): - if self.get_iattr_value(item.conf, 'sonos_attrib').lower() == 'dpt3_helper': - parent_item = item.return_parent().return_parent().return_parent() - else: - parent_item = item.return_parent() - if parent_item is not None: - if self.has_iattr(parent_item.conf, 'sonos_uid'): - return self.get_iattr_value(parent_item.conf, 'sonos_uid').lower() + uid = '' + + lookup_item = item + for i in range(self._uid_lookup_levels): + uid = self.get_iattr_value(lookup_item.conf, 'sonos_uid') + if uid is not None: + uid = uid.lower() + break + else: + lookup_item = lookup_item.return_parent() + if lookup_item is None: + break + + if uid == '': + self.logger.warning(f"Could not resolve sonos_uid for item {item.id()}") - self.logger.warning("Sonos: could not resolve sonos_uid for item {item}".format(item=item)) - return '' + return uid - def _discover(self) -> None: + def _discover(self, force: bool = False) -> None: """ - Discover Sonos speaker in the network. If the plugin parameter 'speaker_ips' has IP addresses, no discover - package is sent over the network. + Discover Sonos speaker in the network. If the plugin parameter 'speaker_ips' has IP addresses, no discover package is sent over the network. :rtype: None """ - self.logger.info("Debug: Start discover fct") + + self.logger.debug("Start discover function") online_speaker_count = 0 handled_speaker = {} - zones = [] - if self._speaker_ips: + + # Create Soco objects if IPs are given, otherwise discover speakers and create Soco objects + if self._speaker_ips and not force: for ip in self._speaker_ips: zones.append(SoCo(ip)) else: try: zones = soco.discover(timeout=5) except Exception as e: - self.logger.error("Exception during soco discover function: %s" % str(e)) + self.logger.error(f"Exception during soco discover function: {e}") return self.zones = zones @@ -2966,15 +3424,14 @@ def _discover(self) -> None: # 2. attempt: ok, no speaker found, go on if not zones: if not self.zero_zone: - self.logger.info("Debug: No speaker found (1. attempt), ignoring speaker handling.") + self.logger.debug("No speaker found (1. attempt), ignoring speaker handling.") self.zero_zone = True return - self.logger.info("Debug: No speaker found.") + self.logger.debug("No speaker found.") self.zero_zone = False for zone in zones: # Trying to extract Speaker ID (UID). Skip speaker otherwise: - try: uid = zone.uid except requests.ConnectionError as e: @@ -2993,22 +3450,9 @@ def _discover(self) -> None: continue uid = uid.lower() - self.logger.debug(f"Pinging speaker {uid} with ip {zone.ip_address}") - # don't trust the discover function, offline speakers can be cached - # we try to ping the speaker - try: - proc_result = subprocess.run(['ping', '-i', '0.2', '-c', '2', zone.ip_address], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=1) - is_up = True - except subprocess.CalledProcessError: - self.logger.warning(f"Debug: Ping {zone.ip_address} process finished with return code {proc_result.returncode}") - is_up = False - except subprocess.TimeoutExpired: - self.logger.debug(f"Ping {zone.ip_address} process timed out") - is_up = False - - if is_up: - self.logger.info("Debug: Speaker found: {zone}, {uid}".format(zone=zone.ip_address, uid=uid)) + + if self._is_speaker_up(uid, zone.ip_address): + self.logger.debug(f"Speaker found: {zone.ip_address}, {uid}") online_speaker_count = online_speaker_count + 1 if uid in sonos_speaker: try: @@ -3017,19 +3461,18 @@ def _discover(self) -> None: self.logger.warning(f"Exception in discover -> sonos_speaker[uid].soco: {e}") else: if zone is not zone_compare: - self.logger.info("Debug: zone is not in speaker list jet. Adding and subscribing zone {0}".format(zone)) + self.logger.debug(f"zone is not in speaker list, yet. Adding and subscribing zone {zone}.") sonos_speaker[uid].soco = zone sonos_speaker[uid].subscribe_base_events() else: - self.logger.info("Debug: SoCo instance {0} already initiated, skipping.".format(zone)) -# # The following check subscriptions functions triggers an unsubscribe/subscribe. However, this causes -# # a massive memory leak increasing with every check_subscription call. -# self.logger.info("Debug: checking subscriptions") -# sonos_speaker[uid].check_subscriptions() - + self.logger.debug(f"SoCo instance {zone} already initiated, skipping.") + # The following check subscriptions functions triggers an unsubscribe/subscribe. However, this causes + # a massive memory leak increasing with every check_subscription call. + # self.logger.debug("checking subscriptions") + # sonos_speaker[uid].check_subscriptions() else: - self.logger.warning(f"Debug: Initializing new speaker with uid={uid} and ip={zone.ip_address}") - _initialize_speaker(uid, self.logger) + self.logger.warning(f"Initializing new speaker with uid={uid} and ip={zone.ip_address}") + _initialize_speaker(uid, self.logger, self.get_shortname()) sonos_speaker[uid].soco = zone sonos_speaker[uid].is_initialized = True @@ -3038,77 +3481,75 @@ def _discover(self) -> None: else: # Speaker is not online. Disposing... if sonos_speaker[uid].soco is not None: - self.logger.info( - "Debug: Disposing offline speaker: {zone}, {uid}".format(zone=zone.ip_address, uid=uid)) + self.logger.debug(f"Disposing offline speaker: {zone.ip_address}, {uid}") sonos_speaker[uid].dispose() else: - self.logger.info( - "Debug: Ignoring offline speaker: {zone}, {uid}".format(zone=zone.ip_address, uid=uid)) + self.logger.debug(f"Ignoring offline speaker: {zone.ip_address}, {uid}") sonos_speaker[uid].is_initialized = False if uid in sonos_speaker: - self.logger.info("Debug: setting {0}, uid {1} to handled speaker".format(zone.ip_address,uid)) + self.logger.debug(f"setting {zone.ip_address}, uid {uid} to handled speaker") handled_speaker[uid] = sonos_speaker[uid] else: - self.logger.info("Debug: ip {0}, uid {1} is not in sonos_speaker".format(zone.ip_address, uid)) + self.logger.debug(f"ip {zone.ip_address}, uid {uid} is not in sonos_speaker") # dispose every speaker that was not found for uid in set(sonos_speaker.keys()) - set(handled_speaker.keys()): if sonos_speaker[uid].soco is not None: - self.logger.info( - "Debug: Removing undiscovered speaker: {zone}, {uid}".format(zone=zone.ip_address, uid=uid)) + self.logger.debug(f"Removing undiscovered speaker: {sonos_speaker[uid].ip_address}, {uid}") sonos_speaker[uid].dispose() # Extract number of online speakers: self.SoCo_nr_speakers = online_speaker_count - - def init_webinterface(self): - """" - Initialize the web interface for this plugin - - This method is only needed if the plugin is implementing a web interface + def _is_speaker_up(self, uid: str, ip_address: str) -> bool: + """ + Check if speaker is available via Ping + Note: don't trust the discover function, offline speakers can be cached, we try to ping the speaker + + :param uid: + :param ip_address: + :return: """ - try: - self.mod_http = Modules.get_instance().get_module( - 'http') # try/except to handle running in a core version that does not support modules - except: - self.mod_http = None - if self.mod_http == None: - self.logger.error("Not initializing the web interface") - return False - import sys - if not "SmartPluginWebIf" in list(sys.modules['lib.model.smartplugin'].__dict__): - self.logger.warning("Web interface needs SmartHomeNG v1.5 and up. Not initializing the web interface") + self.logger.debug(f"Pinging speaker {uid} with ip {ip_address}") + try: + proc_result = subprocess.run(['ping', '-i', '0.2', '-c', '2', ip_address], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=1) + return True + except subprocess.CalledProcessError: + self.logger.debug(f"Ping {ip_address} process finished with return code {proc_result.returncode}") return False + except subprocess.TimeoutExpired: + self.logger.debug(f"Ping {ip_address} process timed out") + return False - # set application configuration for cherrypy - webif_dir = self.path_join(self.get_plugin_dir(), 'webif') - config = { - '/': { - 'tools.staticdir.root': webif_dir, - }, - '/static': { - 'tools.staticdir.on': True, - 'tools.staticdir.dir': 'static' - } - } - - # Register the web interface as a cherrypy app - self.mod_http.register_webif(WebInterface(webif_dir, self), - self.get_shortname(), - config, - self.get_classname(), self.get_instance_name(), - description='') + def _get_zone_name_from_uid(self, uid: str) -> str: + """ + Return zone/speaker name per uid + """ - return True + for zone in self.zones: + if zone._uid.lower() == uid.lower(): + return zone._player_name + @property + def sonos_speaker(self): + """ + Returns sonos_speaker dict + """ + return sonos_speaker + @property + def log_level(self): + """ + Returns current logging level + """ + return self.logger.getEffectiveLevel() -def _initialize_speaker(uid: str, logger: logging) -> None: +def _initialize_speaker(uid: str, logger: logging, plugin_shortname: str) -> None: """ Create a Speaker object by a given uuid :param uid: uid of the speaker @@ -3119,98 +3560,4 @@ def _initialize_speaker(uid: str, logger: logging) -> None: # if they have not been discovered yet by the sonos discovery function. with _create_speaker_lock: if uid not in sonos_speaker: - sonos_speaker[uid] = Speaker(uid=uid, logger=logger) - - - -# ------------------------------------------ -# Webinterface of the plugin -# ------------------------------------------ - -import cherrypy -from jinja2 import Environment, FileSystemLoader - - -class WebInterface(SmartPluginWebIf): - - def __init__(self, webif_dir, plugin): - """ - Initialization of instance of class WebInterface - - :param webif_dir: directory where the webinterface of the plugin resides - :param plugin: instance of the plugin - :type webif_dir: str - :type plugin: object - """ - self.logger = logging.getLogger(__name__) - self.webif_dir = webif_dir - self.plugin = plugin - self.tplenv = self.init_template_environment() - - self.items = Items.get_instance() - - @cherrypy.expose - def index(self, reload=None): - """ - Build index.html for cherrypy - - Render the template and return the html file to be delivered to the browser - - :return: contents of the template after beeing rendered - """ - - speakerlist = [] - - for zone in self.plugin.zones: - #self.logger.debug(f"vars(zone): {vars(zone)}") - speaker = dict() - try: - speaker['name'] = zone.player_name - except: - speaker['name'] = 'unknown' - - try: - speaker['ip'] = zone.ip_address - except: - speaker['ip'] = 'unknown' - - try: - speaker['uid'] = zone.uid - except: - speaker['uid'] = 'unknown' - - speakerlist.append(speaker) - - speakerlist_sorted = sorted(speakerlist, key=lambda k: k['name']) - - tmpl = self.tplenv.get_template('index.html') - # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) - return tmpl.render(p=self.plugin, items=sorted(self.items.return_items(), key=lambda k: str.lower(k['_path'])), speakerlist=speakerlist_sorted) - - - @cherrypy.expose - def get_data_html(self, dataSet=None): - """ - Return data to update the webpage - - For the standard update mechanism of the web interface, the dataSet to return the data for is None - - :param dataSet: Dataset for which the data should be returned (standard: None) - :return: dict with the data needed to update the web page. - """ - if dataSet is None: - # get the new data - data = {} - - # data['item'] = {} - # for i in self.plugin.items: - # data['item'][i]['value'] = self.plugin.getitemvalue(i) - # - # return it as json the the web page - # try: - # return json.dumps(data) - # except Exception as e: - # self.logger.error("get_data_html exception: {}".format(e)) - return {} - - + sonos_speaker[uid] = Speaker(uid=uid, logger=logger, plugin_shortname=plugin_shortname) diff --git a/sonos/examples/sonos.yaml b/sonos/examples/sonos.yaml deleted file mode 100755 index ae3f4641d..000000000 --- a/sonos/examples/sonos.yaml +++ /dev/null @@ -1,307 +0,0 @@ -MySonos: - Kueche: - sonos_uid: rincon_xxxxxxxxxxxxxxxxx - - is_initialized: - type: bool - sonos_recv: is_initialized - - volume: - type: num - sonos_recv: volume - sonos_send: volume - enforce_updates: true - - group_command: - type: bool - value: false - sonos_attrib: group - - max_volume: - type: num - value: -1 - sonos_attrib: max_volume - - volume_dpt3: - type: list - sonos_attrib: vol_dpt3 - sonos_dpt3_step: 2 - sonos_dpt3_time: 1 - - helper: - sonos_attrib: dpt3_helper - type: num - sonos_send: volume - - play: - type: bool - sonos_recv: play - sonos_send: play - - stop: - type: bool - sonos_recv: stop - sonos_send: stop - - pause: - type: bool - sonos_recv: pause - sonos_send: pause - - next: - type: bool - sonos_send: next - enforce_updates: true - - previous: - type: bool - sonos_send: previous - enforce_updates: true - - mute: - type: bool - sonos_recv: mute - sonos_send: mute - - play_mode: - type: str - sonos_recv: play_mode - sonos_send: play_mode - - bass: - type: num - sonos_recv: bass - sonos_send: bass - - group_command: - type: bool - value: false - sonos_attrib: group - - treble: - type: num - sonos_recv: treble - sonos_send: treble - - group_command: - type: bool - value: false - sonos_attrib: group - - loudness: - type: bool - sonos_recv: loudness - sonos_send: loudness - - group_command: - type: bool - value: false - sonos_attrib: group - - night_mode: - # only supported by Playbar - type: bool - sonos_recv: night_mode - sonos_send: night_mode - - dialog_mode: - # only supported by Playbar - type: bool - sonos_recv: dialog_mode - sonos_send: dialog_mode - - cross_fade: - type: bool - sonos_recv: cross_fade - sonos_send: cross_fade - - snooze: - type: num - sonos_recv: snooze - sonos_send: snooze - - is_coordinator: - type: bool - sonos_recv: is_coordinator - - coordinator: - type: str - sonos_recv: coordinator - - zone_group_members: - type: list - sonos_recv: zone_group_members - - status_light: - type: bool - sonos_recv: status_light - sonos_send: status_light - - player_name: - type: str - sonos_recv: player_name - - household_id: - type: str - sonos_recv: household_id - - track_uri: - type: str - sonos_recv: track_uri - - streamtype: - type: str - sonos_recv: streamtype - - switch_linein: - # only supported by Play5 yet (or all speakers with line-in) - type: bool - sonos_send: switch_linein - - switch_tv: - # only supported by Playbar - type: bool - sonos_send: switch_tv - - track_artist: - type: str - sonos_recv: track_artist - - track_title: - type: str - sonos_recv: track_title - - track_album: - type: str - sonos_recv: track_album - - track_album_art: - type: str - sonos_recv: track_album_art - - radio_station: - type: str - sonos_recv: radio_station - - radio_show: - type: str - sonos_recv: radio_show - - current_track: - type: num - sonos_recv: current_track - - number_of_tracks: - type: num - sonos_recv: number_of_tracks - - current_track_duration: - type: str - sonos_recv: current_track_duration - - current_transport_actions: - type: str - sonos_recv: current_transport_actions - - current_valid_play_modes: - type: str - sonos_recv: current_valid_play_modes - - stream_content: - type: str - sonos_recv: stream_content - - play_tunein: - type: str - sonos_send: play_tunein - - start_after: - type: bool - value: True - sonos_attrib: start_after - - play_url: - type: str - sonos_send: play_url - - start_after: - type: bool - value: True - sonos_attrib: start_after - - join: - type: str - sonos_send: join - enforce_updates: True - - unjoin: - type: bool - sonos_send: unjoin - enforce_updates: True - - start_after: - type: bool - value: False - sonos_attrib: start_after - - sonos_playlists: - type: list - sonos_recv: sonos_playlists - - load_sonos_playlist: - type: str - sonos_send: load_sonos_playlist - enforce_updates: True - - start_after: - type: bool - value: False - sonos_attrib: start_after - - start_track: - type: num - value: 0 - sonos_attrib: start_track - - clear_queue: - type: bool - value: False - sonos_attrib: clear_queue - - uid: - type: str - sonos_recv: uid - - play_tts: - type: str - sonos_send: play_tts - enforce_updates: True - - tts_language: - type: str - value: de - sonos_attrib: tts_language - - tts_volume: - type: num - value: -1 - sonos_attrib: tts_volume - - tts_fade_in: - type: bool - sonos_attrib: tts_fade_in - - play_snippet: - type: str - sonos_send: play_snippet - enforce_updates: True - - snippet_volume: - type: num - value: 25 - sonos_attrib: snippet_volume - - snippet_fade_in: - type: bool - value: True - sonos_attrib: snippet_fade_in \ No newline at end of file diff --git a/sonos/plugin.yaml b/sonos/plugin.yaml index 0e8350bc0..3845bbb4e 100755 --- a/sonos/plugin.yaml +++ b/sonos/plugin.yaml @@ -5,27 +5,35 @@ plugin: description: # Alternative: description in multiple languages de: 'Anbindung von Sonos Lautsprechern' en: 'Sonos plugin' - maintainer: pfischi - tester: pfischi + maintainer: aschwith + tester: sisamiwe state: ready keywords: Sonos sonos multimedia - documentation: https://github.com/smarthomeNG/plugins/tree/master/sonos # url of documentation (wiki) page + documentation: https://github.com/smarthomeNG/plugins/blob/master/sonos/README.md support: https://knx-user-forum.de/forum/supportforen/smarthome-py/25151-sonos-anbindung - version: 1.6.5 # Plugin version + version: 1.8.1 # Plugin version sh_minversion: 1.5.1 # minimum shNG version to use this plugin - py_minversion: 3.5 # minimum Python version to use for this plugin + py_minversion: 3.8 # minimum Python version to use for this plugin multi_instance: False # plugin supports multi instance restartable: unknown classname: Sonos # class containing the plugin -plugin_functions: NONE - -logic_parameters: NONE +parameters: + speaker_ips: + type: list + description: + de: "(optional) Die IPs der Lautsprecher können manuell gesetzt werden. Dies kann in einer Container-Umgebung (z.B Docker) mit eingeschränkten Netzwerkzugriff sinnvoll sein." + en: "(optional) You can set static IP addresses for your Sonos speaker. This will disable auto-discovery. This is useful if you're using a containerized environment with restricted network access." -item_structs: NONE + discover_cycle: + type: int + default: 180 + valid_min: 120 + description: + de: "Zeitintervall, nach dem (erneut) nach (neuen) Lautsprechern im Netzwerk gesucht wird." + en: "Time interval to search again for (new) speakers in the network." -parameters: tts: type: bool default: False @@ -35,42 +43,39 @@ parameters: local_webservice_path: type: str + default: /tmp/tts description: - de: "(optional) In dieses Verzeichnis werden alle TTS-Dateien automatisch gespeichert. Is die Option 'tts' aktiviert, dann muss diese Option gesetzt sein." - en: "(optional) All tts files will be stored here automatically. If 'tts' is enabled, this option is mandatory." + de: '(optional) In dieses Verzeichnis werden alle TTS-Dateien automatisch gespeichert. \n + Ist die Option "tts" aktiviert, dann muss diese Option gesetzt sein.' + en: '(optional) All tts files will be stored here automatically. \n + If "tts" is enabled, this option is mandatory.' local_webservice_path_snippet: type: str + default: '' description: - de: "(optional) Die eigenen Audio-Snippet Dateien können beim Setzen dieser Option getrennt von den autmotisch generierten TTS-Dateien gespeichert werden. Wird diese Option nicht gesetzt und 'tts' ist aktiviert, so wird der Wert von 'local_webservice_path' für Snippets benutzt." - en: "(optional) The sniipet audio files can be stored separatly from the TTS audio files by activating this option. If 'tts' is enabled and this option is not set, the value 'local_webservice_path' is used for the audio snippet path." + de: '(optional) Die eigenen Audio-Snippet Dateien können beim Setzen dieser Option getrennt von den automatisch generierten \n + TTS-Dateien gespeichert werden. Wird diese Option nicht gesetzt und "tts" ist aktiviert, so wird der Wert \n + von "local_webservice_path" für Snippets benutzt.' + en: '(optional) The snippet audio files can be stored separately from the TTS audio files by activating this option. \n + If "tts" is enabled and this option is not set, the value "local_webservice_path" is used for the audio snippet path.' webservice_ip: type: ip description: - de: "(optional) Für TTS und die Audio-Snippet-Funktionalität wird ein simpler Webservice gestartet. Die IP-Adresse wird per default automatisch ermittelt, kann hier aber manuell gesetzt werden." - en: "(optional) For the TTS and audio snippet functionality, a simple webservice is started. By default the IP is detected automatically and can be manually overridden here." + de: '(optional) Für TTS und die Audio-Snippet-Funktionalität wird ein simpler Webservice gestartet. \n + Die IP-Adresse wird per default automatisch ermittelt, kann hier aber manuell gesetzt werden.' + en: '(optional) For the TTS and audio snippet functionality, a simple webservice is started. \n + By default the IP is detected automatically and can be manually overridden here.' webservice_port: type: int default: 23500 + valid_min: 1024 + valid_max: 65535 description: - de: "(optional) Webservice-Port" - en: "(optional) Webservice port" - - speaker_ips: - type: list - description: - de: "(optional) Die IPs der Lautsprecher können manuell gesetzt werden. Dies kann in einer Container-Umgebung (z.B Docker) mit eingeschränkten Netzwerkzugriff sinnvoll sein." - en: "(optional) You can set static IP addresses for your Sonos speaker. This will disable auto-discovery. This is useful if you're using a containerized environment with restricted network access." - - discover_cycle: - type: int - default: 180 - valid_min: 120 - description: - de: "Zeitintervall, nach dem (erneut) nach (neuen) Lautsprechern im Netzwerk gesucht wird." - en: "Time interval to search again for (new) speakers in the network." + de: "(optional) Für TTS und die Audio-Snippet-Funktionalität wird ein simpler Webservice gestartet. Der Webservice-Port dafür wird hier definiert" + en: "(optional) For the TTS and audio snippet functionality, a simple webservice is started. The Webservice port can be defined here." snippet_duration_offset: type: num @@ -80,94 +85,98 @@ parameters: de: "(optional) Verlängert die Dauer von Snippet Audio Dateien um einen festen Offset in Sekunden." en: "(optional) Extend snippet duration by a fixed offset specified in seconds" + item_attributes: - # Definition of item attributes defined by this plugin sonos_uid: type: str description: - de: 'Sonos unique device ID' - en: 'Sonos unique device ID' - mandatory: 'False' + de: 'Sonos eindeutige Geräte/Zone Identifikation (Unique Device ID)' + en: 'Sonos unique device ID (UID)' + mandatory: False sonos_recv: type: str description: - de: 'Empfangsattribut' - en: 'Receive attribute' - mandatory: 'False' + de: 'Sonos Zonen/Speaker Attribut für den Emfang von Daten/Ergebnissen' + en: 'Sonos zone attribute to receive data' + mandatory: False valid_list: - - 'is_initialized' - - 'volume' - - 'play' - - is_coordiantor - - mute - - stop - - seek - - pause - - track_title - - track_artist - - track_uri - - track_album - - track_album_art - - bass - - treble - - loudness - - play_mode - - radio_show - - radio_station - - serial_number - - software_version - - hardware_version - - model - - uid - - ip - - mac_address - - status - - additional_zone_members - - alarms - - is_coordinator - - tts_local_mode - - night_mode - - dialog_mode - - buttons_enabled - - cross_fade - - coordinator - - snooze - - status_light - - zone_group_members - - player_name - - household_id - - streamtype - - current_track - - number_of_tracks - - current_transport_actions - - current_valid_play_modes - - sonos_playlists - - stream_content - - current_track_duration + - is_initialized + - volume + - play + - is_coordiantor + - mute + - stop + - seek + - pause + - track_title + - track_artist + - track_uri + - track_album + - track_album_art + - bass + - treble + - loudness + - play_mode + - radio_show + - radio_station + - serial_number + - software_version + - hardware_version + - model + - uid + - ip + - mac_address + - status + - additional_zone_members + - alarms + - is_coordinator + - tts_local_mode + - night_mode + - dialog_mode + - buttons_enabled + - cross_fade + - coordinator + - snooze + - status_light + - zone_group_members + - player_name + - household_id + - streamtype + - current_track + - number_of_tracks + - current_transport_actions + - current_valid_play_modes + - sonos_playlists + - stream_content + - current_track_duration + # new + - sonos_favorites + - favorite_radio_stations sonos_send: type: str description: - de: 'Sendeattribut' - en: 'Sending attribute' + de: 'Sonos Zonen/Speaker Attribut für das Senden von Daten/Kommandos' + en: 'Sonos zone attribute to send data/commands' mandatory: 'False' valid_list: - - 'volume' - - 'play' - - 'pause' - - 'stop' - - 'mute' - - 'cross_fade' - - 'snooze' - - 'play_mode' - - 'next' - - 'previous' - - 'play_tunein' - - 'play_url' - - 'load_sonos_playlist' - - 'play_snippet' - - 'play_tts' + - volume + - play + - pause + - stop + - mute + - cross_fade + - snooze + - play_mode + - next + - previous + - play_tunein + - play_url + - play_sharelink + - load_sonos_playlist + - play_snippet + - play_tts - join - unjoin - volume_up @@ -190,38 +199,706 @@ item_attributes: - status_light - switch_linein - switch_tv + # new + - play_favorite_title + - play_favorite_number + - play_favorite_radio_title + - play_favorite_radio_number + - play_sonos_radio sonos_attrib: type: str description: - de: 'Empfangsattribut' - en: 'Receive attribute' - mandatory: 'False' + de: 'ergänzende Attribute, die immer Child-Items des sendenden Items sein müssen' + en: 'additional attribute, which need to be always child-items of the sending item' + mandatory: False valid_list: - - 'group' - - 'dpt3_helper' - - 'vol_dpt3' - - 'max_volume' - - 'tts_language' - - 'tts_volume' - - 'tts_fade_in' - - 'start_after' - - 'snippet_fade_in' - - 'snippet_volume' - - 'clear_queue' - - 'start_track' + - group + - dpt3_helper + - vol_dpt3 + - max_volume + - tts_language + - tts_volume + - tts_fade_in + - start_after + - snippet_fade_in + - snippet_volume + - clear_queue + - start_track sonos_dpt3_step: type: int description: de: 'Relatives dpt3 Inkrement' en: 'Relative dpt3 increment' - mandatory: 'False' - + mandatory: False sonos_dpt3_time: type: int description: de: 'Dpt3 Zeitinkrement' en: 'Dpt3 time increment' - mandatory: 'False' + mandatory: False + +item_structs: + basic_control: + volume: + type: num + sonos_recv: volume + sonos_send: volume + enforce_updates: true + + group_command: + type: bool + initial_value: false + sonos_attrib: group + + max_volume: + type: num + initial_value: -1 + sonos_attrib: max_volume + + volume_dpt3: + type: list + sonos_attrib: vol_dpt3 + sonos_dpt3_step: 2 + sonos_dpt3_time: 1 + + helper: + sonos_attrib: dpt3_helper + type: num + sonos_send: volume + + play: + type: bool + sonos_recv: play + sonos_send: play + + stop: + type: bool + sonos_recv: stop + sonos_send: stop + + pause: + type: bool + sonos_recv: pause + sonos_send: pause + + next: + type: bool + sonos_send: next + enforce_updates: true + + previous: + type: bool + sonos_send: previous + enforce_updates: true + + mute: + type: bool + sonos_recv: mute + sonos_send: mute + + play_mode: + type: str + sonos_recv: play_mode + sonos_send: play_mode + + settings: + bass: + type: num + sonos_recv: bass + sonos_send: bass + + group_command: + type: bool + initial_value: false + sonos_attrib: group + + treble: + type: num + sonos_recv: treble + sonos_send: treble + + group_command: + type: bool + initial_value: false + sonos_attrib: group + + loudness: + type: bool + sonos_recv: loudness + sonos_send: loudness + + group_command: + type: bool + initial_value: false + sonos_attrib: group + + night_mode: + # only supported by Playbar + type: bool + sonos_recv: night_mode + sonos_send: night_mode + + dialog_mode: + # only supported by Playbar + type: bool + sonos_recv: dialog_mode + sonos_send: dialog_mode + + cross_fade: + type: bool + sonos_recv: cross_fade + sonos_send: cross_fade + + snooze: + type: num + sonos_recv: snooze + sonos_send: snooze + + status_light: + type: bool + sonos_recv: status_light + sonos_send: status_light + + join: + type: str + sonos_send: join + enforce_updates: True + + unjoin: + type: bool + sonos_send: unjoin + enforce_updates: True + + start_after: + type: bool + initial_value: False + sonos_attrib: start_after + + status: + uid: + type: str + sonos_recv: uid + + is_initialized: + type: bool + sonos_recv: is_initialized + + is_coordinator: + type: bool + sonos_recv: is_coordinator + + coordinator: + type: str + sonos_recv: coordinator + + zone_group_members: + type: list + sonos_recv: zone_group_members + + player_name: + type: str + sonos_recv: player_name + + household_id: + type: str + sonos_recv: household_id + + content: + track_uri: + type: str + sonos_recv: track_uri + + streamtype: + type: str + sonos_recv: streamtype + + switch_linein: + # only supported by Play5 yet (or all speakers with line-in) + type: bool + sonos_send: switch_linein + + switch_tv: + # only supported by Playbar + type: bool + sonos_send: switch_tv + + track_artist: + type: str + sonos_recv: track_artist + + track_title: + type: str + sonos_recv: track_title + + track_album: + type: str + sonos_recv: track_album + + track_album_art: + type: str + sonos_recv: track_album_art + + radio_station: + type: str + sonos_recv: radio_station + + radio_show: + type: str + sonos_recv: radio_show + + current_track: + type: num + sonos_recv: current_track + + number_of_tracks: + type: num + sonos_recv: number_of_tracks + + current_track_duration: + type: str + sonos_recv: current_track_duration + + current_transport_actions: + type: str + sonos_recv: current_transport_actions + + current_valid_play_modes: + type: str + sonos_recv: current_valid_play_modes + + stream_content: + type: str + sonos_recv: stream_content + + play_tunein: + type: str + sonos_send: play_tunein + enforce_updates: true + + start_after: + type: bool + initial_value: True + sonos_attrib: start_after + + play_sonos_radio: + type: str + sonos_send: play_sonos_radio + enforce_updates: True + + start_after: + type: bool + initial_value: True + sonos_attrib: start_after + + play_url: + type: str + sonos_send: play_url + enforce_updates: True + + start_after: + type: bool + initial_value: True + sonos_attrib: start_after + + sonos_playlists: + type: list + sonos_recv: sonos_playlists + + load_sonos_playlist: + type: str + sonos_send: load_sonos_playlist + enforce_updates: True + + start_after: + type: bool + initial_value: False + sonos_attrib: start_after + + start_track: + type: num + initial_value: 0 + sonos_attrib: start_track + + clear_queue: + type: bool + initial_value: False + sonos_attrib: clear_queue + + sonos_favorites: + type: list + sonos_recv: sonos_favorites + + favorite_radio_stations:: + type: list + sonos_recv: favorite_radio_stations + + play_tts: + type: str + sonos_send: play_tts + enforce_updates: True + + tts_language: + type: str + initial_value: de + sonos_attrib: tts_language + + tts_volume: + type: num + initial_value: -1 + sonos_attrib: tts_volume + + tts_fade_in: + type: bool + sonos_attrib: tts_fade_in + + play_snippet: + type: str + sonos_send: play_snippet + enforce_updates: True + + snippet_volume: + type: num + initial_value: 25 + sonos_attrib: snippet_volume + + snippet_fade_in: + type: bool + initial_value: True + sonos_attrib: snippet_fade_in + + play_favorites: + play_favorite_title: + type: str + sonos_send: play_favorite_title + enforce_updates: True + + play_favorite_number: + type: num + sonos_send: play_favorite_number + enforce_updates: True + + play_favorite_radio_title: + type: str + sonos_send: play_favorite_radio_title + enforce_updates: True + + play_favorite_radio_number: + type: num + sonos_send: play_favorite_radio_number + enforce_updates: True + + standard: + # sonos_uid: rincon_xxxxxxxxxxxxxxxxx + + is_initialized: + type: bool + sonos_recv: is_initialized + + volume: + type: num + sonos_recv: volume + sonos_send: volume + enforce_updates: true + + group_command: + type: bool + initial_value: false + sonos_attrib: group + + max_volume: + type: num + initial_value: -1 + sonos_attrib: max_volume + + volume_dpt3: + type: list + sonos_attrib: vol_dpt3 + sonos_dpt3_step: 2 + sonos_dpt3_time: 1 + + helper: + sonos_attrib: dpt3_helper + type: num + sonos_send: volume + + play: + type: bool + sonos_recv: play + sonos_send: play + + stop: + type: bool + sonos_recv: stop + sonos_send: stop + + pause: + type: bool + sonos_recv: pause + sonos_send: pause + + next: + type: bool + sonos_send: next + enforce_updates: true + + previous: + type: bool + sonos_send: previous + enforce_updates: true + + mute: + type: bool + sonos_recv: mute + sonos_send: mute + + play_mode: + type: str + sonos_recv: play_mode + sonos_send: play_mode + + bass: + type: num + sonos_recv: bass + sonos_send: bass + + group_command: + type: bool + initial_value: false + sonos_attrib: group + + treble: + type: num + sonos_recv: treble + sonos_send: treble + + group_command: + type: bool + initial_value: false + sonos_attrib: group + + loudness: + type: bool + sonos_recv: loudness + sonos_send: loudness + + group_command: + type: bool + initial_value: false + sonos_attrib: group + + night_mode: + # only supported by Playbar + type: bool + sonos_recv: night_mode + sonos_send: night_mode + + dialog_mode: + # only supported by Playbar + type: bool + sonos_recv: dialog_mode + sonos_send: dialog_mode + + cross_fade: + type: bool + sonos_recv: cross_fade + sonos_send: cross_fade + + snooze: + type: num + sonos_recv: snooze + sonos_send: snooze + + is_coordinator: + type: bool + sonos_recv: is_coordinator + + coordinator: + type: str + sonos_recv: coordinator + + zone_group_members: + type: list + sonos_recv: zone_group_members + + status_light: + type: bool + sonos_recv: status_light + sonos_send: status_light + + player_name: + type: str + sonos_recv: player_name + + household_id: + type: str + sonos_recv: household_id + + track_uri: + type: str + sonos_recv: track_uri + + streamtype: + type: str + sonos_recv: streamtype + + switch_linein: + # only supported by Play5 yet (or all speakers with line-in) + type: bool + sonos_send: switch_linein + + switch_tv: + # only supported by Playbar + type: bool + sonos_send: switch_tv + + track_artist: + type: str + sonos_recv: track_artist + + track_title: + type: str + sonos_recv: track_title + + track_album: + type: str + sonos_recv: track_album + + track_album_art: + type: str + sonos_recv: track_album_art + + radio_station: + type: str + sonos_recv: radio_station + + radio_show: + type: str + sonos_recv: radio_show + + current_track: + type: num + sonos_recv: current_track + + number_of_tracks: + type: num + sonos_recv: number_of_tracks + + current_track_duration: + type: str + sonos_recv: current_track_duration + + current_transport_actions: + type: str + sonos_recv: current_transport_actions + + current_valid_play_modes: + type: str + sonos_recv: current_valid_play_modes + + stream_content: + type: str + sonos_recv: stream_content + + play_tunein: + type: str + sonos_send: play_tunein + enforce_updates: True + + start_after: + type: bool + initial_value: True + sonos_attrib: start_after + + play_url: + type: str + sonos_send: play_url + enforce_updates: True + + start_after: + type: bool + initial_value: True + sonos_attrib: start_after + + join: + type: str + sonos_send: join + enforce_updates: True + + unjoin: + type: bool + sonos_send: unjoin + enforce_updates: True + + start_after: + type: bool + initial_value: False + sonos_attrib: start_after + + sonos_playlists: + type: list + sonos_recv: sonos_playlists + + load_sonos_playlist: + type: str + sonos_send: load_sonos_playlist + enforce_updates: True + + start_after: + type: bool + initial_value: False + sonos_attrib: start_after + + start_track: + type: num + initial_value: 0 + sonos_attrib: start_track + + clear_queue: + type: bool + initial_value: False + sonos_attrib: clear_queue + + uid: + type: str + sonos_recv: uid + + play_tts: + type: str + sonos_send: play_tts + enforce_updates: True + + tts_language: + type: str + initial_value: de + sonos_attrib: tts_language + + tts_volume: + type: num + initial_value: -1 + sonos_attrib: tts_volume + + tts_fade_in: + type: bool + sonos_attrib: tts_fade_in + + play_snippet: + type: str + sonos_send: play_snippet + enforce_updates: True + + snippet_volume: + type: num + initial_value: 25 + sonos_attrib: snippet_volume + + snippet_fade_in: + type: bool + initial_value: True + sonos_attrib: snippet_fade_in + +plugin_functions: NONE + +logic_parameters: NONE \ No newline at end of file diff --git a/sonos/requirements.txt b/sonos/requirements.txt index c6e54a70d..f7c65d069 100755 --- a/sonos/requirements.txt +++ b/sonos/requirements.txt @@ -1,9 +1,9 @@ -#requests requirement moved to core +# These packages are used by the plugin itself: xmltodict>=0.11.0 tinytag>=0.18.0 gtts -# + # These packages are used by SoCo: ifaddr appdirs -lxml +lxml \ No newline at end of file diff --git a/sonos/soco/__init__.py b/sonos/soco/__init__.py index 95eae6bc7..4c89d7f92 100755 --- a/sonos/soco/__init__.py +++ b/sonos/soco/__init__.py @@ -17,7 +17,7 @@ __author__ = "The SoCo-Team " # Please increment the version number and add the suffix "-dev" after # a release, to make it possible to identify in-development code -__version__ = "0.28.1" +__version__ = "0.29.0" __website__ = "https://github.com/SoCo/SoCo" __license__ = "MIT License" diff --git a/sonos/soco/config.py b/sonos/soco/config.py index 450d2a3e5..b2ecda3aa 100755 --- a/sonos/soco/config.py +++ b/sonos/soco/config.py @@ -78,3 +78,14 @@ timeout at runtime. It can also be overridden for specific calls by using the 'timeout' kwarg in the relevant calling functions. """ + +ZGT_EVENT_FALLBACK = True +""" +For large Sonos systems (about 20+ players) the standard method of querying a +player for the Sonos Zone Group Topology will fail. + +By default, SoCo will then fall back to using a method based on ZGT events. If +you wish to disable this behaviour, set 'ZGT_EVENT_FALLBACK' to 'False'. Your +code should then be prepared to catch `NotSupportedException` errors when +using functions that interrogate system state. +""" diff --git a/sonos/soco/core.py b/sonos/soco/core.py index 780b7cd46..ff67db3ae 100755 --- a/sonos/soco/core.py +++ b/sonos/soco/core.py @@ -73,6 +73,7 @@ 84934658: "Multichannel PCM 5.1", 84934713: "Dolby 5.1", 84934714: "Dolby Digital Plus 5.1", + 84934718: "Dolby Multichannel PCM 5.1", 84934721: "DTS 5.1", } diff --git a/sonos/soco/discovery.py b/sonos/soco/discovery.py index 903a54516..e81991005 100755 --- a/sonos/soco/discovery.py +++ b/sonos/soco/discovery.py @@ -22,6 +22,7 @@ def discover( timeout=5, include_invisible=False, interface_addr=None, + household_id="Sonos", allow_network_scan=False, **network_scan_kwargs ): @@ -52,6 +53,9 @@ def discover( the system default interface(s) for UDP multicast messages will be used. This is probably what you want to happen. Defaults to `None`. + household_id (str): Supply a Sonos Household ID to restrict discovery + to a specific household. Useful in multi-household networks. In + the default case the first player to respond will be used. allow_network_scan (bool, optional): If normal discovery fails, fall back to a scan of the attached network(s) to detect Sonos devices. @@ -188,7 +192,7 @@ def close_sockets(): for _sock in response: data, addr = _sock.recvfrom(1024) _LOG.debug('Received discovery response from %s: "%s"', addr, data) - if b"Sonos" in data: + if really_utf8(household_id) in data: # Now we have an IP, we can build a SoCo instance and query # that player for the topology to find the other players. # It is much more efficient to rely upon the Zone @@ -203,10 +207,17 @@ def close_sockets(): if allow_network_scan: _LOG.debug("Falling back to network scan discovery") - return scan_network( - include_invisible=include_invisible, - **network_scan_kwargs, - ) + if household_id == "Sonos": + return scan_network( + include_invisible=include_invisible, + **network_scan_kwargs, + ) + else: + return scan_network_by_household_id( + household_id, + include_invisible=include_invisible, + **network_scan_kwargs, + ) def any_soco(allow_network_scan=False, **network_scan_kwargs): @@ -274,7 +285,7 @@ def scan_network( include_invisible=False, multi_household=False, max_threads=256, - scan_timeout=0.1, + scan_timeout=0.5, min_netmask=24, networks_to_scan=None, ): @@ -431,7 +442,7 @@ def scan_network_by_household_id( network_scan_kwargs["multi_household"] = True zones = scan_network(include_invisible=include_invisible, **network_scan_kwargs) if zones: - zones = {zone for zone in zones if zone.household_id == household_id} + zones = {zone for zone in zones if household_id in zone.household_id} _LOG.debug("Returning zones: %s", zones) return zones diff --git a/sonos/soco/events.py b/sonos/soco/events.py index 1fa2fc6af..16299ff98 100755 --- a/sonos/soco/events.py +++ b/sonos/soco/events.py @@ -403,7 +403,7 @@ def _request(self, method, url, headers, success, unconditional=None): if response and response.status_code != 412: response.raise_for_status() - if success: + if response and success: success(response.headers) if unconditional: unconditional() diff --git a/sonos/soco/plugins/wimp.py b/sonos/soco/plugins/wimp.py index 37dc27bbf..d6d4f235d 100755 --- a/sonos/soco/plugins/wimp.py +++ b/sonos/soco/plugins/wimp.py @@ -82,7 +82,7 @@ def _get_header(soap_action): # depends on the locale settings of the system. However, I'm unsure if # they are actually used. The character coding is set elsewhere and I think # the available music in each country is bound to the account. - language, _ = locale.getdefaultlocale() + language, _ = locale.getlocale() if language is None: language = "" else: diff --git a/sonos/soco/zonegroupstate.py b/sonos/soco/zonegroupstate.py index 770198bc9..1f89952f2 100755 --- a/sonos/soco/zonegroupstate.py +++ b/sonos/soco/zonegroupstate.py @@ -60,12 +60,14 @@ """ +import asyncio import logging import time from lxml import etree as LXML from . import config +from .exceptions import NotSupportedException, SoCoException, SoCoUPnPException from .groups import ZoneGroup EVENT_CACHE_TIMEOUT = 60 @@ -148,8 +150,96 @@ def poll(self, soco): ) soco = soco._satellite_parent - zgs = soco.zoneGroupTopology.GetZoneGroupState()["ZoneGroupState"] - self.process_payload(payload=zgs, source="poll", source_ip=soco.ip_address) + # On large (about 20+ players) systems, GetZoneGroupState() can cause + # the target Sonos player to return an HTTP 501 error, raising a + # SoCoUPnPException. + try: + zgs = soco.zoneGroupTopology.GetZoneGroupState()["ZoneGroupState"] + self.process_payload(payload=zgs, source="poll", source_ip=soco.ip_address) + + # In the event of failure, we fall back to using a ZGT event to + # determine the ZGS. Fallback behaviour can be disabled by setting the + # config.ZGT_EVENT_FALLBACK flag to False. + except SoCoUPnPException as soco_upnp_exception: + _LOG.debug( + "Exception (%s) raised on 'GetZoneGroupState()'", + soco_upnp_exception, + ) + + if config.ZGT_EVENT_FALLBACK is False: + _LOG.debug("ZGT event fallback disabled (config.ZGT_EVENT_FALLBACK)") + raise NotSupportedException( + "'GetZoneGroupState()' call fails on large Sonos systems " + "and event fallback is disabled" + ) from soco_upnp_exception + + _LOG.debug("Falling back to using a ZGT event") + try: + self.update_zgs_by_event(soco) + except Exception as soco_exception: + raise soco_exception from soco_upnp_exception + + def update_zgs_by_event(self, speaker): + """ + Fall back to updating the ZGS using a ZGT event. + Use of the 'events_twisted' module is not currently supported. + """ + if config.EVENTS_MODULE.__name__ == "soco.events": + _LOG.debug("Updating ZGS using standard 'events' module") + self.update_zgs_by_event_default(speaker) + + elif config.EVENTS_MODULE.__name__ == "soco.events_asyncio": + _LOG.debug("Updating ZGS using 'events_asyncio' module") + # Explicit asyncio event loop control required for Python 3.6 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(ZoneGroupState.update_zgs_by_event_asyncio(speaker)) + asyncio.set_event_loop(None) + loop.close() + # From Python 3.7, we can just use the single statement: + # asyncio.run(ZoneGroupState.update_zgs_events_asyncio(speaker)) + + elif config.EVENTS_MODULE.__name__ == "soco.events_twisted": + # Future: Insert code here to handle the 'events_twisted' case + raise SoCoException( + "ZGT event fallback not yet implemented when using the " + "'events_twisted' module" + ) + + else: + # In case any additional events frameworks come along ... + raise SoCoException( + "ZGT event fallback not implemented for " + f"'{config.EVENTS_MODULE.__name__}' module" + ) + + def update_zgs_by_event_default(self, speaker): + """ + Update the ZGS using the default events module. + """ + sub = speaker.zoneGroupTopology.subscribe() + event = sub.events.get(timeout=1.0) + sub.unsubscribe() + zgs = event.variables.get("zone_group_state") + self.process_payload(payload=zgs, source="event", source_ip=speaker.ip_address) + + @staticmethod + async def update_zgs_by_event_asyncio(speaker): + """ + Update ZGS using events_asyncio. When the event is received, + the events_asyncio notify handler will call 'process_payload' with + the updated ZGS. + """ + from . import events_asyncio # pylint: disable=C0415 + + event_listener_is_running = events_asyncio.event_listener.is_running + sub = await speaker.zoneGroupTopology.subscribe() + await asyncio.sleep(0.25) + await sub.unsubscribe() + if not event_listener_is_running: + # The event listener was started as a result of our + # subscribe() call, so stop it + await events_asyncio.event_listener.async_stop() def process_payload(self, payload, source, source_ip): """Update using the provided XML payload.""" diff --git a/sonos/sv_widgets/widget_sonos.html b/sonos/sv_widgets/widget_sonos.html index 1270cf2cf..70752cd99 100755 --- a/sonos/sv_widgets/widget_sonos.html +++ b/sonos/sv_widgets/widget_sonos.html @@ -1,5 +1,5 @@ /** -* Displas a Sonos title +* Displays a Sonos title * * @param {uid} unique id for this widget * @param {artist} artist item for sonos player @@ -13,7 +13,7 @@ {% endmacro %} /** -* Displas a Sonos title +* Displays a Sonos title * * @param {uid} unique id for this widget * @param {title} title item for sonos player @@ -29,7 +29,7 @@ /** -* Displas a Sonos cover +* Displays a Sonos cover * * @param {uid} unique id for this widget * @param {cover} cover item for sonos player @@ -45,7 +45,7 @@ /** -* Displas a Sonos album name +* Displays a Sonos album name * * @param {uid} unique id for this widget * @param {album} album item for sonos player @@ -58,7 +58,7 @@ /** -* Displas a Sonos play button +* Displays a Sonos play button * * @param {uid} unique id for this widget * @param {play} play item for sonos player @@ -67,7 +67,7 @@ * @info by Pfischi */ {% macro play(uid, play, current_transport_actions) %} - {% import "basic.html" as basic %} + {% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %}
{{ basic.stateswitch(uid, play, 'icon', [0, 1], ['audio_play.svg', 'audio_pause.svg' ]) }}
@@ -75,7 +75,7 @@ /** -* Displas a Sonos previous button +* Displays a Sonos previous button * * @param {uid} unique id for this widget * @param {next} trigger next item for sonos player @@ -84,7 +84,7 @@ * @info by Pfischi */ {% macro next(uid, next, current_transport_actions) %} - {% import "basic.html" as basic %} + {% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %} @@ -92,7 +92,7 @@ /** -* Displas a Sonos previous button +* Displays a Sonos previous button * * @param {uid} unique id for this widget * @param {previous} previous item for sonos player @@ -101,14 +101,14 @@ * @info by Pfischi */ {% macro previous(uid, previous, current_transport_actions) %} - {% import "basic.html" as basic %} + {% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %} {% endmacro %} /** -* Displas a Sonos volume slider +* Displays a Sonos volume slider * * @param {uid} unique id for this widget * @param {volume} volume item for sonos player @@ -116,7 +116,7 @@ * @info by Pfischi */ {% macro volume(uid, volume) %} - {% import "basic.html" as basic %} + {% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %}
{{ basic.slider(uid, volume, 0, 100, 1, 'none', 'none') }}
@@ -132,7 +132,7 @@ * @info by Pfischi */ {% macro mute(uid, mute) %} - {% import "basic.html" as basic %} + {% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %}
{{ basic.stateswitch(uid, mute, 'icon', [0, 1], ['audio_volume_low.svg', 'audio_volume_mute.svg']) }}
@@ -140,7 +140,7 @@ /** -* Displas a Sonos playlist +* Displays a Sonos playlist * * @param {uid} unique id for this widget * @param {sonos_playlist} item for sonos playlist @@ -186,7 +186,7 @@

Playlists:

{% set sonos_playlists = '.sonos_playlists' %} {% set load_sonos_playlist = '.load_sonos_playlist' %} {% set cover_default = 'pages/base/pics/trans.png' %} - {% import "basic.html" as basic %} + {% import config_version_full >= "3.2.c" ? "@widgets/basic.html" : "basic.html" as basic %} {% import _self as mysonos %}
diff --git a/sonos/user_doc.rst b/sonos/user_doc.rst index 1c994dd2d..a7fbaa178 100755 --- a/sonos/user_doc.rst +++ b/sonos/user_doc.rst @@ -1,50 +1,854 @@ .. index:: Plugins; sonos -.. index:: Sonos +.. index:: sonos -======== +===== sonos -======== +===== -Sonos plugin, mit Unterstützung für Sonos Lautsprecher +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left -Das Plugin basiert auf dem Sonos SoCo github projekt: -https://github.com/SoCo/SoCo -Official Sonos Seite: -https://www.sonos.com/ +Anforderungen +============= + + +Notwendige Software +------------------- + +Folgende Python Pakete werden vom Plugin benötigt und automatisch bei der ersten Verwendung installiert: + * xmltodict>=0.11.0 + * tinytag>=0.18.0 + * gtts + +Weiterhin braucht das Basisframework SoCo diese python Pakete. Diese werden auch bei der ersten Verwendung installiert: + * ifaddr + * appdirs + * lxml + +Unterstützte Geräte +------------------- + +Es werden alle Sonos Lautsprecher mit Sonos Softwareversion > 10.1 unterstützt. +Offizielle Sonos Seite: ``https://www.sonos.com/`` + +Das Plugin basiert auf dem Sonos SoCo Github Projekt: ``https://github.com/SoCo/SoCo`` Konfiguration ============= -Die Informationen zur Konfiguration des Plugins sind unter :doc:`/plugins_doc/config/sonos` beschrieben. +Erste Schritte +-------------- +Die Zuordnung der Items zu den Speakern erfolgt über eine eindeutige Speaker ID, auch UID genannt. +Diese können für Speaker im lokalen Netzwerk mittels des Python Skriptes ``search_uids.py`` ausgelesen werden. Dazu wird +das Skript in der Konsole folgendermaßen ausgeführt: -Web Interface -============= +.. code-block:: python + + python3 search_uids.py + +Die Aussgabe sieht dann so aus: + +.. code-block:: bash + + --------------------------------------------------------- + rincon_000f448c3392a01411 + ip : 192.168.1.100 + speaker name : Wohnzimmer + speaker model: Sonos PLAY:1 -Das sonos Plugin verfügt über ein Webinterface, auf dem die aktive SoCo version angezeigt wird. + --------------------------------------------------------- + rincon_c7e91735d19711411 + ip : 192.168.1.99 + speaker name : Kinderzimmer + speaker model: Sonos PLAY:3 + --------------------------------------------------------- + +Die erste Zeile jedes Eintrags gibt die UID an (rincon_xxxxxxxxxxxxxx). + +Alternativ können die Speaker/Zones auch dem WebIF entnommen werden. Dazu muss das Plugin aktiviert sein, Items müssen +allerdings zu diesem Zeitpunkt noch nicht definiert sein. + + +plugin.yaml +----------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + +1) Sonos Speaker automatisch im Netzwerk suchen: +Standardmäßig können Sonos Speaker im lokalen Netzwerk automatisch detektiert und verwendet werden. + + +2) Sonos Speaker manuell konfigurieren: + +In manchen Situation sollten die verfügbaren Sonos Speaker statisch über Ihre IP Adressen konfiguriert werden. Dies +ist zum Beispiel erforderlich, wenn das lokale Netzwerk Multicast und/oder UDP nicht unterstützt, was für die automatische Detektion +unter 1) benötigt wird. + +Folgendermaßen werden Speaker statisch in der plugin.yaml konfiguriert: + +.. code-block:: yaml + + Sonos: + class_name: Sonos + class_path: plugins.sonos + speaker_ips: + - 192.168.1.10 + - 192.168.1.77 + - 192.168.1.78 .. important:: - Das Webinterface des Plugins kann mit SmartHomeNG v1.4.2 und davor **nicht** genutzt werden. - Es wird dann nicht geladen. Diese Einschränkung gilt nur für das Webinterface. Ansonsten gilt - für das Plugin die in den Metadaten angegebene minimale SmartHomeNG Version. + Die zyklische Discover Funktionalität prüft, ob neue Speaker hinzugekommen sind oder ob + bekannte Speaker inzwischen offline sind. Die Funktionalität sollte aus Performancegründen nicht + unnötig strapaziert werden. + In der ``plugin.yaml`` kann hierzu im Parameter ``discover_cycle`` (in Sekunden) definiert werden, wie oft die + Detektion ausgeführt werden soll. + + Es wird nicht empfohlen, den Wert kleiner als 60 Sekunden zu wählen. + + +items.yaml +---------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + + +logic.yaml +---------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + + +Funktionen +---------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + + +Unterstütze Eigenschaften/Funktionen +==================================== + +Folgende Sonos Funktionen bzw. Eigenschaften werden unterstützt und können mit einem smarthomeNG Item verknüpft werden. +Es müssen nicht alle Items für die Funktionen angelegt werden. Die Items markiert mit ``visu`` sollten bei der Verwendung +des smartVisu Sonos Widgets mindestens angelegt werden. +Die Markierungen ``read`` bzw. ``write`` geben an, ob es sich um eine schreibende Funktion (für Befehle an Sonos) +und/oder lesende Funktion (Status von Sonos lesen) handelt. + +bass +---- +``read`` ``write`` + +Dieses Attribut steuert die Basslautstärke eines Speakers. Der Wert muss ein ganzzahliger Wert zwischen -10 und 10 sein. +Diese Eigenschaft ist **kein** Gruppenbefehl. Wird ein untergeordnetes Item mit Attribut ``group_command: True`` gesetzt, +wird die Basslautstärke trotzdem für alle Speaker einer Gruppe gesetzt. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +coordinator +----------- +``read`` + +Gibt die UID des Speakers zurück, der aktuell der Koordinator der Gruppe ist. Die UID ist ein String. Ist ein Speaker nicht +Teil einer Gruppe, ist er per Definition immer selber Koordinator. Das Item gibt in diesem Fall die eigene UID zurück. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +cross_fade +---------- +``read`` ``write`` + +Setzt bzw. liest den Cross-Fade Modus eines Speakers. Das Item ist vom Typ Boolean. `True` bedeutet Cross-Fade +eingeschaltet, `False` ausgeschaltet. +Das Setzen ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +current_track +------------- +``read`` + +Gibt die Indexposition des aktuell gespielten Tracks innerhalb der Playliste zurück. Das Item ist vom Typ Integer. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +current_track_duration +---------------------- +``read`` + +Gibt die aktuelle Spiellänge des Tracks im Format HH:mm:ss an. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +current_transport_actions +------------------------- +``read`` ``visu`` + +Gibt die möglichen Transport Actions für den aktuellen Track wieder. +Mögliche Werte sind: Set, Stop, Pause, Play, X_DLNA_SeekTime, Next, Previous, X_DLNA_SeekTrackNr. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +current_valid_play_modes +------------------------ +``read`` + +Gibt alle validen Abspielmodi für den aktuellen Zustand zurück. Die Modi werden als String (mit Kommata getrennt) ausgegeben. +Einer der Modi kann dem ``play_mode`` Befehl übergeben werden. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +dialog_mode +----------- +``read`` ``write`` + +Nur unterstützt von Sonos Playbars. +Setzt bzw. liest den Dialog Modus einer Playbar. `True` bedeutet Dialog Modus ein, `False` Modus aus. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an (zu bestätigen). + +household_id +------------ +``read`` + +Gibt die Household ID des Speakers zurück. + +is_coordinator +-------------- +``read`` + +Gibt den Status zurück, ob ein Speaker Koordinator eine Gruppe ist, oder nicht. Das Item ist vom Typ Boolean. +Rückgabe von `True`, falls der Speaker der Koordinator ist, `False`, falls der Koordinator ein anderer Speaker ist. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +is_initialized +-------------- +``read`` + +Gibt den Status zurück, ob ein Speaker initialisiert und erreichbar ist. Das Item ist vom Typ Boolean. +`True` bedeutet, dass der Speaker initialisiert und erreichbar ist. Bei `False` ist der Speaker entweder offline oder nicht vollständig initialisiert. +Nutze dieses Item in Logiken oder Szenen, bevor weitere Kommendos an den Speaker gesendet werden, siehe Beispiel 3). + +join +---- +``write`` + +Verbindet einen Speaker mit einem anderen Speaker oder Gruppe per Übergabe der UID eines Geräts, +welches sich schon in der Gruppe befindet. Zusätzlich sollte für das Item das smarthomeNG item Attribut ``enforce_update: True`` +gesetzt werden. + +load_sonos_playlist +------------------- +``write`` + +Lädt eine Sonos playlist über ihren Namen. Die Funktion ``sonos_playlists`` zeigt alle verfügbaren Playlisten an. +Dies ist ein Gruppenbefehl, der auf jeden Speaker einer Gruppe angewandt werden kann. + +Unteritem ``start_after``: +Wird ein untergeordnetes item vom Typ Boolean mit dem Attribut ``sonos_attrib: start_after`` angelegt, kann das Verhalten +nach Laden der Playliste bestimmt werden. Wird das Item auf `True` gesetzt, startet der Speaker direkt die Wiedergabe. +Wird das Item auf `False` gesetzt, wird nur die Playliste geladen und es erfolgt keine direkte Wiedergabe. +Wird dieses Item weggelassen, ist das Standardverhalten `False`. + +Unteritem ``clear_queue``: +Wird ein untergeordnetes item vom Typ Boolean mit dem Attribut ``sonos_attrib: clear_queue`` angelegt, wird bei Wert +`True` die bestehende Sonos Playlist gelöscht bevor die neue Playlist geladen wird. Bei Wert `False` bleibt die bestehende Liste +erhalten und die Songs der neu zu ladenden Playliste werden angehängt. +Wird dieses Item weggelassen, ist das Standardverhalten `False`. + +Unteritem ``start_track``: +Wird ein untergeordnetes item vom Typ Number mit dem Attribut ``sonos_attrib: start_track`` angelegt, kann die Indexposition +innerhalb der geladen Playliste definiert werden, von wo die Wiedergabe startet. Der erste Song in der Playliste entspricht der +Indexposition `0`. +Wird dieses Item weggelassen, ist das Standardverhalten ein Start bei Indexposition `0`. + +loudness +-------- +``read`` ``write`` + +Setzt oder liest den Modus Lautstärkeabsenkung eines Speakers. Das Item ist vom Typ Boolean. Bei Wert `True` +wird die Lautstärke und Bass abgesenkt, bei `False` nicht. +Diese Eigenschaft ist kein Gruppenbefehl. Nichtsdestotrotz kann über ein untergeordnetes Item mit dem Attribut +``group_command: True`` ein Gruppenbefehl erzwungen werden, d.h. die Lautstärkeabsenkung wird für alle Speaker innerhalb der Gruppe gesetzt. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +streamtype +---------- +``read`` ``visu`` + +Gibt den aktuellen Streamtyp zurück. Das Item ist vom Typ String. Mögliche Werte sind +`music` (Standard, z.B. beim Spielen eines Songs aus dem Netzwerk), `radio`, `tv` (falls der Audio Output einer Playbar +auf `TV` gesetzt ist, oder `line-in` (z.B. beim Sonos Play5). +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +mute +---- +``read`` ``write`` ``visu`` + +Stellt einen Speaker auf lautlos. Das Item ist vom Typ Boolean. Der Wert `True` bedeutet lautlos (mute), +`False` bedeutet laut (un-mute). +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +next +---- +``write`` ``visu`` + +Wechselt zum nächsten Song der aktuellen Playliste. Das Item ist vom Typ Boolean. Der Wert `True` +bedeutet Sprung zum nächsten Track. Ein Setzen auf `False` hat keinen Effekt. Zusätzlich muss +für das Item das smarthomeNG item Attribut ``enforce_update: True`` gesetzt werden. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. + +night_mode +---------- +``read`` ``write`` + +Nur von der Sonos Playbar unterstützt. +Setzt oder liest den Nachtmodus einer Sonos Playbar. Das Item ist vom Typ Boolean. Wert `True` zeigt Nachtmodus aktiv an, +Wert `False` bedeutet Nachtmodus aus. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an (bisher ungetestet). + +number_of_tracks +---------------- +``read`` + +Gibt die komplette Anzahl an Tracks in der aktuellen Playliste zurück. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +pause +----- +``read`` ``write`` ``visu`` + +Pausiert die Wiedergabe. Das Item ist vom Typ Boolean. Wert `True` bedeutet pausieren, `False` führt die Wiedergabe fort. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +play +---- +``read`` ``write`` ``visu`` + +Startet die Wiedergabe. Das Item ist vom Typ Boolean. Der Wert `True` bedeutet Wiedergabe, `False` bedeutet pausieren. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +player_name +----------- +``read`` + +Gibt den Namen des Speakers zurück. Das Item ist vom Typ String. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +play_mode +--------- +``read`` ``write`` + +Setzt oder liest den Abspielmodus für einen Speaker. Das Item ist vom Typ String. +Erlaubte Werte sind `NORMAL`, `REPEAT_ALL`, `SHUFFLE`, `SHUFFLE_NOREPEAT`, `SHUFFLE_REPEAT_ONE`, `REPEAT_ONE`. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +play_snippet +------------- +``write`` + +Spielt ein Audio Snippet über einen Audiodateinamen ab (z.B. `alarm.mp3`). Das Item ist vom Typ String. +Voraussetzung ist, dass in der ``plugin.yaml`` die Attribute ``tts`` und der ``local_webservice_path`` gesetzt sind. +Die Audiodatei muss in dem unter ``local_webservice_path`` oder ``local_webservice_path_snippet`` angegebenen Pfaden liegen. +Folgende Dateiformate werden unterstützt: `mp3`, `mp4`, `ogg`, `wav`, `aac` (tested only with `mp3`). +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. + +Unteritem ``snippet_volume``: +Wird ein untergeordnetes Item vom Typ Number mit Attribut ``sonos_attrib: snippet_volume`` definiert, +kann die Laustärke explizit für das Abspielen von Snippets gesetzt werden. Diese Snippet Lautstärke beeinflusst nicht +die Lautstärke der normalen Wiedergabe, auf die nach Abspielen des Snippets zurück gewechselt wird. +Wird ein Snippet in einer Gruppe abgespielt, wird für jeden einzelnen Speaker die ursprüngliche Lautstärke wiederhergestellt. + +Unteritem ``snippet_fade_in``: +Wird ein untergeordnetes Item vom Typ Boolean mit Attribut ``sonos_attrib: snippet_fade_in`` definiert, wird die Lautstärke +nach dem Abspielen des Snippets von `0` auf das gewünschte Level schrittweise angehoben und eingeblendet. + +play_tts +-------- +``write`` + +Spielt eine definierte Nachricht ab (Text-to-Speech). Das Item ist vom Typ String. Aus der Nachricht im String wird von dem Google TTS API eine +Audiodatei erzeugt, die lokal gespeichert und abgespielt wird. +Für die Nutzung dieses Features müssen mindestens zwei Parameter in der ``plugin.yaml`` gesetzt sein: +``tts`` und ``local_webservice_path``. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. + +Unteritem ``tts_language``: +Wird ein untergeordnetes Item vom Typ String mit Attribut ``sonos_attrib: tts_language`` angelegt, kann die +Spracheinstellung der Google TTS API definiert werden. +Gültige Werte sind `en`, `de`, `es`, `fr`, `it`. Ist das Item nicht vorhanden, wird die Standardeinstellung `de` verwendet. + +Unteritem ``tts_volume``: +Wird ein untergeordnetes Item vom Typ Number mit Attribut ``sonos_attrib: tts_volume`` angelegt, kann die Lautstärke +für das Abspielen von Text-to-Speech separat definiert werden. Die reguläre Lautstärke wird damit nicht beeinflusst. +Nach der Ansage wird die Lautstärke jedes Speakers individuell in der Gruppe wieder hergestellt. + +Unteritem ``tts_fade_in``: +Wird ein untergeordnetes Item vom Typ Boolean mit Attribut ``sonos_attrib: tts_fade_in`` definiert, wird die Lautstärke +nach dem Abspielen der Nachricht von 0 auf das gewünschte Level schrittweise angehoben und eingeblendet. + +play_tunein / play_sonos_radio +------------------------------ +``write`` + +Spielt einen Radiosender anhand eines Namens. Das Item ist vom Typ String. Sonos sucht dazu in einer Datenbank +nach potentiellen Radiostationen, die dem Namen entsprechen. +Wird mehr als ein zum Suchbegriff passender Radiosender gefunden, wird der erste Treffer verwendet. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. + +Unteritem ``start_after``: +Wird ein untergeordnetes Item vom Typ Boolean mit Attribut ``sonos_attrib: start_after`` definiert, wird das +Verhalten nach dem Laden der Radiostation definiert. Der Wert `True`, startet die Wiedergabe automatisch. +Existiert das Unteritem nicht, ist die Standardeinstellung `True`. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. + +play_url +-------- +``write`` + +Spielt eine gegebene URL. Das Item ist vom Typ String, in dem die URL übergeben wird. + +Unteritem ``start_after``: +Wird ein untergeordnetes Item vom Typ Boolean mit Attribut ``sonos_attrib: start_after`` definiert, wird das +Verhalten nach dem Laden der URL definiert. Wurde der obige ``group_command`` auf `True` gesetzt, +startet die Wiedergabe automatisch. Existiert das Unteritem nicht, ist die Standardeinstellung `True`. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. + +play_sharelink +-------------- +``write`` + +Spielt einen gegebenen Sharelink, z.B. einen Spotify Sharelink. In diesem Fall wird ein Premium Spotify Account benötigt, da der +kostenlose Account Sharelinks nicht unterstützt. + +Unteritem ``start_after``: +Wird ein untergeordnetes Item vom Typ Boolean mit Attribut ``sonos_attrib: start_after`` definiert, wird das +Verhalten nach dem Laden des Sharelinks definiert. Wurde der obige ``group_command`` auf `True` gesetzt, +startet die Wiedergabe automatisch. Existiert das Unteritem nicht, ist die Standardeinstellung `True`. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. + +previous +-------- +``write`` ``visu`` + +Setzt den aktuellen Track auf den Vorherigen zurück. Das Item ist vom Typ Boolean. Der Wert `True` triggert das Schalten +auf den vorherigen Track, der Wert `False` hat keinen Effekt. +Zusätzlich muss für das Item das smarthomeNG Item Attribut ``enforce_update: True`` gesetzt werden. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. + +radio_station +------------- +``read`` ``visu`` + +Gibt den Namen des aktuellen Radiosenders zurück. +Das Item ist vom Typ String. Falls kein Radio gespielt wird, siehe ``streamtype``, ist das Item leer. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +radio_show +---------- +``read`` ``visu`` +Falls verfügbar (hängt von dem Radiosender ab), gibt dieses Item den Namen des aktuellen Programms zurück. +Das Item ist vom Typ String. Falls kein Radio gespielt wird, siehe ``streamtype``, ist das Item leer. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. -Aufruf des Webinterfaces +snooze +------ +``read`` ``write`` + +Setzt bzw. liest den Snooze Timer. Das Item ist vom Typ Number mit ganzzahligen Werten zwischen 0 - 86399 (in Sekunden). +Der Wert `0` bedeutet, dass der Snooze Timer ausgeschaltet ist. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. +Der Wert wird **nicht** in Echtzeit aktualisiert, sondern in jedem Speaker Discovery Zyklus aktualisiert. + +sonos_playlists +--------------- +``read`` ``visu`` + + +Gibt eine Liste der erstellten Sonos Playlists zurück. Das Item ist vom Typ String. Die Playlists können über +``load_sonos_playlist`` geladen werden. + +status_light +------------ +``read`` ``write`` + +Setzt bzw. liest den Status der LED im Speaker. Das Item ist vom Typ Boolean. Der Wert `True` bedeutet LED eingeschaltet, +`False` bedeutet deaktiviert. Der Wert wird **nicht** in Echtzeit aktualisiert, sondern in jedem Speaker Discovery Zyklus aktualisiert. + +buttons_enabled +--------------- +``read`` ``write`` + +Setzt bzw. liest den Status des Tasters/Touchbedienung am Speaker. Das Item ist vom Typ Boolean. Der Wert `True` bedeutet +Taster/Touchbedienung eingeschaltet, `False` bedeutet deaktiviert. Der Wert wird **nicht** in Echtzeit aktualisiert, +sondern in jedem Speaker Discovery Zyklus aktualisiert. + +stop +---- +``read`` ``write`` ``visu`` + +Stoppt die Wiedergabe. Das Item ist vom Typ Boolean. Der Wert `True` steht für Stop, `False` für Wiedergabe starten. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +stream_content +-------------- +``read`` ``visu`` + +Gibt den Inhalt wieder, der aktuell für einen Radiosender bereitgestellt wird, z.B. +aktuell gespielter Titel und Interpret. Das Item ist vom Typ String. Falls kein Radio gespielt wird, siehe ``streamtype``, +ist das Item leer. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +switch_line_in +-------------- +``write`` + +Schaltet den Audioeingang eines Sonos Play5 (oder anderen Sonos Speaker mit Line-in Eingang) auf den Line-in Eingang. +Das Item ist vom Typ Boolean. Wert `True` triggert das Schalten auf Line-in, +`False` hat keinen Effekt. + +switch_tv +--------- +``write`` + +Nur von der Sonos Playbar unterstützt. Schaltet den Playbar auf TV Eingang. Das Item ist vom Typ Boolean. Wert `True` +bedeutet auf den TV Eingang schalten, `False` hat keine Effekt. + +track_album +----------- +``read`` ``visu`` + +Gibt den Albumtitel des aktuellen Tracks zurück. Das Item ist vom Typ String. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +track_album_art +--------------- +``read`` ``visu`` + +Gibt die URL des Albumcovers für den aktuellen Track zurück. Das Item ist vom Typ String. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +track_artist +------------ +``read`` ``visu`` + +Gibt den Artisten des aktuellen Track zurück. Das Item ist vom Typ String. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +track_title +----------- +``read`` ``visu`` + +Gibt den Titel des aktuellen Tracks zurück. Das Item ist vom Typ String. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +track_uri +--------- +``read`` ``visu`` + +Gibt die URI (Link) auf den aktuell wiedergegebenen Track zurück. Das Item ist vom Typ String. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +treble +------ +``read`` ``write`` + +Setzt bzw. liest das Höhen Level eines Speakers. Das Item ist vom Typ Number und muss ein ganzzahligen Wert zwischen -10 and 10 enthalten. +Diese Eigenschaft ist **kein** Gruppenbefehl. Nichtsdestotrotz kann ein untergeordnetes Item ``group_command: True`` definiert werden, +um die Höheneinstellung für alle Speaker innerhalb der Gruppe zu übernehmen. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +uid +--- +``read`` + +Gibt die eindeutige Speaker ID als String zurück. + +unjoin +------ +``write`` + +Entkoppelt einen Speaker aus einer Gruppe. + +Unteritem ``start_after``: +Wird ein untergeordnetes Item vom Typ Boolean mit Attribut ``sonos_attrib: start_after`` definiert, wird dadurch das Verhalten +nach Entkopplung festgelegt. +Ein Wert `True` bedeutet, der entkoppelte Speaker startet seine individuelle Wiedergabe, `False` startet keine Wiedergabe. +Dieses Unteritem ist optional und kann weggelassen werden. In dem Fall greift das Standardverhalten als keine Wiedergabe. + +volume +------ +``read`` ``write`` ``visu`` + +Setzt bzw. liest den Lautstärkepegel eines Speakers. Das Item ist vom Typ Number und muss ein ganzzahliger Wert zwischen 0-100 sein. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. +Es wird empfohlen, zusätzlich das Item Attribut ``enforce_updates: true`` zu setzen. + +Unteritem ``group_command``: +Wird ein untergeordnetes Item vom Typ Boolean mit Attribut ``sonos_attrib: group_command`` definiert, wird die Lautstärke +auf alle Speaker innerhalb der Gruppe angewendet. + +Unteritem ``max_volume``: +Wird ein untergeordnetes Item vom Typ Number mit Attribut ``sonos_attrib: max_volume`` definiert, wird der Wert der +maximal möglichen Lautstärke auf den Wert begrenzt. Wertebereich ist 0-100. Dies betrifft nicht das Setzen der Lautstärke via Sonos APP. +Wurde der obige ``group_command`` auf `True` gesetzt, betrifft die Begrenzung alle Speaker innerhalb der Gruppe. + +Unteritem ``volume_dpt3``: +Um die Lautstärke inkrementell via KNX dpt3 ohne externe Logik zu verstellen, kann optional dieses untergeordnete Item definiert werden. +Hierzu wird ein untergeordnetes Item mit ``volume_dpt3`` angelegt, siehe Beispiel 4). + +zone_group_members +------------------ +``read`` + +Gibt eine Liste aller UIDs aus, die sich in der Gruppe des Speakers befinden. Die Liste enthält auch den aktuellen Speaker. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +sonos_favorites +--------------- +``read`` + +Liest die Liste der gespeicherten Sonos Favoriten. Das Item ist vom Typ List. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +favorite_radio_stations +--------------- +``read`` + +Liest die Liste der gespeicherten Tunein Favoriten. Das Item ist vom Typ List. +Das Item wird über Sonos Events aktualisiert und zeigt daher immer den aktuellen Status an. + +play_favorite_title +------------------- +``write`` + +Spielt einen gespeicherten Sonos Favoriten anhand eines Namens. Das Item ist vom Typ String. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. +Die Liste der gespeicherten Favoriten kann mit dem Attribut ``sonos_favorites`` einem Item zugewiesen werden. + +play_favorite_number +-------------------- +``write`` + +Spielt einen gespeicherten Sonos Favoriten anhand der Nummer des Listeneintrages. Das Item ist vom Typ Number +und muss zwischen 1 und Länge der Favoritenliste liegen. Letztere kann mit dem Attribut ``sonos_favorites`` einem Item zugewiesen werden. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. + +play_favorite_radio_title +------------------------- +``write`` + +Spielt einen gespeicherten Tunein Radio Favoriten anhand eines Namens. Das Item ist vom Typ String. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. +Die Liste der gespeicherten Favoriten kann mit dem Attribut ``favorite_radio_stations`` einem Item zugewiesen werden. + +play_favorite_radio_number +-------------------------- +``write`` + +Spielt einen gespeicherten Tunein Radio Favoriten anhand der Nummer des Listeneintrages. Das Item ist vom Typ Number +und muss zwischen 1 und Länge der Radiofavoritenliste liegen. Letztere kann mit dem Attribut ``favorite_radio_stations`` einem Item zugewiesen werden. +Der Befehl ist ein Gruppenbefehl und wird für alle Speaker einer Gruppe angewendet. + + +Nicht echtzeitfähige Eigenschaften +---------------------------------- + +Einige Eigenschaften sind nicht Event basiert. Das bedeutet, dass sie nicht direkt nach +Änderung über ein Event aktualisiert werden, sondern die Änderung erst bei der nächsten +zyklischen Abfrage bei smarthomeNG ankommt. +Folgende Eigenschaften sind **nicht** Event basiert: + * snooze + * status_light + + +Gruppenbefehle +-------------- +Einige Items werden immer als Gruppenbefehl, d.h. auf alle Speaker innerhalb einer Gruppe ausgeführt. +Folgende Methoden sind Gruppenbefehle: + + * play + * pause + * stop + * mute + * cross_fade + * snooze + * play_mode + * next + * previous + * play_tunein + * play_url + * load_sonos_playlist + +Für diese Items ist es egal, für welchen Speaker einer Gruppe diese Kommandos gesendet werden. Sie werden automatisch für alle +Speaker einer Gruppe angewendet. + + +Beispiele +========= + +1) Radiosender abspielen ------------------------ +Ein Radiosender wird über play_tunein ausgewählt. + +.. code-block:: text + + sh.Sonos.Speaker.play_tunein('WDR2') + sh.Sonos.Speaker.play(True) + sh.Sonos.Speaker.mute(False) + +2) Sonos Playlist abspielen +--------------------------- + +Eine Sonos Playliste wird über ``load_sonos_playlist`` ausgewählt. +Alle verfügbaren Playlists werden mit ``sonos_playlist`` angezeigt. + +.. code-block:: text + + sh.Sonos.Speaker.load_sonos_playlist('NameDerPlaylist') + +3) Nutzung der `is_initialized` Eigenschaft +------------------------------------------- + +Nach Start dauert es etwas, bis alle Sonos Speaker im Netzwerk initialisiert sind. Es ist deshalb angeraten, +die Methode ``is_initialized`` in Logiken zu verwenden. Gibt die Eigenschaft `True` zurück, so ist der Speaker +erreichbar und funktional. `False` bedeutet, der Speaker ist noch nicht initialisiert oder offline. + +Beispiel: + +.. code-block:: python + + if sh.MySonosPlayer.is_initialized(): + do_something() + +4a) Lautstärke inkrementell verstellen (via KNX dpt3) +---------------------------------------------------- + +Dieses Beispiel zeigt die Verstellung der Laustärke inkrementell via dpt3: + +.. code-block:: yaml + + volume: + ... + ... + volume_dpt3: + type: list + sonos_attrib: vol_dpt3 + sonos_dpt3_step: 2 + sonos_dpt3_time: 1 + + helper: + sonos_attrib: dpt3_helper + type: num + sonos_send: volume + +Bitte sicherstellen, dass ein entsprechendes helper Item definiert wird. Über das Attribut ``sonos_dpt3_step`` +werden die Laustärkeinkremente definiert und über ``sonos_dpt3_time`` die Zeit pro Inkrement. Beide Werte können +weggelassen werden. Dann werden die Standardwerte ``sonos_dpt3_step: 2`` und ``sonos_dpt3_step: 1`` verwendet. +Die Eigenschaften ``group_command`` und ``max_volume`` werden hierbei berücksichtigt. + +4b) Erweitertes DPT3 Beispiel +----------------------------- + +.. code-block:: yaml + + Kueche: + sonos_uid: rincon_000e58cxxxxxxxxx + + volume: + type: num + sonos_recv: volume + sonos_send: volume + enforce_updates: true + + group_command: + type: bool + value: false + sonos_attrib: group + + max_volume: + type: num + value: -1 + sonos_attrib: max_volume + + volume_dpt3: + type: list + sonos_attrib: vol_dpt3 + sonos_dpt3_step: 4 + sonos_dpt3_time: 1 + knx_dpt: 3 + knx_listen: 7/1/0 + + helper: + sonos_attrib: dpt3_helper + type: num + sonos_send: volume + + +5) Minimalbeispiel +------------------ + +Für ein Minimalbeispiel muss ein item mit dem Attribut ``sonos_uid`` und mindestens einem Unteritem definiert werden. +Beispiel: + +.. code-block:: yaml + + MyRoom: + MySonos: + sonos_uid: rincon_xxxxxxxxxxxxxx + + play: + type: bool + sonos_recv: play + sonos_send: play + + +Web Interface +============= + Das Plugin kann aus dem Admin Interface aufgerufen werden. Dazu auf der Seite Plugins in der entsprechenden Zeile das Icon in der Spalte **Web Interface** anklicken. Außerdem kann das Webinterface direkt über ``http://smarthome.local:8383/sonos`` aufgerufen werden. +Folgende Informationen können im Webinterface angezeigt werden: -Beispiele ---------- + - Oben rechts werden allgemeine Parameter zum Plugin wie die verwendete SoCo Version angezeigt und die Anzahl der Speaker + angezeigt, die aktuell online und verwendbar sind.. + - Tab Items: Mit dem Plugin verbundene Items + - Tab Speakers/Zones: Details zu den Speakern/Zones im Netzwerk u.a. UID + + + +SmartVisu Widget +================ + +Zur Nutzung des Sonos Widgets für SmartVisu die Dateien (html, css, js) unter +``plugins/sonos/sv_widget`` in den Ordner ``dropins/widgets`` der SmartVisu kopieren. + +Sofern alle Sonos Items gemäß Beispiel Struct definiert worden sind, wird das Widget so integriert: + +.. code-block:: html + + {% import "widget_sonos.html" as sonos %} + {% block content %} + +
+
+
+ {{ sonos.player('sonos_kueche', 'Sonos.Kueche') }} +
+
+
+ + {% endblock %} -Folgende Informationen können im Webinterface angezeigt werden: -Oben rechts werden allgemeine Parameter zum Plugin wie die aktuelle SoCo Version angezeigt. +Version History +=============== diff --git a/sonos/utils.py b/sonos/utils.py index f50e8ddff..3876564bd 100755 --- a/sonos/utils.py +++ b/sonos/utils.py @@ -12,7 +12,6 @@ def is_valid_port(port): return True return False - def unique_list(seq, idfun=None): # order preserving if idfun is None: @@ -29,7 +28,6 @@ def idfun(x): return x result.append(item) return result - def is_open_port(port): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: @@ -46,7 +44,6 @@ def get_local_ip_address(): return "0.0.0.0" return s.getsockname()[0] - def file_size(size): _suffixes = ['bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] # determine binary order in steps of size 10 @@ -58,12 +55,10 @@ def file_size(size): # should never resort to exponent values) return '{:.4g} {}'.format(size / (1 << (order * 10)), _suffixes[order]) - def get_free_diskspace(folder): statvfs = os.statvfs(folder) return statvfs.f_frsize * statvfs.f_bfree - def get_folder_size(folder): total_size = 0 for dir_path, dir_names, file_names in os.walk(folder): @@ -72,7 +67,6 @@ def get_folder_size(folder): total_size += os.path.getsize(fp) return total_size - def get_tts_local_file_path(local_directory, tts_string, tts_language): m = hashlib.md5() m.update('{}_{}'.format(tts_language, tts_string).encode('utf-8')) diff --git a/sonos/webif/__init__.py b/sonos/webif/__init__.py new file mode 100755 index 000000000..5ca55d897 --- /dev/null +++ b/sonos/webif/__init__.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2020- +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.5 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import json +import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after being rendered + """ + + pagelength = self.plugin.get_parameter_value('webif_pagelength') + tmpl = self.tplenv.get_template('index.html') + + return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, + item_list=self.plugin.item_list, + item_count=len(self.plugin.item_list), + plugin_shortname=self.plugin.get_shortname(), + plugin_version=self.plugin.get_version(), + plugin_info=self.plugin.get_info(), + soco_version=self.plugin.SoCo_version, + maintenance=True if self.plugin.log_level <= 20 else False, + ) + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + + if dataSet == 'overview': + data = dict() + try: + data = json.dumps(data) + return data + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") + + if dataSet is None: + data = dict() + + data['items'] = {} + for item in self.plugin.item_list: + data['items'][item.id()] = {} + data['items'][item.id()]['value'] = item() if item() is not None else '-' + data['items'][item.id()]['last_update'] = item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') + data['items'][item.id()]['last_change'] = item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') + + data['maintenance'] = True if self.plugin.log_level <= 20 else False + + try: + return json.dumps(data, default=str) + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") + + @cherrypy.expose + def discover(self): + self.plugin._discover(True) diff --git a/sonos/webif/static/img/plugin_logo.png b/sonos/webif/static/img/plugin_logo.png new file mode 100755 index 000000000..8710be724 Binary files /dev/null and b/sonos/webif/static/img/plugin_logo.png differ diff --git a/sonos/webif/templates/index.html b/sonos/webif/templates/index.html index ad05fc0bf..bf0adb97b 100755 --- a/sonos/webif/templates/index.html +++ b/sonos/webif/templates/index.html @@ -3,11 +3,144 @@ {% set logo_frame = false %} -{% set update_interval = 0 %} +{% set update_interval = 5000 %} + + + + + + + + + + + +{% block pluginstyles %} + +{% endblock pluginstyles %} +{% block pluginscripts %} + + + + +{% endblock pluginscripts %} {% block headtable %} @@ -24,56 +157,216 @@ {% endblock headtable %} - + +{% block buttons %} + +{% endblock buttons %} - -{% set tabcount = 1 %} + +{% set tabcount = 3 %} - -{% set start_tab = 1 %} + +{% if item_count > 0 %} + {% set start_tab = 1 %} +{% else %} + {% set start_tab = 2 %} +{% endif %} - -{% set tab1title = "" ~ p.get_shortname() ~ " " %} + +{% set tab1title = "" ~ plugin_shortname ~ " Items (" ~ item_count ~ ")" %} +{% set tab2title = "" ~ plugin_shortname ~ " Zones and Speakers " %} +{% set tab3title = "" ~ plugin_shortname ~ " Maintenance" %} + +{% if maintenance %} + {% set tab3title = "" ~ plugin_shortname ~ " Maintenance" %} +{% else %} + {% set tab3title = "hidden" %} +{% endif %} + + + {% block bodytab1 %} +
+ + + + + + + + + + + + + + + + + + {% for item in item_list %} + + + + + + + + + + + + + + {% endfor %} + +
{{_('Item')}}{{_('Typ')}}{{_('Zone')}}{{_('sonos_recv')}}{{_('sonos_send')}}{{_('sonos_attrib')}}{{_('sonos_dpt3_step')}}{{_('sonos_dpt3_time')}}{{ _('Wert') }}
{{ item._path }}{{ item._type }} + {% if 'sonos_uid' in item.conf %} + {{ p._get_zone_name_from_uid(item.conf['sonos_uid']) }} + {% elif 'sonos_recv' in item.conf %} + {{ p._get_zone_name_from_uid(p._resolve_uid(item)) }} + {% elif 'sonos_send' in item.conf %} + {{ p._get_zone_name_from_uid(p._resolve_uid(item)) }} + {% else %} + {{_('-')}} + {% endif %}{% if 'sonos_recv' in item.conf %} {{ item.conf['sonos_recv'] }} {% else %} {{_('-')}} {% endif %}{% if 'sonos_send' in item.conf %} {{ item.conf['sonos_send'] }} {% else %} {{_('-')}} {% endif %}{% if 'sonos_attrib' in item.conf %} {{ item.conf['sonos_attrib'] }} {% else %} {{_('-')}} {% endif %}{% if 'sonos_dpt3_step' in item.conf %} {{ item.conf['sonos_dpt3_step'] }} {% else %} {{_('-')}} {% endif %}{% if 'sonos_dpt3_time' in item.conf %} {{ item.conf['sonos_dpt3_time'] }} {% else %} {{_('-')}} {% endif %}.{{ item._value }}
+
+{% endblock bodytab1 %} +{% block bodytab2 %}
-
- +
+ +
+

Zones

- - + + + + + + - {% if speakerlist %} - {% for speaker in speakerlist %} + {% for zone in p.zones %} - - - + + + + + + + + {% endfor %} + +
{{ _('Speaker') }}
{{ _('Zone') }} {{ _('IP') }} {{ _('UID') }}{{ _('Is Coordinator') }}{{ _('Has Satellites') }}{{ _('Is Satellite') }}
{{ speaker.name }}{{ speaker.ip }}{{ speaker.uid }}{{ zone._player_name }}{{ zone.ip_address }}{{ zone.uid }}{{ zone.is_coordinator }}{{ zone.has_satellites }}{{ zone._is_satellite }}
+
+
+

Speakers

+
+ + + + + + + + + + + + {% for speaker in p.sonos_speaker %} + {% if p.sonos_speaker[speaker]._uid %} + + + + + + + + {% endif %} {% endfor %} - {% else %} - - - - {% endif %}
{{ _('No') }}{{ _('Name') }}{{ _('UID') }}{{ _('Play Status') }}
{{ loop.index }}{{ p.sonos_speaker[speaker].player_name }}{{ p.sonos_speaker[speaker]._uid }} + {% if p.sonos_speaker[speaker]._play %} + {{ _('Play') }} + {% elif p.sonos_speaker[speaker]._pause %} + {{ _('Pause') }} + {% elif p.sonos_speaker[speaker]._stop %} + {{ _('Stop') }} + {% else %} + {{ _('Unknown') }} + {% endif %} +
{{ _('Keine aktiven Speaker') }}
+{% endblock bodytab2 %} -{% endblock bodytab1 %} +{% block bodytab3 %} +
+ + +
+ + + + + + + + + {% for zone in p.zones %} + + + + + {% endfor %} + +
{{ _('Zone') }}{{ _('Zone Details') }}
{{ loop.index }}{{ zone.__dict__ }}
+
+
+ + + + + + + + + {% for speaker in p.sonos_speaker %} + + + + + {% endfor %} + +
{{ _('Speaker') }}{{ _('Speaker Details') }}
{{ loop.index }}{{ p.sonos_speaker[speaker].__dict__ }}
+
+
+{% endblock bodytab3 %} diff --git a/speech/user_doc.rst b/speech/user_doc.rst index d8cd035ae..98a4d74a0 100755 --- a/speech/user_doc.rst +++ b/speech/user_doc.rst @@ -1,21 +1,26 @@ -Speech + +.. index:: Plugins; speech +.. index:: speech + +====== +speech ====== -Das Speech Plugin nutzt Android um aus Sprachbefehlen Text zu machen, die dann mit dem Plugin analysiert werden um um Aktionen im Haus auszulösen. +Das Speech Plugin nutzt Android um aus Sprachbefehlen Text zu machen, die dann mit dem Plugin analysiert werden um um Aktionen im Haus auszulösen. Es wird eine Kombination aus Tasker und AutoVoice Plugin verwendet in Verbindung mit der Google Spracherkennung. Das bedeutet natürlich im Gegenzug, das die Sprachdaten in die Cloud zur Erkennung geschickt werden. -Der erste Abschnitt enthält Listen die Begriffe und Rückgabewerte beinhalten, z.B. werden Begriffe unter unterschiedlichen Namen angesprochen, +Der erste Abschnitt enthält Listen die Begriffe und Rückgabewerte beinhalten, z.B. werden Begriffe unter unterschiedlichen Namen angesprochen, das Licht in einem Raum als "Beleuchtung", "Lampe", "Licht", "Leuchte" usw. In der Konfigurationsdatei gibt es für die häufigsten Fälle Wortkombinationen die als Basis für die eigene Sprachsteuerung verwendet werden können. -Der zweite Abschnitt sind die Regeln nach denen die Items angesprochen werden. -Im wesentlichen werden verschiedene vorher definierte Variablen/Listen kombiniert um Aktionen auszuführen, +Der zweite Abschnitt sind die Regeln nach denen die Items angesprochen werden. +Im wesentlichen werden verschiedene vorher definierte Variablen/Listen kombiniert um Aktionen auszuführen, z.B. Raum, Licht, Schalten um die Beleuchtung zu schalten. Beispiele finden sich in der beiliegenden Konfigurationsdatei. Der dritte Abschnitt enthält Fehlermeldungen die zurückgegeben werden, wenn z. B. ein Befehl nicht erkannt wurde, hier muss am Anfang nicht verändert werden. Funktionsweise --------------- +============== * Spracherkennung mit "OK Google" oder durch betätigen des Mikrofon-Symbols starten. @@ -29,3 +34,9 @@ Funktionsweise Wenn eine Regel zutrifft dann wird das entsprechende Item gesetzt oder die Logik getriggert. Am Ende wird noch eine Antwort generiert und über das Smartphone als Sprache ausgegeben. + +Konfiguration +============= + +Diese Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/speech` beschrieben. diff --git a/stateengine/StateEngineAction.py b/stateengine/StateEngineAction.py index 3342add78..4f429e431 100755 --- a/stateengine/StateEngineAction.py +++ b/stateengine/StateEngineAction.py @@ -269,10 +269,10 @@ def execute(self, is_repeat: bool, allow_item_repeat: bool, state): delay, self._scheduler_name) _delay_info = 0 if delay is None: - self._log_warning("Action'{0}: Ignored because of errors while determining the delay!", self._name) + self._log_warning("Action '{0}': Ignored because of errors while determining the delay!", self._name) _delay_info = -1 elif delay < 0: - self._log_warning("Action'{0}: Ignored because of delay is negative!", self._name) + self._log_warning("Action '{0}': Ignored because delay is negative!", self._name) _delay_info = -1 else: self._waitforexecute(actionname, self._name, repeat_text, delay, current_condition_met, previous_condition_met, previousstate_condition_met) diff --git a/stateengine/StateEngineItem.py b/stateengine/StateEngineItem.py index 5a6f4c703..8cd30e923 100755 --- a/stateengine/StateEngineItem.py +++ b/stateengine/StateEngineItem.py @@ -44,6 +44,10 @@ class SeItem: def id(self): return self.__id + @property + def log_level(self): + return self.__log_level + @property def variables(self): return self.__variables @@ -471,8 +475,11 @@ def run_queue(self): if last_state is None: self.__logger.info("No matching state found, no previous state available. Doing nothing.") else: - _last_conditionset_id = self.__conditionsets[_wouldenter][0] - _last_conditionset_name = self.__conditionsets[_wouldenter][1] + try: + _last_conditionset_id = self.__conditionsets[_wouldenter][0] + _last_conditionset_name = self.__conditionsets[_wouldenter][1] + except: + pass if last_state.conditions.count() == 0: self.lastconditionset_set('', '') _last_conditionset_id = '' diff --git a/stateengine/__init__.py b/stateengine/__init__.py index e6627eb7c..e15e676da 100755 --- a/stateengine/__init__.py +++ b/stateengine/__init__.py @@ -39,7 +39,7 @@ class StateEngine(SmartPlugin): - PLUGIN_VERSION = '1.9.2' + PLUGIN_VERSION = '1.9.5' # Constructor # noinspection PyUnusedLocal,PyMissingConstructor @@ -47,7 +47,7 @@ def __init__(self, sh): super().__init__() StateEngineDefaults.logger = self.logger self.itemsApi = Items.get_instance() - self.__items = self.abitems = {} + self._items = self.abitems = {} self.mod_http = None self.__sh = sh self.alive = False @@ -59,7 +59,6 @@ def __init__(self, sh): StateEngineDefaults.log_level = log_level log_directory = self.__log_directory self.logger.info("Init StateEngine (log_level={0}, log_directory={1})".format(log_level, log_directory)) - StateEngineDefaults.startup_delay = self.get_parameter_value("startup_delay_default") StateEngineDefaults.suspend_time = self.get_parameter_value("suspend_time_default") StateEngineDefaults.instant_leaveaction = self.get_parameter_value("instant_leaveaction") @@ -116,16 +115,16 @@ def run(self): try: abitem = StateEngineItem.SeItem(self.get_sh(), item, self) abitem.ab_alive = True - self.__items[abitem.id] = abitem + self._items[abitem.id] = abitem except ValueError as ex: self.logger.error("Problem with Item: {0}: {1}".format(item.property.path, ex)) - if len(self.__items) > 0: - self.logger.info("Using StateEngine for {} items".format(len(self.__items))) + if len(self._items) > 0: + self.logger.info("Using StateEngine for {} items".format(len(self._items))) else: self.logger.info("StateEngine deactivated because no items have been found.") - self.__cli = StateEngineCliCommands.SeCliCommands(self.get_sh(), self.__items, self.logger) + self.__cli = StateEngineCliCommands.SeCliCommands(self.get_sh(), self._items, self.logger) self.alive = True self.get_sh().stateengine_plugin_functions.ab_alive = True @@ -133,11 +132,11 @@ def run(self): def stop(self): self.logger.debug("stop method called") self.scheduler_remove('StateEngine: Remove old logfiles') - for item in self.__items: - self.__items[item].ab_alive = False + for item in self._items: + self._items[item].ab_alive = False self.scheduler_remove('{}'.format(item)) self.scheduler_remove('{}-Startup Delay'.format(item)) - self.__items[item].remove_all_schedulers() + self._items[item].remove_all_schedulers() self.alive = False self.get_sh().stateengine_plugin_functions.ab_alive = False @@ -175,16 +174,16 @@ def get_items(self): :return: sorted itemlist """ - sortedlist = sorted([k for k in self.__items.keys()]) + sortedlist = sorted([k for k in self._items.keys()]) finallist = [] for i in sortedlist: - finallist.append(self.__items[i]) + finallist.append(self._items[i]) return finallist def get_graph(self, abitem, graphtype='link'): if isinstance(abitem, str): - abitem = self.__items[abitem] + abitem = self._items[abitem] webif = StateEngineWebif.WebInterface(self.__sh, abitem) try: os.makedirs(self.path_join(self.get_plugin_dir(), 'webif/static/img/visualisations/')) diff --git a/stateengine/locale.yaml b/stateengine/locale.yaml index fe956f85f..bf1520f27 100755 --- a/stateengine/locale.yaml +++ b/stateengine/locale.yaml @@ -1,7 +1,7 @@ plugin_translations: # Translations for the plugin specially for the web interface 'Die folgenden Items verfügen über ein StateEngine Item': {'de': '=', 'en': 'The following items have a StateEngine item assigned'} - 'Klick auf die entsprechende Zeile öffnet die Visualisierung': {'de': '=', 'en': 'Clicking a specific line opens the visualization'} + 'Klick auf das Lupensymbol öffnet die Visualisierung!': {'de': '=', 'en': 'Clicking the magnifying symbol opens the visualization'} 'Visualisierung': {'de': '=', 'en': 'Visualization'} 'Zustände': {'de': '=', 'en': 'States'} 'aktueller Zustand': {'de': '=', 'en': 'current state'} @@ -12,3 +12,4 @@ plugin_translations: 'Suspend Dauer': {'de': '=', 'en': 'Suspend Duration'} 'Items': {'de': '=', 'en': '='} 'SE Item': {'de': '=', 'en': '='} + 'Detailvisualisierung': {'de': '=', 'en': 'Detailed Visualization'} diff --git a/stateengine/plugin.yaml b/stateengine/plugin.yaml index 529f88ec5..e59f0f985 100755 --- a/stateengine/plugin.yaml +++ b/stateengine/plugin.yaml @@ -37,10 +37,9 @@ plugin: maintainer: onkelandy tester: '?' state: ready - documentation: https://www.smarthomeng.de/user/plugins/stateengine/user_doc.html support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1303071-stateengine-plugin-support - version: 1.9.2 + version: 1.9.5 sh_minversion: 1.6 multi_instance: False classname: StateEngine @@ -654,7 +653,7 @@ item_structs: type: str visu_acl: ro cache: True - + previousstate_id: remark: The id/path of the previous state is assigned to this item by the stateengine type: str diff --git a/stateengine/user_doc.rst b/stateengine/user_doc.rst index cde5a5ce3..58bf3d5ac 100755 --- a/stateengine/user_doc.rst +++ b/stateengine/user_doc.rst @@ -1,11 +1,16 @@ - .. index:: Plugins; stateengine -.. index:: stateengine; Plugin +.. index:: stateengine =========== stateengine =========== +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 1000px + :height: 472px + :scale: 30 % + :align: right .. toctree:: :titlesonly: @@ -23,3 +28,38 @@ stateengine user_doc/11_sonderzustaende.rst user_doc/12_aktioneneinzeln.rst user_doc/13_sonstiges.rst + +Web Interface +============= + +Das Webinterface bietet folgende Übersichtsinformationen: + +- **Allgemeines**: Oben rechts werden Loglevel, Logverzeichnis, Startverzögerung, standardmäßige Suspendzeit sowie die Anzahl Items angezeigt. + +- **SE_Item**: Item, das eine Stateengine definiert hat + +- **aktueller Zustand** + +- **aktuelles Bedingungsset** + +- **Visu**: Klick auf das Icon öffnet die Detailansicht + +- **Log Level**: Log Level des Items. Kann den Wert 1 (Standard), 2 (Verbose) oder 3 (Develop) enthalten. + +- **Zustände**: sämtliche konfigurierte Zustände des Items + + .. image:: user_doc/assets/webif_stateengine_overview.png + :height: 1602px + :width: 3320px + :scale: 25% + :alt: Web Interface Overview + :align: center + +Ein Klick auf das Lupensymbol in der Visu-Spalte öffnet die Detailansicht. Hier ist zu sehen, welcher Zustand eingenommen werden könnte, welcher aktiv ist und welche Aktionen bei welcher Bedingung ausgeführt werden. + + .. image:: user_doc/assets/webif_stateengine_detail.png + :height: 1656px + :width: 3312px + :scale: 25% + :alt: Web Interface Detail + :align: center diff --git a/stateengine/user_doc/01_allgemein.rst b/stateengine/user_doc/01_allgemein.rst index 8a135166e..896d0992a 100755 --- a/stateengine/user_doc/01_allgemein.rst +++ b/stateengine/user_doc/01_allgemein.rst @@ -53,8 +53,8 @@ Die folgenden Bedingungen können Teil der Bedingungsgruppen sein: Zusätzlich können beliebige Items (z.B. Temperatur) als Bedingungen geprüft werden (Minimum, Maximum oder Wert) -Umstieg von Autblind --------------------- +Umstieg von Autoblind +--------------------- Das `Autoblind Plugin `__ von i-am-offline wurde für SmarthomeNG 1.6 ins offizielle Repo übernommen und @@ -69,7 +69,7 @@ Webinterface Über das Webinterface lässt sich auf einen Blick erkennen, welche State Engine sich in welchem Zustand befindet. Zusätzlich ist es möglich, durch Klick auf einen Eintrag die komplette State Engine visuell zu betrachten. Dabei ist folgende Farbkodierung zu beachten: -- grau: wurde nicht evaluiert (weil bereits ein höher rangiger Zustand eingenommen wurde) +- grau: wurde nicht evaluiert (weil bereits ein höherrangiger Zustand eingenommen wurde) - grün: aktueller Zustand / ausgeführte Aktion - rot: Bedingungen nicht erfüllt diff --git a/stateengine/user_doc/assets/webif_stateengine_detail.png b/stateengine/user_doc/assets/webif_stateengine_detail.png new file mode 100755 index 000000000..a3dccc55d Binary files /dev/null and b/stateengine/user_doc/assets/webif_stateengine_detail.png differ diff --git a/stateengine/user_doc/assets/webif_stateengine_overview.png b/stateengine/user_doc/assets/webif_stateengine_overview.png new file mode 100755 index 000000000..2fc2d7325 Binary files /dev/null and b/stateengine/user_doc/assets/webif_stateengine_overview.png differ diff --git a/stateengine/webif/__init__.py b/stateengine/webif/__init__.py index efa86dc1d..4ce23edc6 100755 --- a/stateengine/webif/__init__.py +++ b/stateengine/webif/__init__.py @@ -29,6 +29,7 @@ import time import os import logging +import json from lib.item import Items from lib.model.smartplugin import SmartPluginWebIf @@ -70,7 +71,7 @@ def index(self, action=None, item_id=None, item_path=None, reload=None, abitem=N item = self.plugin.itemsApi.return_item(item_path) tmpl = self.tplenv.get_template('{}.html'.format(page)) - + pagelength = self.plugin.get_parameter_value('webif_pagelength') if action == "get_graph" and abitem is not None: if isinstance(abitem, str): try: @@ -85,6 +86,8 @@ def index(self, action=None, item_id=None, item_path=None, reload=None, abitem=N language=self.plugin.get_sh().get_defaultlanguage(), now=self.plugin.shtime.now()) # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, + item_count=len(self.plugin._items), language=self.plugin.get_sh().get_defaultlanguage(), now=self.plugin.shtime.now()) @cherrypy.expose @@ -100,14 +103,12 @@ def get_data_html(self, dataSet=None): if dataSet is None: # get the new data data = {} - - # data['item'] = {} - # for i in self.plugin.items: - # data['item'][i]['value'] = self.plugin.getitemvalue(i) - # - # return it as json the the web page - # try: - # return json.dumps(data) - # except Exception as e: - # self.logger.error("get_data_html exception: {}".format(e)) + for item in self.plugin.get_items(): + conditionset = item.lastconditionset_name + conditionset = "-" if conditionset == "" else conditionset + data.update({item.id: {'laststate': item.laststate_name, 'lastconditionset': conditionset}}) + try: + return json.dumps(data) + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") return {} diff --git a/stateengine/webif/templates/index.html b/stateengine/webif/templates/index.html index 4797fe228..b6b70a42f 100755 --- a/stateengine/webif/templates/index.html +++ b/stateengine/webif/templates/index.html @@ -1,4 +1,19 @@ {% extends "base_plugin.html" %} +{% set update_interval = 5000 %} +{% block pluginstyles %} + +{% endblock pluginstyles %} {% block pluginscripts %} {{ super() }} + {% endblock pluginscripts %} {% set logo_frame = false %} - -{% set item_count = p.__items|length %} {% set tab1title = "Items" %} {% set tab2title = "" ~ _('Visualisierung') ~ "" %} {% set tabcount = 1 %} {% block headtable %} - - - - @@ -70,33 +116,38 @@ {% block bodytab1 %} -
+
{{ _('Die folgenden Items verfügen über ein StateEngine Item') }}. - {{ _('Klick auf die entsprechende Zeile öffnet die Visualisierung!') }} + {{ _('Klick auf das Lupensymbol öffnet die Visualisierung!') }}
-
{{ _('Log Level') }} {{ p.get_parameter_value_for_display('log_level') }} {{ _('Log Verzeichnis') }} {{ p.get_parameter_value_for_display('log_directory') }}
{{ _('Startverzögerung') }} {{ p.get_parameter_value_for_display('startup_delay_default') }} {{ _('Suspend Dauer') }} {{ p.get_parameter_value_for_display('suspend_time_default') }}
{{ _('Items') }} {{ item_count }}
+
- + - + + + + {% for item in p.get_items() %} - + - - - - + + + + + {% endfor %} +
{{ _('SE Item') }}{{ _('Zustände') }} {{ _('aktueller Zustand') }} {{ _('aktuelles Bedingungsset') }}Visu{{ _('Log Level') }}{{ _('Zustände') }}
{{ item }}{% for cond in item.webif_infos.keys() %}{% if not p.itemsApi.return_item(cond) == None %} - {% if loop.index > 1%},{% endif %} - {{ p.itemsApi.return_item(cond)._name.split('.')[-1] }}{% endif %}{% endfor %}{{ item.laststate_name }}{{ item.lastconditionset_name }}{{ item.laststate_name }} + {% if item.lastconditionset_name == "" %}-{% else %}{{ item.lastconditionset_name }}{% endif %} + {{ item.log_level }}{% for cond in item.webif_infos.keys() %}{% if not p.itemsApi.return_item(cond) == None %}{% if loop.index > 1 %},{% endif %}{{ p.itemsApi.return_item(cond)._name.split('.')[-1] }}{% endif %}{% endfor %}
diff --git a/tankerkoenig/README.md b/tankerkoenig/README.md index 834258316..955c9cd39 100755 --- a/tankerkoenig/README.md +++ b/tankerkoenig/README.md @@ -1,7 +1,5 @@ # TankerKoenig -Version 0.1 - ## Requirements This plugin requires lib requests. You can install this lib with: @@ -89,8 +87,8 @@ Returned is an array of petrol station data, with the following available keys: 'place', 'brand', 'houseNumber', 'street', 'id', 'lng', 'name', 'lat', 'price', 'dist', 'isOpen', 'postCode' Note: Take care with too high rad values, as this also increases load on tankerkoenig interface. -### get_petrol_station_detail(id) -This funktion gets the details of one petrol station, identified by its internal TankerKoenig ID. +### get_petrol_station_detail_full(id) +This function gets the details (incl. prices) of one petrol station, identified by its internal TankerKoenig ID. ```python detail = sh.tankerkoenig.get_petrol_station_detail(sh.petrol_station.DemoBavariaPetrol.conf['tankerkoenig_id']) @@ -116,7 +114,7 @@ sh.petrol_station.DemoBavariaPetrol.isOpen(detail['isOpen']) sh.petrol_station.DemoBavariaPetrol.diesel(detail['diesel']) ``` -### Get prices of two petrol stations +### Get prices of one petrol stations ```python prices = sh.tankerkoenig.get_petrol_station_prices([sh.petrol_station.DemoBavariaPetrol.conf['tankerkoenig_id']]) ``` diff --git a/tankerkoenig/__init__.py b/tankerkoenig/__init__.py index e83b014f0..0434c2b98 100755 --- a/tankerkoenig/__init__.py +++ b/tankerkoenig/__init__.py @@ -1,11 +1,14 @@ #!/usr/bin/env python3 # ######################################################################### -# Copyright 2016 René Frieß rene.friess(a)gmail.com -# Version 1.1.1 +# Copyright 2016 René Frieß rene.friess(a)gmail.com +# Copyright 2022 Michael Wenzel wenzel_michael(a)web.de ######################################################################### -# # This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Plugin to get prices from TankerKoenig # # SmartHomeNG is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -22,61 +25,182 @@ # ######################################################################### -import logging +from lib.model.smartplugin import SmartPlugin +from lib.item import Items + import requests import json -from lib.model.smartplugin import SmartPlugin + +from .webif import WebInterface + +'https://creativecommons.tankerkoenig.de/json/' class TankerKoenig(SmartPlugin): - PLUGIN_VERSION = "1.4.1" - _base_url = 'https://creativecommons.tankerkoenig.de/json/' + PLUGIN_VERSION = "2.0.1" + + _base_url = 'https://creativecommons.tankerkoenig.de/json' _detail_url_suffix = 'detail.php' _prices_url_suffix = 'prices.php' _list_url_suffix = 'list.php' - def __init__(self, sh, *args, **kwargs): + def __init__(self, sh): """ Initializes the plugin - @param apikey: For accessing the free "Tankerkönig-Spritpreis-API" you need a personal - api key. For your own key register to https://creativecommons.tankerkoenig.de + + For accessing the free "Tankerkönig-Spritpreis-API" you need a personal api key. For your own key register to https://creativecommons.tankerkoenig.de """ - self._apikey = self.get_parameter_value('apikey') + + # Call init code of parent class (SmartPlugin) + super().__init__() + + self.item_dict = {} + self.station_ids = [] + self.station_details = {} + self.station_prices = {} + self._lat = self.get_sh()._lat + self._lon = self.get_sh()._lon self._session = requests.Session() + self.alive = False + + # get the parameters for the plugin (as defined in metadata plugin.yaml): + # get the parameters for the plugin (as defined in metadata plugin.yaml): + try: + self.webif_pagelength = self.get_parameter_value('webif_pagelength') + self._apikey = self.get_parameter_value('apikey') + self.price_update_cycle = self.get_parameter_value('price_update_cycle') + self.details_update_cycle = self.get_parameter_value('details_update_cycle') + except KeyError as e: + self.logger.critical(f"Plugin '{self.get_shortname()}': Inconsistent plugin (invalid metadata definition: {e} not defined).") + self._init_complete = False + return + + # init WebInterface + self.init_webinterface(WebInterface) def run(self): + """ + Run method for the plugin + """ + + self.logger.debug("Run method called") + + # create scheduler for price data updates + self.scheduler_add('update_status_data', self.update_status_data, cycle=self.price_update_cycle) + + # create scheduler for detailed data updates + _cycle = {'daily': 24*60*60, 'weekly': 7*24*60*60, 'monthly': 30.5*24*60*60}[self.details_update_cycle] + self.scheduler_add('update_detail_data', self.update_detail_data, cron='init+30', cycle=_cycle) + self.alive = True def stop(self): + """ + Stop method for the plugin + """ + + self.logger.debug("Stop method called") + self.scheduler_remove('update_status_data') self.alive = False - def get_petrol_stations(self, lat, lon, type='diesel', sort='price', rad=4): + def parse_item(self, item): + """ + Default plugin parse_item method. Is called when the plugin is initialized. + The plugin can, corresponding to its attribute keywords, decide what to do with + the item in the future, like adding it to an internal array for future reference + :param item: The item to process. + :return: If the plugin needs to be informed of an items change you should return a call back function + like the function update_item down below. An example when this is needed is the knx plugin + where parse_item returns the update_item function when the attribute knx_send is found. + This means that when the items value is about to be updated, the call back function is called + with the item, caller, source and dest as arguments and in case of the knx plugin the value + can be sent to the knx with a knx write function within the knx plugin. + """ + + if self.has_iattr(item.conf, 'tankerkoenig_id'): + self.logger.debug(f"parse item: {item}") + station_id = self.get_iattr_value(item.conf, 'tankerkoenig_id') + tankerkoenig_attr = self.get_iattr_value(item.conf, 'tankerkoenig_attr') + + if station_id and tankerkoenig_attr: + self.logger.debug(f"parse_item: tankerkoenig_attr={tankerkoenig_attr} with station_id={station_id} detected, item added") + self.item_dict[item] = {'station_id': station_id, 'tankerkoenig_attr': tankerkoenig_attr.lower()} + + if station_id not in self.station_ids: + self.station_ids.append(station_id) + + if self.has_iattr(item.conf, 'tankerkoenig_admin'): + self.logger.debug(f"parse item: {item.id()}") + tankerkoenig_admin = self.get_iattr_value(item.conf, 'tankerkoenig_admin') + if tankerkoenig_admin == 'update': + return self.update_item + + def update_item(self, item, caller=None, source=None, dest=None): + """ + Item has been updated + + This method is called, if the value of an item has been updated by SmartHomeNG. + It should write the changed value out to the device (hardware/interface) that + is managed by this plugin. + + :param item: item to be updated towards the plugin + :param caller: if given it represents the callers name + :param source: if given it represents the source + :param dest: if given it represents the dest + """ + + if self.alive and caller != self.get_shortname(): + self.logger.info(f"Update item: {item.property.path}, item has been changed outside this plugin") + + if self.get_iattr_value(item.conf, 'tankerkoenig_admin').lower() == 'update' and item(): + self.logger.debug("Data update has been initiated.") + self.update_status_data() + item(False) + return None + +################################################### +# Public Functions +################################################### + + def get_petrol_stations(self, lat: float = None, lon: float = None, price: str = 'diesel', sort: str = 'price', rad: float = 4) -> list: """ Returns a list of information for petrol stations around a specific location and radius Should not be used extensively, due to performance issues on tankerkoenig side. - https://creativecommons.tankerkoenig.de/#techInfo + @param lat: latitude of center to retrieve petrol station information for - @param long: longitude of center to retrieve petrol station information for - @param type: price type, e.g. diesel + @param lon: longitude of center to retrieve petrol station information for + @param price: price type, e.g. diesel @param sort: sort type, e.g. price @param rad: radius in kilometers """ + + # set default for lat and lon + if lat is None: + lat = self._lat + if lon is None: + lon = self._lon + + # check if value for price + if price not in ['e5', 'e10', 'diesel', 'all']: + self.logger.error(f"Plugin '{self.get_fullname()}': Used value={price} for 'price' at 'get_petrol_stations' not allowed. Set to default 'all'.") + price = 'all' + + # check if value for sort + if sort not in ['price']: + self.logger.error(f"Plugin '{self.get_fullname()}': Used value={sort} for 'sort' at 'get_petrol_stations' not allowed. Set to default 'price'.") + sort = 'price' + + # limit radius to 25km + if float(rad) > 25: + self.logger.error(f"Plugin '{self.get_fullname()}': Used value={rad} for 'rad' at 'get_petrol_stations' not allowed. Set to max allowed value '25km'.") + rad = 25 + result_stations = [] - try: - response = self._session.get(self._build_url("%s?lat=%s&lng=%s&rad=%s&sort=%s&type=%s&apikey=%s" % ( - self._list_url_suffix, lat, lon, rad, sort, type, self._apikey))) - except Exception as e: - self.logger.error( - "Exception when sending GET request for get_petrol_stations: %s" % str(e)) - return - self.logger.debug("Plugin '{}': {}".format(self.get_fullname(), self._build_url( - "%s?lat=%s&lng=%s&rad=%s&sort=%s&type=%s&apikey=%s" % ( - self._list_url_suffix, lat, lon, rad, sort, type, self._apikey)))) - json_obj = response.json() - keys = ['place', 'brand', 'houseNumber', 'street', 'id', 'lng', 'name', 'lat', 'price', 'dist', 'isOpen', - 'postCode'] + json_obj = self._request_stations(lat=lat, lon=lon, price=price, sort=sort, rad=rad) + + keys = ['place', 'brand', 'houseNumber', 'street', 'id', 'lng', 'name', 'lat', 'price', 'dist', 'isOpen', 'postCode'] if json_obj.get('stations', None) is None: - self.logger.warning("Plugin '{}': Tankerkönig didn't return any station".format(self.get_fullname())) + self.logger.warning(f"Plugin '{self.get_fullname()}': Tankerkönig didn't return any station") else: for i in json_obj['stations']: result_station = {} @@ -85,74 +209,317 @@ def get_petrol_stations(self, lat, lon, type='diesel', sort='price', rad=4): result_stations.append(result_station) return result_stations - def get_petrol_station_detail(self, id): + def get_petrol_station_detail(self, station_id: str) -> dict: """ Returns detail information for a petrol station id + Should not be used extensively, due to performance issues on tankerkoenig side. - https://creativecommons.tankerkoenig.de/#techInfo - @param id: Internal ID of petrol station to retrieve information for + + @param station_id: Internal ID of petrol station to retrieve information for """ - try: - response = self._session.get( - self._build_url("%s?id=%s&apikey=%s" % (self._detail_url_suffix, id, self._apikey))) - except Exception as e: - self.logger.error( - "Plugin '{}': Exception when sending GET request for get_petrol_station_detail: {}".format( - self.get_fullname(), str(e))) - return - json_obj = response.json() - keys = ['e5', 'e10', 'diesel', 'street', 'houseNumber', 'postCode', 'place', 'brand', 'id', 'lng', 'name', - 'lat', 'isOpen'] + + json_obj = self._request_station_detail(station_id) + + keys = ['e5', 'e10', 'diesel', 'street', 'houseNumber', 'postCode', 'place', 'brand', 'id', 'lng', 'name', 'lat', 'isOpen'] result_station = {} + try: i = json_obj['station'] for key in keys: result_station[key] = i[key] - except: + except Exception: pass return result_station - def get_petrol_station_prices(self, ids): + def get_petrol_station_detail_reduced(self, station_id: str) -> dict: + """ + Returns reduced detail information (no príces and "open" status) for a petrol station id + + Should not be used extensively, due to performance issues on tankerkoenig side. + + @param station_id: Internal ID of petrol station to retrieve information for + """ + station_details = self.get_petrol_station_detail(station_id) + + # clean dict + keys_to_be_deleted = ['e5', 'e10', 'diesel', 'isOpen'] + for item in keys_to_be_deleted: + if item in station_details: + del station_details[item] + + # add address + _street = station_details.get('street', None) + _housenumber = station_details.get('houseNumber', None) + _postcode = station_details.get('postCode', None) + _place = station_details.get('place', None) + station_details['address'] = f"{_street} {_housenumber}\n{_postcode} {_place}" + + return station_details + + def get_petrol_station_prices(self, station_ids: list) -> dict: """ - Returns a list of prices for an array of petrol station ids + Returns a dict of prices for an array of petrol station ids + Recommended to be used by tankerkoenig team due to performance issues!!! + + @param station_ids: Array of tankerkoenig internal petrol station ids to retrieve the prices for + """ + _station_id_prices = self._request_station_prices(station_ids) + if _station_id_prices is None: + self.logger.error( + f"get_petrol_station_prices: self._request_station_prices(station_ids) returned invalid result") + return None + _price_dict = _station_id_prices.get('prices', None) + for station_id in station_ids: + if station_id not in _price_dict: + self.logger.error(f"Plugin '{self.get_fullname()}': No result for station with id {station_id}. Check manually!") + + if _price_dict and isinstance(_price_dict, dict): + for station_id in _price_dict: + if 'status' in _price_dict[station_id]: + _price_dict[station_id]['open'] = True if _price_dict[station_id]['status'].lower() == 'open' else False + + return _price_dict + +################################################### +# Plugin Functions +################################################### + + def update_status_data(self): + """ + Gets price and status data for all defined stations and updates item values + """ + + self.station_prices = self.get_petrol_station_prices(self.station_ids) + return self.set_item_status_values() + + def update_detail_data(self): + """ + Gets price and status data for all defined stations and updates item values + """ + + self.station_details = self.get_station_details() + return self.set_item_detail_values() + + def get_station_details(self): + """ + Gets details for all defined stations and put it to plugin dict. + """ + + stations_details = {} + + for station_id in self.station_ids: + station_details = self.get_petrol_station_detail_reduced(station_id) + stations_details[station_id] = station_details + + return stations_details + + def set_item_status_values(self): + """ + Set values of status items + """ + + for item in self.item_dict: + self.logger.debug(f"set_item_status_values: handle item {item} type {item.type()}") + station_id = self.item_dict[item]['station_id'] + tankerkoenig_attr = self.item_dict[item]['tankerkoenig_attr'] + value = self.station_prices.get(station_id, None).get(tankerkoenig_attr, None) + self.logger.debug(f"set_item_status_values: station_id={station_id}, tankerkoenig_attr={tankerkoenig_attr}, value={value}") + if value: + item(value, self.get_shortname()) + + def set_item_detail_values(self): + """ + Set values of details items + """ + + for item in self.item_dict: + station_id = self.item_dict[item]['station_id'] + tankerkoenig_attr = self.item_dict[item]['tankerkoenig_attr'] + value = self.station_details.get(station_id, None).get(tankerkoenig_attr, None) + self.logger.debug(f"set_item_detail_values: station_id={station_id}, tankerkoenig_attr={tankerkoenig_attr}, value={value}") + if value: + item(value, self.get_shortname()) + + def _request_stations(self, lat: float = None, lon: float = None, price: str = 'diesel', sort: str = 'price', rad: float = 4) -> dict: + """ + Returns a dict of information for petrol stations around a specific location and radius + + Should not be used extensively, due to performance issues on tankerkoenig side. https://creativecommons.tankerkoenig.de/#techInfo - @param ids: Array of tankerkoenig internal petrol station ids to retrieve the prices for + + URL: + https://creativecommons.tankerkoenig.de/json/list.php?lat=52.521&lng=13.438&rad=1.5&sort=dist&type=all&apikey=00000000-0000-0000-0000-000000000002 + + Reponse: + [ + { Datentyp, Bedeutung + "id": "474e5046-deaf-4f9b-9a32-9797b778f047", - UUID, eindeutige Tankstellen-ID + "name": "TOTAL BERLIN", - String, Name + "brand": "TOTAL", - String, Marke + "street": "MARGARETE-SOMMER-STR.", - String, Straße + "place": "BERLIN", - String, Ort + "lat": 52.53083, - float, geographische Breite + "lng": 13.440946, - float, geographische Länge + "dist": 1.1, - float, Entfernung zum Suchstandort in km + "diesel": 1.109, \ + "e5": 1.339, - float, Spritpreise in Euro + "e10": 1.319, / + "isOpen": true, - boolean, true, wenn die Tanke zum Zeitpunkt der Abfrage offen hat, sonst false + "houseNumber": "2", - String, Hausnummer + "postCode": 10407 - integer, PLZ + }, + {weitere Tankstelle}, + {} + ] + + @param lat: latitude of center to retrieve petrol station information for + @param lon: longitude of center to retrieve petrol station information for + @param price: price type, e.g. diesel + @param sort: sort type, e.g. price + @param rad: radius in kilometers """ - result_station_prices = [] - station_ids_string = json.dumps(ids) + + url = self._build_url(f"{self._list_url_suffix}?lat={lat}&lng={lon}&rad={rad}&sort={sort}&type={price}&apikey={self._apikey}") try: - response = self._session.get( - self._build_url("%s?ids=%s&apikey=%s" % (self._prices_url_suffix, station_ids_string, self._apikey))) + response = self._session.get(url) except Exception as e: - self.logger.error( - "Plugin '{}': Exception when sending GET request for get_petrol_station_detail: {}".format( - self.get_fullname(), str(e))) + self.logger.error(f"Plugin '{self.get_fullname()}': Exception when sending GET request for _request_petrol_stations: {e}") + return + + try: + return response.json() + except Exception as e: + self.logger.error(f"Plugin '{self.get_fullname()}': Exception when handling GET response to JSON for _request_petrol_stations: {e}") return - json_obj = response.json() - keys = ['e5', 'e10', 'diesel', 'status'] - - for id in ids: - if 'prices' in json_obj: - if id in json_obj['prices']: - result_station = dict() - result_station['id'] = id - for key in keys: - if key in json_obj['prices'][id]: - result_station[key] = json_obj['prices'][id][key] - else: - result_station[key] = "" - result_station_prices.append(result_station) - else: - self.logger.error( - "Plugin '{}': No result for station with id {}. Check manually!".format( - self.get_fullname(), id)) - else: - self.logger.error( - "Plugin '{}': 'prices' key missing in json response for station with id {}. Check manually!".format( - self.get_fullname(), id)) - return result_station_prices + + def _request_station_detail(self, station_id: str) -> dict: + """ + Returns detail information for a petrol station id + Should not be used extensively, due to performance issues on tankerkoenig side. + https://creativecommons.tankerkoenig.de/#techInfo + + URL: https://creativecommons.tankerkoenig.de/json/detail.php?id=24a381e3-0d72-416d-bfd8-b2f65f6e5802&apikey=00000000-0000-0000-0000-000000000002 + + Response: + { + "ok": true, + "license": "CC BY 4.0 - https:\/\/creativecommons.tankerkoenig.de", + "data": "MTS-K", + "status": "ok", + "station": { + "id": "24a381e3-0d72-416d-bfd8-b2f65f6e5802", + "name": "Esso Tankstelle", + "brand": "ESSO", + "street": "HAUPTSTR. 7", + "houseNumber": " ", + "postCode": 84152, + "place": "MENGKOFEN", + "openingTimes": [ - Array mit regulären Öffnungszeiten + { + "text": "Mo-Fr", + "start": "06:00:00", + "end": "22:30:00" + }, + { + "text": "Samstag", + "start": "07:00:00", + "end": "22:00:00" + }, + { + "text": "Sonntag", + "start": "08:00:00", + "end": "22:00:00" + } + ], + "overrides": [ - Array mit geänderten Öffnungszeiten + "13.04.2017, 15:00:00 - 13.11.2017, 15:00:00: geschlossen" - im angegebenen Zeitraum geschlossen + ], + "wholeDay": false, - nicht ganztägig geöffnet + "isOpen": false, + "e5": 1.379, + "e10": 1.359, + "diesel": 1.169, + "lat": 48.72210601, + "lng": 12.44438439, + "state": null - Bundesland nicht angegeben + } + } + + @param station_id: Internal ID of petrol station to retrieve information for + """ + + url = self._build_url(f"{self._detail_url_suffix}?id={station_id}&apikey={self._apikey}") + try: + response = self._session.get(url) + except Exception as e: + self.logger.error(f"Plugin '{self.get_fullname()}': Exception when sending GET request for get_petrol_station_detail: {e}") + return + + try: + return response.json() + except Exception as e: + self.logger.error(f"Plugin '{self.get_fullname()}': Exception when handling GET response to JSON for get_petrol_station_detail: {e}") + return + + def _request_station_prices(self, station_ids: list): + """ + Returns a json object with prices for an array of petrol station ids + + Recommended to be used by tankerkoenig team due to performance issues!!! + https://creativecommons.tankerkoenig.de/#techInfo + + URL: https://creativecommons.tankerkoenig.de/json/prices.php?ids=4429a7d9-fb2d-4c29-8cfe-2ca90323f9f8,446bdcf5-9f75-47fc-9cfa-2c3d6fda1c3b,60c0eefa-d2a8-4f5c-82cc-b5244ecae955,44444444-4444-4444-4444-444444444444&apikey=00000000-0000-0000-0000-000000000002 + + Response: + { + "ok": true, + "license": "CC BY 4.0 - https:\/\/creativecommons.tankerkoenig.de", + "data": "MTS-K", + "prices": { + "60c0eefa-d2a8-4f5c-82cc-b5244ecae955": { + "status": "open", - Tankstelle ist offen + "e5": false, - kein Super + "e10": false, - kein E10 + "diesel": 1.189 - Tankstelle führt nur Diesel + }, + "446bdcf5-9f75-47fc-9cfa-2c3d6fda1c3b": { + "status": "closed" - Tankstelle ist zu + }, + "4429a7d9-fb2d-4c29-8cfe-2ca90323f9f8": { + "status": "open", + "e5": 1.409, + "e10": 1.389, + "diesel": 1.129 + }, + "44444444-4444-4444-4444-444444444444": { + "status": "no prices" - keine Preise für Tankstelle verfügbar + } + } + } + + @param station_ids: Array of tankerkoenig internal petrol station ids to retrieve the prices for + """ + + station_ids_string = json.dumps(station_ids) + url = self._build_url(f"{self._prices_url_suffix}?ids={station_ids_string}&apikey={self._apikey}") + + try: + response = self._session.get(url) + except Exception as e: + self.logger.error(f"Plugin '{self.get_fullname()}': Exception when sending GET request for _request_station_prices: {e}") + return + + try: + return response.json() + except Exception as e: + self.logger.error(f"Plugin '{self.get_fullname()}': Exception when handling GET response to JSON for _request_station_prices: {e}") + return + +################################################### +# Helper Functions +################################################### def _build_url(self, suffix): """ @@ -160,5 +527,22 @@ def _build_url(self, suffix): @param suffix: url suffix @return: string of the url """ - url = "%s%s" % (self._base_url, suffix) - return url + return f"{self._base_url}/{suffix}" + + @property + def station_list(self): + """ + Returns a list of station ids to be requested + """ + return self.station_ids + + @property + def item_list(self): + """ + Returns a list of items with defined station_ids + """ + return list(self.item_dict.keys()) + + @property + def log_level(self): + return self.logger.getEffectiveLevel() diff --git a/tankerkoenig/locale.yaml b/tankerkoenig/locale.yaml new file mode 100755 index 000000000..c0984a9ee --- /dev/null +++ b/tankerkoenig/locale.yaml @@ -0,0 +1,10 @@ +# translations for the web interface +plugin_translations: + # Translations for the plugin specially for the web interface + 'Wert 2': {'de': '=', 'en': 'Value 2'} + 'Wert 4': {'de': '=', 'en': 'Value 4'} + + # Alternative format for translations of longer texts: + 'Hier kommt der Inhalt des Webinterfaces hin.': + de: '=' + en: 'Here goes the content of the web interface.' diff --git a/tankerkoenig/plugin.yaml b/tankerkoenig/plugin.yaml index 8cc961842..66b9b2be7 100755 --- a/tankerkoenig/plugin.yaml +++ b/tankerkoenig/plugin.yaml @@ -1,7 +1,7 @@ # Metadata for the Smart-Plugin plugin: # Global plugin attributes - type: web # plugin type (gateway, interface, protocol, system, web) + type: web description: de: 'Benzinpreise über die API von Tankerkönig. Bitte sicherstellen, die API nicht zu oft aufzurufen. Bitte Hinweise unter https://creativecommons.tankerkoenig.de/#techInfo beachten!' en: 'Petrol station prices by the API of TankerKönig. Take care not to request the interface too often or for too many petrol stations. Please follow instructions given on https://creativecommons.tankerkoenig.de/#techInfo.' @@ -12,9 +12,11 @@ plugin: documentation: http://smarthomeng.de/user/plugins_doc/config/tankerkoenig.html support: https://knx-user-forum.de/forum/supportforen/smarthome-py/938924-benzinpreis-plugin keywords: petrol station, fuel prices, petrol prices - version: 1.4.1 # Plugin version - sh_minversion: 1.4 # minimum shNG version to use this plugin + version: 2.0.1 # Plugin version + sh_minversion: 1.9 # minimum shNG version to use this plugin # sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) +# py_minversion: # minimum Python version to use for this plugin +# py_maxversion: # maximum Python version to use for this plugin (leave empty if latest) multi_instance: False # plugin supports multi instance restartable: unknown classname: TankerKoenig # class containing the plugin @@ -28,6 +30,28 @@ parameters: de: 'Persönlicher API Key für TankerKoenig. Registrierung unter https://creativecommons.tankerkoenig.de.' en: 'Your own personal API key for TankerKoenig. For your own key register to https://creativecommons.tankerkoenig.de.' + price_update_cycle: + type: int + mandatory: False + default: 900 + valid_min: 60 + description: + de: '(optional) Zeit zwischen zwei Preisaktualisierungen.' + en: '(optional) Time period between two price update cycles.' + + details_update_cycle: + type: str + mandatory: False + default: weekly + description: + de: '(optional) Zeit zwischen zwei Aktualisierung der Tankstellen Details.' + en: '(optional) Time period between two update cycles for petrol station details.' + valid_list: + - daily + - weekly + - monthly + + item_attributes: # Definition of item attributes defined by this plugin tankerkoenig_id: @@ -36,8 +60,98 @@ item_attributes: de: 'Id der Tankstelle auf tankerkoenig.de' en: 'Id of the gaz station on tankerkoenig.de' -item_structs: NONE - # Definition of item-structure templates for this plugin + tankerkoenig_admin: + type: str + description: + de: 'Admin-Funktionen des Plugins' + en: 'Admin functions of plugin' + valid_list_ci: + - 'update' + valid_list_description: + - 'Sofortiges Laden/Update der Daten' + + tankerkoenig_attr: + type: str + description: + de: '' + en: '' + valid_list: + - 'E5' + - 'E10' + - 'Diesel' + - 'Open' + - 'street' + - 'houseNumber' + - 'postCode' + - 'place' + - 'address' + - 'brand' + - 'name' + - 'lng' + - 'lat' + valid_list_description: + - 'Preis für Benzin E5' + - 'Preis für Benzin E10' + - 'Preis für Diesel' + - 'Tankstelle geöffnet' + - 'Straße' + - 'Hausnummer' + - 'PLZ' + - 'Ort' + - 'Adresse' + - 'Marke' + - 'Name' + - 'Longitute' + - 'Latitute' + valid_list_datatype: + - 'num' + - 'num' + - 'num' + - 'bool' + - 'str' + - 'str' + - 'num' + - 'str' + - 'str' + - 'str' + - 'str' + - 'num' + - 'num' + +item_structs: + petrol_station: + isopen: + type: bool + tankerkoenig_id: ..:. + tankerkoenig_attr: Open + visu_acl: ro + + e5: + type: num + tankerkoenig_id: ..:. + tankerkoenig_attr: E5 + visu_acl: ro + database: yes + + e10: + type: num + tankerkoenig_id: ..:. + tankerkoenig_attr: E10 + visu_acl: ro + database: yes + + diesel: + type: num + tankerkoenig_id: ..:. + tankerkoenig_attr: Diesel + visu_acl: ro + database: yes + + adresse: + type: str + tankerkoenig_id: ..:. + tankerkoenig_attr: address + visu_acl: ro logic_parameters: NONE # Definition of logic parameters defined by this plugin @@ -95,10 +209,23 @@ plugin_functions: de: "UUID für die anzufragende Tankstelle." en: "UUID for requested petrol station." + get_petrol_station_detail_reduced: + type: dict + description: + de: "Diese Funktion gibt Details über eine Tankstelle als DICT mit folgenden Keys zurück: 'street', 'houseNumber', 'postCode', 'place', 'brand', 'id', 'lng', 'name', 'lat'" + en: "This function returns details about a petrol station as DICT with keys: 'street', 'houseNumber', 'postCode', 'place', 'brand', 'id', 'lng', 'name', 'lat'" + + parameters: + id: + type: str + description: + de: "UUID für die anzufragende Tankstelle." + en: "UUID for requested petrol station." + get_petrol_station_prices: type: dict description: - de: "Gibt ein DICT mit Preisen für ein Array an Tankstellen-IDs zurück. Benutzung wird vom Tankerkoenig Team aus Performanzgründen empfeohlen!!!" + de: "Gibt ein DICT mit Preisen für ein Array an Tankstellen-IDs zurück. Benutzung wird vom Tankerkoenig Team aus Performanzgründen empfohlen!!!" en: "Returns a list of prices for an array of petrol station ids. Recommended to be used by tankerkoenig team due to performance issues!!!" parameters: diff --git a/tankerkoenig/user_doc.rst b/tankerkoenig/user_doc.rst new file mode 100755 index 000000000..060280a38 --- /dev/null +++ b/tankerkoenig/user_doc.rst @@ -0,0 +1,38 @@ +.. index:: Plugins; tankerkoenig +.. index:: tankerkoenig + +============ +tankerkoenig +============ + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + + +Anforderungen +============= + +Es wird ein persönlicher API-Key von Tankerkönig benötigt. Dafür muss man sich unter +https://creativecommons.tankerkoenig.de +registrieren. + + +Konfiguration +============= + +Diese Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/tankerkoenig` beschrieben. + + + +Web Interface +============= + +Das WebIF bietet 3 Reiter. Auf Reiter 1 werden die verbundenen Items und deren Werte gezeigt. Auf Reiter 2 werden +die mit Items verbundenen Tankstellen (Station-IDs) mit Preisen und Öffnungszeiten dargestellt. Reiter 3 enthält +Maintenance/Debug Informationen und ist nur bei entsprechenden Log-Level aktiv. + diff --git a/tankerkoenig/webif/__init__.py b/tankerkoenig/webif/__init__.py new file mode 100755 index 000000000..efdabfbea --- /dev/null +++ b/tankerkoenig/webif/__init__.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2020- +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.5 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import datetime +import time +import os +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + tmpl = self.tplenv.get_template('index.html') + # Setting pagelength (max. number of table entries per page) for web interface + try: + pagelength = self.plugin.webif_pagelength + except Exception: + pagelength = 100 + # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin, + webif_pagelength=pagelength, + items=self.plugin.item_list, + item_count=len(self.plugin.item_list), + plugin_shortname=self.plugin.get_shortname(), + plugin_version=self.plugin.get_version(), + plugin_info=self.plugin.get_info(), + maintenance=True if self.plugin.log_level == 10 else False, + ) + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + + if dataSet is None: + # get the new data + data = dict() + data['items'] = {} + + for item in self.plugin.item_list: + data['items'][item.id()] = {} + data['items'][item.id()]['value'] = item.property.value + data['items'][item.id()]['last_update'] = item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') + data['items'][item.id()]['last_change'] = item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') + + data['maintenance'] = True if self.plugin.log_level == 10 else False + data['prices'] = self.plugin.station_prices + + try: + return json.dumps(data, default=str) + except Exception as e: + self.logger.error(f"get_data_html exception: {e}") + + @cherrypy.expose + def submit(self, button=None, lat=None, lon=None, rad=4, clear=False): + """ + Submit handler for Ajax + """ + + self.logger.warning(f"submit called with button={button}, lat={lat}, lon={lon}, rad={rad}") + if button is not None: + result = self.plugin.get_petrol_stations(lat=lat, lon=lon, rad=rad) + self.logger.warning(f"result={result}") + + elif clear: + for addr in self._last_read: + self._last_read[addr]['val'] = '' + self._last_read['last'] = {'addr': None, 'val': '', 'cmd': ''} + + cherrypy.response.headers['Content-Type'] = 'application/json' + return json.dumps(self._last_read).encode('utf-8') + + @cherrypy.expose + def recalc_all(self): + self.logger.debug(f"recalc_all called") + self.plugin.update_status_data() \ No newline at end of file diff --git a/tankerkoenig/webif/static/img/plugin_logo.png b/tankerkoenig/webif/static/img/plugin_logo.png new file mode 100755 index 000000000..1250c6d80 Binary files /dev/null and b/tankerkoenig/webif/static/img/plugin_logo.png differ diff --git a/tankerkoenig/webif/templates/index.html b/tankerkoenig/webif/templates/index.html new file mode 100755 index 000000000..0e772f369 --- /dev/null +++ b/tankerkoenig/webif/templates/index.html @@ -0,0 +1,303 @@ +{% extends "base_plugin.html" %} + +{% set logo_frame = false %} + + +{% set update_interval = ((50 * item_count / 1000) | round | int) * 1000 %} + + +{% block pluginstyles %} + +{% endblock pluginstyles %} + + +{% block pluginscripts %} + + + + +{% endblock pluginscripts %} + + +{% block headtable %} + + + + + + + + + + + + + + +
Price Update Cycle{{ p.price_update_cycle }}sDetails Update Cycle{{ p.details_update_cycle }}API Key{{ p._apikey }}
+{% endblock headtable %} + + + +{% block buttons %} +
+ +
+{% endblock %} + + + +{% set tabcount = 3 %} + +{% set tab1title = "" ~ p.get_shortname() ~ " Items (" ~ item_count ~ ")" %} +{% set tab2title = "" ~ p.get_shortname() ~ " Tankstellen (" ~ len(p.station_ids) ~ ")" %} +{% if maintenance %} + {% set tab3title = "" ~ p.get_shortname() ~ " Maintenance" %} +{% else %} + {% set tab3title = "hidden" %} +{% endif %} + + +{% if item_count > 0 %} + {% set start_tab = 3 %} +{% endif %} + + +{% block bodytab1 %} +
+ + + + + + + + + + + + + {% for item in items %} + + + + + + + + + {% endfor %} + +
{{ _('Item') }}{{ _('Station_ID') }}{{ _('Attribut') }}{{ _('Wert') }}{{_('Letztes Update')}}{{_('Letzter Change')}}
{{ item._path }}{{ item.conf['tankerkoenig_id'] }}{{ item.conf['tankerkoenig_attr'] }}{{ item() | replace('\n', '
') }}
{{ item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') }}{{ item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') }}
+
+{% endblock bodytab1 %} + + +{% block bodytab2 %} +
+ + + + + + + + + + + + + {% for id in p.station_details %} + + + + + {% if p.station_prices.get(id) %} + {% set prices = 'E5: ' ~ p.station_prices[id].get('e5') + '\n' + 'E10: ' ~ p.station_prices[id].get('e10') + '\n' + 'Diesel: ' ~ p.station_prices[id].get('diesel') + '\n' + 'Status: ' ~ p.station_prices[id].get('status') %} + + {% else %} + + {% endif %} + + {% if p.station_details[id]['wholeDay'] %} + + {% elif not p.station_details[id]['wholeDay'] and p.station_details[id]['openingTimes']%} + + {% else %} + + {% endif %} + + + + {% endfor %} + +
{{ _('Station Name') }}{{ _('Marke') }}{{ _('Adresse') }}{{ _('Preise') }}{{ _('Öffnungszeiten') }}{{ _('Station ID') }}
{{ p.station_details[id]['name'] }}{{ p.station_details[id]['brand'] }}{{ p.station_details[id]['address'] | replace('\n', '
') }}
{{ prices | replace('\n', '
') }}
{{ _('-') }} {{ _('24/7') }} + {% for entry in p.station_details[id]['openingTimes'] %} + {{ entry['text'] }}
+ {{ entry['start'] }} {{ _('-') }} {{ entry['end'] }}
+ {% endfor %} +
{{ _('??') }}{{ id }}
+
+{% endblock bodytab2 %} + + +{% block bodytab3 %} +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{{ _('Item') }}{{ _('Wert') }}
{{ _('item_dict') }}{{ p.item_dict }}
{{ _('station_ids') }}{{ p.station_ids }}
{{ _('station_details') }}{{ p.station_details }}
{{ _('station_prices') }}{{ p.station_prices }}
+
+{% endblock bodytab3 %} diff --git a/tasmota/__init__.py b/tasmota/__init__.py index f43c71de2..8266281f0 100755 --- a/tasmota/__init__.py +++ b/tasmota/__init__.py @@ -2,12 +2,10 @@ # vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab ######################################################################### # Copyright 2020- Martin Sinn m.sinn@gmx.de +# Copyright 2021- Michael Wenzel wenzel_michael@web.de ######################################################################### # This file is part of SmartHomeNG. # -# Sample plugin for new plugins to run with SmartHomeNG version 1.4 and -# upwards. -# # SmartHomeNG is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or @@ -24,28 +22,69 @@ ######################################################################### from datetime import datetime, timedelta -import time -from lib.module import Modules from lib.model.mqttplugin import * -from lib.item import Items - from .webif import WebInterface class Tasmota(MqttPlugin): """ - Main class of the Plugin. Does all plugin specific stuff and provides - the update functions for the items + Main class of the Plugin. Does all plugin specific stuff and provides the update functions for the items """ - PLUGIN_VERSION = '1.2.2' + PLUGIN_VERSION = '1.4.0' + + LIGHT_MSG = ['HSBColor', 'Dimmer', 'Color', 'CT', 'Scheme', 'Fade', 'Speed', 'LedTable', 'White'] + + RF_MSG = ['RfSync', 'RfLow', 'RfHigh', 'RfCode'] + + ZIGBEE_BRIDGE_DEFAULT_OPTIONS = {'SetOption89': 'OFF', + 'SetOption101': 'OFF', + 'SetOption120': 'OFF', + 'SetOption83': 'ON', + 'SetOption112': 'OFF', + 'SetOption110': 'OFF', + 'SetOption119': 'OFF', + 'SetOption118': 'OFF', + 'SetOption125': 'ON', + } + TASMOTA_ATTR_R_W = ['relay', 'hsb', 'white', 'ct', 'rf_send', 'rf_key_send', 'zb_permit_join', 'zb_forget', 'zb_ping', 'rf_key'] + + TASMOTA_ZB_ATTR_R_W = ['power', 'hue', 'sat', 'ct', 'dimmer', 'ct_k'] + + ENERGY_SENSOR_KEYS = {'Voltage': 'item_voltage', + 'Current': 'item_current', + 'Power': 'item_power', + 'ApparentPower': 'item_apparent_power', + 'ReactivePower': 'item_reactive_power', + 'Factor': 'item_power_factor', + 'TotalStartTime': 'item_total_starttime', + 'Total': 'item_power_total', + 'Yesterday': 'item_power_yesterday', + 'Today': 'item_power_today'} + + ENV_SENSOR = ['DS18B20', 'AM2301', 'SHT3X', 'BMP280', 'DHT11'] + + ENV_SENSOR_KEYS = {'Temperature': 'item_temp', + 'Humidity': 'item_hum', + 'DewPoint': 'item_dewpoint', + 'Pressure': 'item_pressure', + 'Id': 'item_1wid'} + + ANALOG_SENSOR_KEYS = {'Temperature': 'item_analog_temp', + 'Temperature1': 'item_analog_temp1', + 'A0': 'item_analog_a0', + 'Range': 'item_analog_range'} + + ESP32_SENSOR_KEYS = {'Temperature': 'item_esp32_temp'} + + SENSORS = [*ENV_SENSOR, + 'ENERGY', + ] def __init__(self, sh): """ - Initalizes the plugin. - - :param sh: **Deprecated**: The instance of the smarthome object. For SmartHomeNG versions 1.4 and up: **Don't use it**! + Initializes the plugin. """ # Call init code of parent class (MqttPlugin) @@ -54,54 +93,31 @@ def __init__(self, sh): return # get the parameters for the plugin (as defined in metadata plugin.yaml): - self.webif_pagelength = self.get_parameter_value('webif_pagelength') self.telemetry_period = self.get_parameter_value('telemetry_period') - self._cycle = self.telemetry_period + self.full_topic = self.get_parameter_value('full_topic').lower() # crate full_topic - self.full_topic = self.get_parameter_value('full_topic').lower() if self.full_topic.find('%prefix%') == -1 or self.full_topic.find('%topic%') == -1: self.full_topic = '%prefix%/%topic%/' if self.full_topic[-1] != '/': self.full_topic += '/' # Define properties - self.tasmota_devices = {} # to hold tasmota device information for web interface - self.tasmota_zigbee_devices = {} # to hold tasmota zigbee device information for web interface - self.tasmota_items = [] # to hold item information for web interface - self.tasmota_meta = {} # to hold meta information for web interface - self.tasmota_zigbee_bridge = {} # to hold tasmota zigbee bridge status + self.tasmota_devices = {} # to hold tasmota device information for web interface + self.tasmota_zigbee_devices = {} # to hold tasmota zigbee device information for web interface + self.tasmota_items = [] # to hold item information for web interface + self.topics_of_retained_messages = [] # to hold all topics of retained messages + self.alive = None - self.discovered_devices = [] - self.tasmota_zigbee_bridge_stetting = {'SetOption89': 'OFF', # SetOption89 Configure MQTT topic for Zigbee devices (also see SensorRetain); 0 = single tele/%topic%/SENSOR topic (default), 1 = unique device topic based on Zigbee device ShortAddr, Example: tele/Zigbee/5ADF/SENSOR = {"ZbReceived":{"0x5ADF":{"Dimmer":254,"Endpoint":1,"LinkQuality":70}}} - 'SetOption83': 'ON', # SetOption83 Uses Zigbee device friendly name instead of 16 bits short addresses as JSON key when reporting values and commands; 0 = JSON key as short address, 1 = JSON key as friendly name - 'SetOption100': 'ON', # SetOption100 Remove Zigbee ZbReceived value from {"ZbReceived":{xxx:yyy}} JSON message; 0 = disable (default), 1 = enable - 'SetOption125': 'ON', # SetOption125 ZbBridge only Hide bridge topic from zigbee topic (use with SetOption89) 1 = enable - 'SetOption118': 'ON', # SetOption118 Move ZbReceived from JSON message into the subtopic replacing "SENSOR" default; 0 = disable (default); 1 = enable - 'SetOption112': 'ON', # SetOption112 0 = (default); 1 = use friendly name in Zigbee topic (use with ZbDeviceTopic) - 'SetOption119': 'OFF' # SetOption119 Remove device addr from JSON payload; 0 = disable (default); 1 = enable - } # Add subscription to get device discovery - self.add_tasmota_subscription('tasmota', 'discovery', '#', 'dict', callback=self.on_discovery) - - # Add subscription to get device announces - self.add_tasmota_subscription('tele', '+', 'LWT', 'bool', bool_values=['Offline', 'Online'], - callback=self.on_mqtt_announce) - self.add_tasmota_subscription('tele', '+', 'STATE', 'dict', callback=self.on_mqtt_announce) - self.add_tasmota_subscription('tele', '+', 'SENSOR', 'dict', callback=self.on_mqtt_announce) - self.add_tasmota_subscription('tele', '+', 'INFO1', 'dict', callback=self.on_mqtt_announce) - self.add_tasmota_subscription('tele', '+', 'INFO2', 'dict', callback=self.on_mqtt_announce) - self.add_tasmota_subscription('tele', '+', 'INFO3', 'dict', callback=self.on_mqtt_announce) - self.add_tasmota_subscription('tele', '+', 'RESULT', 'dict', callback=self.on_mqtt_announce) - self.add_tasmota_subscription('tele', '+', 'ZbReceived', 'dict', callback=self.on_mqtt_announce) - self.add_tasmota_subscription('stat', '+', 'STATUS0', 'dict', callback=self.on_mqtt_announce) - self.add_tasmota_subscription('stat', '+', 'RESULT', 'dict', callback=self.on_mqtt_announce) - self.add_tasmota_subscription('stat', '+', 'POWER', 'num', callback=self.on_mqtt_message) - self.add_tasmota_subscription('stat', '+', 'POWER1', 'num', callback=self.on_mqtt_message) - self.add_tasmota_subscription('stat', '+', 'POWER2', 'num', callback=self.on_mqtt_message) - self.add_tasmota_subscription('stat', '+', 'POWER3', 'num', callback=self.on_mqtt_message) - self.add_tasmota_subscription('stat', '+', 'POWER4', 'num', callback=self.on_mqtt_message) + self.add_subscription( 'tasmota/discovery/#', 'dict', callback=self.on_mqtt_discovery_message) + # Add subscription to get device LWT + self.add_tasmota_subscription('tele', '+', 'LWT', 'bool', bool_values=['Offline', 'Online'], callback=self.on_mqtt_lwt_message) + # Add subscription to get device status + self.add_tasmota_subscription('stat', '+', 'STATUS0', 'dict', callback=self.on_mqtt_status0_message) + # Add subscription to get device actions result + self.add_tasmota_subscription('stat', '+', 'RESULT', 'dict', callback=self.on_mqtt_message) # Init WebIF self.init_webinterface(WebInterface) @@ -116,24 +132,13 @@ def run(self): # start subscription to all defined topics self.start_subscriptions() - # wait 1 seconds to receive and handle retained messages for device discovery - time.sleep(1) - - # Interview known Tasmota Devices (definied in item.yaml and self discovered) - key_list = list(set(list(self.tasmota_devices.keys()) + self.discovered_devices)) - for topic in key_list: - # ask for status info of each known tasmota_topic, collected during parse_item - self._identify_device(topic) - - # set telemetry period for each known tasmota_topic, collected during parse_item - self.logger.info(f"run: Setting telemetry period to {self.telemetry_period} seconds") - self.logger.debug(f"run: publishing 'cmnd/{topic}/teleperiod'") - self.publish_tasmota_topic('cmnd', topic, 'teleperiod', self.telemetry_period) + self.logger.debug(f"Scheduler: 'check_online_status' created") + dt = self.shtime.now() + timedelta(seconds=(self.telemetry_period - 3)) + self.scheduler_add('check_online_status', self.check_online_status, cycle=self.telemetry_period, next=dt) - # Update tasmota_meta auf Basis von tasmota_devices - self._update_tasmota_meta() + self.logger.debug(f"Scheduler: 'add_tasmota_subscriptions' created") + self.scheduler_add('add_tasmota_subscriptions', self.add_tasmota_subscriptions, cron='init+20') - self.scheduler_add('poll_device', self.poll_device, cycle=self._cycle) self.alive = True def stop(self): @@ -142,7 +147,7 @@ def stop(self): """ self.alive = False self.logger.debug("Stop method called") - self.scheduler_remove('poll_device') + self.scheduler_remove('check_online_status') # stop subscription to all topics self.stop_subscriptions() @@ -151,9 +156,8 @@ def parse_item(self, item): """ Default plugin parse_item method. Is called when the plugin is initialized. The plugin can, corresponding to its attribute keywords, decide what to do with - the item in future, like adding it to an internal array for future reference + the item in the future, like adding it to an internal array for future reference :param item: The item to process. - :type item: Item :return: If the plugin needs to be informed of an items change you should return a call back function like the function update_item down below. An example when this is needed is the knx plugin where parse_item returns the update_item function when the attribute knx_send is found. @@ -161,59 +165,75 @@ def parse_item(self, item): with the item, caller, source and dest as arguments and in case of the knx plugin the value can be sent to the knx with a knx write function within the knx plugin. """ - if self.has_iattr(item.conf, 'tasmota_topic'): - self.logger.debug(f"parsing item: {item.id()}") + if self.has_iattr(item.conf, 'tasmota_topic'): tasmota_topic = self.get_iattr_value(item.conf, 'tasmota_topic') + self.logger.info(f"parsing item: {item.id()} with tasmota_topic={tasmota_topic}") + tasmota_attr = self.get_iattr_value(item.conf, 'tasmota_attr') tasmota_relay = self.get_iattr_value(item.conf, 'tasmota_relay') + tasmota_rf_details = self.get_iattr_value(item.conf, 'tasmota_rf_key') tasmota_zb_device = self.get_iattr_value(item.conf, 'tasmota_zb_device') - if tasmota_zb_device is not None: - # check if zigbee device short name has been used without parentheses; if so this will be normally parsed to a number and therefore mismatch with defintion + tasmota_zb_group = self.get_iattr_value(item.conf, 'tasmota_zb_group') + tasmota_zb_attr = self.get_iattr_value(item.conf, 'tasmota_zb_attr') + tasmota_zb_attr = tasmota_zb_attr.lower() if tasmota_zb_attr else None + tasmota_sml_device = self.get_iattr_value(item.conf, 'tasmota_sml_device') + tasmota_sml_attr = self.get_iattr_value(item.conf, 'tasmota_sml_attr') + tasmota_sml_attr = tasmota_sml_attr.lower() if tasmota_sml_attr else None + + # handle tasmota devices without zigbee + if tasmota_attr: + self.logger.info(f"Item={item.id()} identified for Tasmota with tasmota_attr={tasmota_attr}") + tasmota_attr = tasmota_attr.lower() + tasmota_relay = 1 if not tasmota_relay else tasmota_relay + + if tasmota_rf_details and '=' in tasmota_rf_details: + tasmota_rf_details, tasmota_rf_key_param = tasmota_rf_details.split('=') + + # handle tasmota zigbee devices + elif tasmota_zb_device and tasmota_zb_attr: + self.logger.info(f"Item={item.id()} identified for Tasmota Zigbee with tasmota_zb_device={tasmota_zb_device} and tasmota_zb_attr={tasmota_zb_attr}") + + # check if zigbee device short name has been used without parentheses; if so this will be normally parsed to a number and therefore mismatch with definition try: tasmota_zb_device = int(tasmota_zb_device) - self.logger.warning( - f"Probably for item {item.id()} the device short name as been used for attribute 'tasmota_zb_device'. Trying to make that work but it will cause exceptions. To prevent this, the short name need to be defined as string by using parentheses") + self.logger.warning(f"Probably for item {item.id()} the device short name as been used for attribute 'tasmota_zb_device'. Trying to make that work but it will cause exceptions. To prevent this, the short name need to be defined as string by using parentheses") tasmota_zb_device = str(hex(tasmota_zb_device)) tasmota_zb_device = tasmota_zb_device[0:2] + tasmota_zb_device[2:len(tasmota_zb_device)].upper() except Exception: pass - tasmota_zb_attr = str(self.get_iattr_value(item.conf, 'tasmota_zb_attr')).lower() - if not tasmota_relay: - tasmota_relay = '1' - # self.logger.debug(f" - tasmota_topic={tasmota_topic}, tasmota_attr={tasmota_attr}, tasmota_relay={tasmota_relay}") - # self.logger.debug(f" - tasmota_topic={tasmota_topic}, item.conf={item.conf}") + # handle tasmota zigbee groups + elif tasmota_zb_group and tasmota_zb_attr: + self.logger.info(f"Item={item.id()} identified for Tasmota Zigbee with tasmota_zb_group={tasmota_zb_group} and tasmota_zb_attr={tasmota_zb_attr}") + + # handle tasmota smartmeter devices + elif tasmota_sml_device and tasmota_sml_attr: + self.logger.info(f"Item={item.id()} identified for Tasmota SML with tasmota_sml_device={tasmota_sml_device} and tasmota_sml_attr={tasmota_sml_attr}") + + # handle everything else + else: + self.logger.info(f"Definition of attributes for item={item.id()} incomplete. Item will be ignored.") + return + # setup dict for new device if not self.tasmota_devices.get(tasmota_topic): - self.tasmota_devices[tasmota_topic] = {} - self.tasmota_devices[tasmota_topic]['connected_to_item'] = False - self.tasmota_devices[tasmota_topic]['connected_items'] = {} - self.tasmota_devices[tasmota_topic]['uptime'] = '-' - self.tasmota_devices[tasmota_topic]['lights'] = {} - self.tasmota_devices[tasmota_topic]['rf'] = {} - self.tasmota_devices[tasmota_topic]['sensors'] = {} - self.tasmota_devices[tasmota_topic]['relais'] = {} - self.tasmota_devices[tasmota_topic]['zigbee'] = {} - - # handle the different topics from Tasmota devices - if tasmota_attr: - tasmota_attr = tasmota_attr.lower() + self._add_new_device_to_tasmota_devices(tasmota_topic) + self.tasmota_devices[tasmota_topic]['status'] = 'item.conf' + # fill tasmota_device dict self.tasmota_devices[tasmota_topic]['connected_to_item'] = True - if tasmota_attr == 'relay': - self.tasmota_devices[tasmota_topic]['connected_items'][ - 'item_' + tasmota_attr + str(tasmota_relay)] = item + if tasmota_attr == 'relay' and tasmota_relay: + item_type = f'item_{tasmota_attr}{tasmota_relay}' + elif tasmota_attr == 'rf_key' and tasmota_rf_details: + item_type = f'item_{tasmota_attr}{tasmota_rf_details}' elif tasmota_zb_device and tasmota_zb_attr: - self.tasmota_devices[tasmota_topic]['connected_items'][ - 'item_' + str(tasmota_zb_device) + '.' + str(tasmota_zb_attr.lower())] = item + item_type = f'item_{tasmota_zb_device}.{tasmota_zb_attr}' + elif tasmota_sml_device and tasmota_sml_attr: + item_type = f'item_{tasmota_sml_device}.{tasmota_sml_attr}' else: - self.tasmota_devices[tasmota_topic]['connected_items']['item_' + tasmota_attr] = item - - if tasmota_attr == 'online': - self.tasmota_devices[tasmota_topic]['online'] = False - elif (tasmota_attr and tasmota_attr.startswith('zb')) or tasmota_zb_device: - self.tasmota_devices[tasmota_topic]['zigbee']['active'] = True + item_type = f'item_{tasmota_attr}' + self.tasmota_devices[tasmota_topic]['connected_items'][item_type] = item # append to list used for web interface if item not in self.tasmota_items: @@ -221,7 +241,12 @@ def parse_item(self, item): return self.update_item - def update_item(self, item, caller=None, source=None, dest=None): + elif self.has_iattr(item.conf, 'tasmota_admin'): + self.logger.debug(f"parsing item: {item.id()} for tasmota admin attribute") + + return self.update_item + + def update_item(self, item, caller: str = None, source: str = None, dest: str = None): """ Item has been updated @@ -229,373 +254,510 @@ def update_item(self, item, caller=None, source=None, dest=None): It should write the changed value out to the device (hardware/interface) that is managed by this plugin. - :param item: item to be updated towards the plugin - :type item: item - :param caller: if given it represents the callers name - :type caller: str - :param source: if given it represents the source - :type source: str - :param dest: if given it represents the dest - :param dest: str + :param item: item to be updated towards the plugin + :param caller: if given it represents the callers name + :param source: if given it represents the source + :param dest: if given it represents the dest """ - self.logger.debug(f"update_item: {item.id()}") if self.alive and caller != self.get_shortname(): - # code to execute if the plugin is not stopped AND only, if the item has not been changed by this this plugin: + # code to execute if the plugin is not stopped AND only, if the item has not been changed by this plugin: # get tasmota attributes of item + tasmota_admin = self.get_iattr_value(item.conf, 'tasmota_admin') tasmota_topic = self.get_iattr_value(item.conf, 'tasmota_topic') tasmota_attr = self.get_iattr_value(item.conf, 'tasmota_attr') tasmota_relay = self.get_iattr_value(item.conf, 'tasmota_relay') + tasmota_relay = '1' if not tasmota_relay else None + tasmota_rf_details = self.get_iattr_value(item.conf, 'tasmota_rf_details') tasmota_zb_device = self.get_iattr_value(item.conf, 'tasmota_zb_device') + tasmota_zb_group = self.get_iattr_value(item.conf, 'tasmota_zb_group') tasmota_zb_attr = self.get_iattr_value(item.conf, 'tasmota_zb_attr') - if tasmota_zb_attr: - tasmota_zb_attr = tasmota_zb_attr.lower() - - topic = tasmota_topic - detail = None - - if tasmota_attr in ['relay', 'hsb', 'white', 'ct', 'rf_send', 'rf_key_send', 'zb_permit_join']: - self.logger.info( - f"update_item: {item.id()}, item has been changed in SmartHomeNG outside of this plugin in {caller} with value {item()}") - value = None - bool_values = None + tasmota_zb_cluster = self.get_iattr_value(item.conf, 'tasmota_zb_cluster') + tasmota_zb_attr = tasmota_zb_attr.lower() if tasmota_zb_attr else None + + # handle tasmota_admin + if tasmota_admin: + if tasmota_admin == 'delete_retained_messages' and bool(item()): + self.clear_retained_messages() + item(False, self.get_shortname()) + + # handle tasmota_attr + elif tasmota_attr and tasmota_attr in self.TASMOTA_ATTR_R_W: + self.logger.info(f"update_item: {item.id()}, item has been changed in SmartHomeNG outside of this plugin in {caller} with value {item()}") + + value = item() + link = { + # 'attribute': (detail, data_type, bool_values, min_value, max_value) + 'relay': (f'Power', bool, ['OFF', 'ON'], None, None), + 'hsb': ('HsbColor', list, None, None, None), + 'white': ('White', int, None, 0, 120), + 'ct': ('CT', int, None, 153, 500), + 'rf_send': ('Backlog', dict, None, None, None), + 'rf_key_send': (f'RfKey', int, None, 1, 16), + 'rf_key': (f'RfKey', bool, None, None, None), + 'zb_permit_join': ('ZbPermitJoin', bool, ['0', '1'], None, None), + 'zb_forget': ('ZbForget', bool, ['0', '1'], None, None), + 'zb_ping': ('ZbPing', bool, ['0', '1'], None, None), + } + + if tasmota_attr not in link: + return + + (detail, data_type, bool_values, min_value, max_value) = link[tasmota_attr] + + # check data type + if not isinstance(value, data_type): + self.logger.warning(f"update_item: type of value {type(value)} for tasmota_attr={tasmota_attr} to be published, does not fit with expected type '{data_type}'. Abort publishing.") + return + + # check and correct if value is in allowed range + if min_value and value < min_value: + self.logger.info(f'Commanded value for {tasmota_attr} below min value; set to allowed min value.') + value = min_value + elif max_value and value > max_value: + self.logger.info(f'Commanded value for {tasmota_attr} above max value; set to allowed max value.') + value = max_value + + # do tasmota_attr specific checks and adaptations if tasmota_attr == 'relay': - # publish topic with new relay state - if not tasmota_relay: - tasmota_relay = '1' - detail = 'POWER' - if tasmota_relay > '1': - detail += str(tasmota_relay) - bool_values = ['OFF', 'ON'] - value = item() + detail = f"{detail}{tasmota_relay}" if tasmota_relay > '1' else detail elif tasmota_attr == 'hsb': - # publish topic with new hsb value - # Format aus dem Item ist eine Liste mit drei int Werten bspw. [299, 100, 94] - # Format zum Senden ist ein String mit kommagetrennten Werten '299,100,94' - detail = 'HsbColor' - hsb = item() - if type(hsb) is list and len(hsb) == 3: - hsb = list(map(int, hsb)) - value = ','.join(str(v) for v in hsb) - else: - self.logger.debug( - f"update_item: hsb value received but not in correct format/content; expected format is list like [299, 100, 94]") - - elif tasmota_attr == 'white': - # publish topic with new white value - detail = 'White' - white = item() - if type(white) is int and 0 <= white <= 100: - value = white - else: - self.logger.debug( - f"update_item: white value received but not in correct format/content; expected format is integer value between 0 and 100") - - elif tasmota_attr == 'ct': - # publish topic with new ct value - detail = 'CT' - ct = item() - if type(ct) is int and 153 <= ct <= 500: - value = ct - else: - self.logger.debug( - f"update_item: ct value received but not in correct format/content; expected format is integer value between 153 for cold white and 500 for warm white") + if not len(value) == 3: + return + new_value = f"{value[0]},{value[1]},{value[2]}" + value = new_value elif tasmota_attr == 'rf_send': - # publish topic with new rf data - # Format aus dem Item ist ein dict in folgendem Format: {'RfSync': 12220, 'RfLow': 440, 'RfHigh': 1210, 'RfCode':'#F06104'} - # Format zum Senden ist: "RfSync 12220; RfLow 440; RfHigh 1210; RfCode #F06104" - detail = 'Backlog' - rf_send = item() - if type(rf_send) is dict: - rf_send_lower = eval(repr(rf_send).lower()) - # rf_send_lower = {k.lower(): v for k, v in rf_send.items()} - if 'rfsync' and 'rflow' and 'rfhigh' and 'rfcode' in rf_send_lower: - value = 'RfSync' + ' ' + str(rf_send_lower['rfsync']) + '; ' + 'RfLow' + ' ' + str( - rf_send_lower['rflow']) + '; ' + 'RfHigh' + ' ' + str( - rf_send_lower['rfhigh']) + '; ' + 'RfCode' + ' ' + str(rf_send_lower['rfcode']) - else: - self.logger.debug( - f"update_item: rf_send received but not with correct content; expected content is: {'RfSync': 12220, 'RfLow': 440, 'RfHigh': 1210, 'RfCode':'#F06104'}") + # Input: {'RfSync': 12220, 'RfLow': 440, 'RfHigh': 1210, 'RfCode':'#F06104'} / Output: "RfSync 12220; RfLow 440; RfHigh 1210; RfCode #F06104" + rf_cmd = {k.lower(): v for k, v in value.items()} + if all(k in rf_cmd for k in [x.lower() for x in self.RF_MSG]): + value = f"RfSync {value['rfsync']}; RfLow {value['rflow']}; RfHigh {value['rfhigh']}; RfCode #{value['rfcode']}" else: - self.logger.debug( - f"update_item: rf_send received but not in correct format; expected format is: {'RfSync': 12220, 'RfLow': 440, 'RfHigh': 1210, 'RfCode':'#F06104'}") + self.logger.debug(f"update_item: rf_send received but not with correct content; expected content is: {'RfSync': 12220, 'RfLow': 440, 'RfHigh': 1210, 'RfCode':'#F06104'}") + return elif tasmota_attr == 'rf_key_send': - # publish topic for rf_keyX Default send - try: - rf_key = int(item()) - except Exception: - self.logger.debug( - f"update_item: rf_key_send received but with correct format; expected format integer or string 1-16") - else: - if rf_key in range(1, 17): - detail = 'RfKey' + str(rf_key) - value = 1 - else: - self.logger.debug( - f"update_item: rf_key_send received but with correct content; expected format value 1-16") - - elif tasmota_attr == 'ZbPermitJoin': - # publish topic for ZbPermitJoin - detail = 'ZbPermitJoin' - bool_values = ['0', '1'] - value = item() - - elif tasmota_attr == 'ZbForget': - # publish topic for ZbForget - detail = 'ZbForget' - value = item() - if item() in self.tasmota_zigbee_devices: - value = item() - else: - self.logger.error(f"Device {item()} not known by plugin, no action taken.") + detail = f"{detail}{value}" + value = 1 - elif tasmota_attr == 'ZbPing': - # publish topic for ZbPing - detail = 'ZbPing' - if item() in self.tasmota_zigbee_devices: - value = item() - else: - self.logger.error(f"Device {item()} not known by plugin, no action taken.") + elif tasmota_attr == 'rf_key': + if not tasmota_rf_details: + self.logger.warning(f"tasmota_rf_details not specified, no action taken.") + return + + if tasmota_rf_details and '=' in tasmota_rf_details: + tasmota_rf_details, tasmota_rf_key_param = tasmota_rf_details.split('=') + + detail = f"{detail}{tasmota_rf_details}" + value = 1 + + elif tasmota_attr == 'zb_forget': + if value not in self.tasmota_zigbee_devices: + self.logger.error(f"Device {value} not known by plugin, no action taken.") + return + + elif tasmota_attr == 'zb_ping': + if value not in self.tasmota_zigbee_devices: + self.logger.error(f"Device {value} not known by plugin, no action taken.") + return if value is not None: - self.publish_tasmota_topic('cmnd', topic, detail, value, item, bool_values=bool_values) - - elif tasmota_zb_attr in ['power', 'hue', 'sat', 'ct', 'dimmer']: - self.logger.info( - f"update_item: {item.id()}, item has been changed in SmartHomeNG outside of this plugin in {caller} with value {item()}") - payload = {} - bool_values = None - # Topic: cmnd//ZbSend // Payload: {"Device":"0x0A22","Send":{"Power":0}} - if tasmota_zb_device and tasmota_zb_attr == 'power': - topic = tasmota_topic - detail = 'ZbSend' - bool_values = ['OFF', 'ON'] - payload = {'Device': tasmota_zb_device, 'Send': {'Power': int(item())}} - - elif tasmota_zb_device and tasmota_zb_attr == 'dimmer': - topic = tasmota_topic - detail = 'ZbSend' - value = int(item()) - if value < 0 or value > 254: - self.logger.warning( - f' commanded value for brightness not within allowed range; set to next valid value') - value = 0 if (value < 0) else 254 - payload = {'Device': tasmota_zb_device, 'Send': {'Dimmer': value}} - - elif tasmota_zb_device and tasmota_zb_attr == 'hue': - topic = tasmota_topic - detail = 'ZbSend' - value = int(item()) - if value < 0 or value > 254: - self.logger.warning( - f' commanded value for hue not within allowed range; set to next valid value') - value = 0 if (value < 0) else 254 - payload = {'Device': tasmota_zb_device, 'Send': {'Hue': value}} - - elif tasmota_zb_device and tasmota_zb_attr == 'sat': - topic = tasmota_topic - detail = 'ZbSend' - value = int(item()) - if value < 0 or value > 254: - self.logger.warning( - f' commanded value for saturation not within allowed range; set to next valid value') - value = 0 if (value < 0) else 254 - payload = {'Device': tasmota_zb_device, 'Send': {'Sat': value}} - - elif tasmota_zb_device and tasmota_zb_attr == 'ct': - topic = tasmota_topic - detail = 'ZbSend' - value = int(item()) - if value < 0 or value > 65534: - self.logger.warning( - f' commanded value for saturation not within allowed range; set to next valid value') - value = 0 if (value < 0) else 65534 - payload = {'Device': tasmota_zb_device, 'Send': {'CT': value}} - - if payload and detail: - self.publish_tasmota_topic('cmnd', topic, detail, payload, item, bool_values=bool_values) + self.publish_tasmota_topic('cmnd', tasmota_topic, detail, value, item, bool_values=bool_values) + + # handle tasmota_zb_attr + elif tasmota_zb_attr and tasmota_zb_attr in self.TASMOTA_ZB_ATTR_R_W: + self.logger.info(f"update_item: item={item.id()} with tasmota_zb_attr={tasmota_zb_attr} has been changed from {caller} with value={item()}") + self.logger.info(f"update_item: tasmota_zb_device={tasmota_zb_device}; tasmota_zb_group={tasmota_zb_group}") + + if tasmota_zb_device is None and tasmota_zb_group is None: + return + + value = int(item()) + detail = 'ZbSend' + link = { + # 'attribute': (send_cmd, bool_values, min_value, max_value, cluster, convert) + 'power': ('Power', ['OFF', 'ON'], None, None, '0x0006', None), + 'dimmer': ('Dimmer', None, 0, 100, '0x0008', _100_to_254), + 'hue': ('Hue', None, 0, 360, '0x0300', _360_to_254), + 'sat': ('Sat', None, 0, 100, '0x0300', _100_to_254), + 'ct': ('CT', None, 150, 500, '0x0300', None), + 'ct_k': ('CT', None, 2000, 6700, '0x0300', _kelvin_to_mired), + } + + if tasmota_zb_attr not in link: + return + + (send_cmd, bool_values, min_value, max_value, cluster, convert) = link[tasmota_zb_attr] + + # check and correct if value is in allowed range + if min_value and value < min_value: + self.logger.info(f'Commanded value for {tasmota_zb_attr} below min value; set to allowed min value.') + value = min_value + elif max_value and value > max_value: + self.logger.info(f'Commanded value for {tasmota_zb_attr} above max value; set to allowed max value.') + value = max_value + + # Konvertiere Wert + if convert: + value = convert(value) + + # build payload + payload = {'Device': tasmota_zb_device} if tasmota_zb_device else {'group': tasmota_zb_group} + payload['Send'] = {send_cmd: value} + if tasmota_zb_cluster: + payload['Cluster'] = cluster + + self.logger.debug(f"payload={payload}") + + # publish command + self.publish_tasmota_topic('cmnd', tasmota_topic, detail, payload, item, bool_values=bool_values) else: - self.logger.warning( - f"update_item: {item.id()}, trying to change item in SmartHomeNG that is read only in tasmota device (by {caller})") + self.logger.warning(f"update_item: {item.id()}, trying to change item in SmartHomeNG that is read only in tasmota device (by {caller})") + + ############################################################ + # Callbacks + ############################################################ - def poll_device(self): + # ToDo: 2023-01-20 17:21:04 ERROR modules.mqtt _on_log: Caught exception in on_message: 'ip' + + def on_mqtt_discovery_message(self, topic: str, payload: dict, qos: int = None, retain: bool = None) -> None: """ - Polls for updates of the tasmota device + Callback function to handle received discovery messages + + :param topic: MQTT topic + :param payload: MQTT message payload + :param qos: qos for this message (optional) + :param retain: retain flag for this message (optional) - This method is only needed, if the device (hardware/interface) does not propagate - changes on it's own, but has to be polled to get the actual status. - It is called by the scheduler which is set within run() method. """ - # check if Tasmota Zigbee Bridge needs to be configured - tasmota_zigbee_bridge_status = self.tasmota_zigbee_bridge.get('status') - if tasmota_zigbee_bridge_status == 'discovered': - self.logger.info(f'poll_device: Tasmota Zigbee Bridge discovered; Configuration will be adapted.') - zigbee_device = self.tasmota_zigbee_bridge.get('device') - if zigbee_device: - self._discover_zigbee_bridge(zigbee_device) - self.logger.info("poll_device: Checking online status of connected devices") - for tasmota_topic in self.tasmota_devices: - if self.tasmota_devices[tasmota_topic].get('online') is True and self.tasmota_devices[tasmota_topic].get( - 'online_timeout'): - if self.tasmota_devices[tasmota_topic]['online_timeout'] < datetime.now(): - self.tasmota_devices[tasmota_topic]['online'] = False - self._set_item_value(tasmota_topic, 'item_online', False, 'poll_device') - self.logger.info( - f"poll_device: {tasmota_topic} is not online any more - online_timeout={self.tasmota_devices[tasmota_topic]['online_timeout']}, now={datetime.now()}") - # delete data from WebIF dict - self.tasmota_devices[tasmota_topic]['lights'] = {} - self.tasmota_devices[tasmota_topic]['rf'] = {} - self.tasmota_devices[tasmota_topic]['sensors'] = {} - self.tasmota_devices[tasmota_topic]['relais'] = {} - self.tasmota_devices[tasmota_topic]['zigbee'] = {} - else: - self.logger.debug(f'poll_device: Checking online status of {tasmota_topic} successfull') + self._handle_retained_message(topic, retain) + + try: + (tasmota, discovery, device_id, msg_type) = topic.split('/') + self.logger.info(f"on_mqtt_discovery_message: device_id={device_id}, type={msg_type}, payload={payload}") + except Exception as e: + self.logger.error(f"received topic {topic} is not in correct format. Error was: {e}") + else: + if msg_type == 'config': + """ + device_id = 2CF432CC2FC5 + + payload = + { + 'ip': '192.168.2.33', // IP address + 'dn': 'NXSM200_01', // Device name + 'fn': ['NXSM200_01', None, None, None, None, None, None, None], // List of friendly names + 'hn': 'NXSM200-01-4037', // Hostname + 'mac': '2CF432CC2FC5', // MAC Adresse ohne : + 'md': 'NXSM200', // Module + 'ty': 0, // Tuya + 'if': 0, // ifan + 'ofln': 'Offline', // LWT-offline + 'onln': 'Online', // LWT-online + 'state': ['OFF', 'ON', 'TOGGLE', 'HOLD'], // StateText[0..3] + 'sw': '12.1.1', // Firmware Version + 't': 'NXSM200_01', // Topic + 'ft': '%prefix%/%topic%/', // Full Topic + 'tp': ['cmnd', 'stat', 'tele'], // Topic [SUB_PREFIX, PUB_PREFIX, PUB_PREFIX2] + 'rl': [1, 0, 0, 0, 0, 0, 0, 0], // Relays, 0: disabled, 1: relay, 2.. future extension (fan, shutter?) + 'swc': [-1, -1, -1, -1, -1, -1, -1, -1], // SwitchMode + 'swn': [None, None, None, None, None, None, None, None], // SwitchName + 'btn': [0, 0, 0, 0, 0, 0, 0, 0], // Buttons + 'so': {'4': 0, '11': 0, '13': 0, '17': 0, '20': 0, '30': 0, '68': 0, '73': 0, '82': 0, '114': 0, '117': 0}, // SetOption needed by HA to map Tasmota devices to HA entities and triggers + 'lk': 0, // ctrgb + 'lt_st': 0, // Light subtype + 'sho': [0, 0, 0, 0], + 'sht': [[0, 0, 48], [0, 0, 46], [0, 0, 110], [0, 0, 108]], + 'ver': 1 // Discovery protocol version + } + """ + + tasmota_topic = payload['t'] + if tasmota_topic: - # ask for status info of reconnected tasmota_topic (which was not connected during plugin start) - if not self.tasmota_devices[tasmota_topic].get('mac'): - self.logger.debug(f"poll_device: reconnected device discovered and try to discover it") - self._identify_device(tasmota_topic) + device_name = payload['dn'] + self.logger.info(f"Discovered Tasmota Device with topic={tasmota_topic} and device_name={device_name}") + + # if device is unknown, add it to dict + if tasmota_topic not in self.tasmota_devices: + self.logger.info(f"New device based on Discovery Message found.") + self._add_new_device_to_tasmota_devices(tasmota_topic) + + # process decoding message and set device to status 'discovered' + self.tasmota_devices[tasmota_topic]['ip'] = payload['ip'] + self.tasmota_devices[tasmota_topic]['friendly_name'] = payload['fn'][0] + self.tasmota_devices[tasmota_topic]['fw_ver'] = payload['sw'] + self.tasmota_devices[tasmota_topic]['device_id'] = device_id + self.tasmota_devices[tasmota_topic]['module'] = payload['md'] + self.tasmota_devices[tasmota_topic]['mac'] = ':'.join(device_id[i:i + 2] for i in range(0, 12, 2)) + self.tasmota_devices[tasmota_topic]['discovery_config'] = self._rename_discovery_keys(payload) + self.tasmota_devices[tasmota_topic]['status'] = 'discovered' + + # start device interview + self._interview_device(tasmota_topic) + + if payload['ft'] != self.full_topic: + self.logger.warning(f"Device {device_name} discovered, but FullTopic of device does not match plugin setting!") + + # if zigbee bridge, process those + if 'zigbee_bridge' in device_name.lower(): + self.logger.info(f"Zigbee_Bridge discovered") + self.tasmota_devices[tasmota_topic]['zigbee']['status'] = 'discovered' + self._configure_zigbee_bridge_settings(tasmota_topic) + self._discover_zigbee_bridge_devices(tasmota_topic) + + elif msg_type == 'sensors': + """ + device_id = 2CF432CC2FC5 + + payload = {'sn': {'Time': '2022-11-19T13:35:59', + 'ENERGY': {'TotalStartTime': '2019-12-23T17:02:03', 'Total': 85.314, 'Yesterday': 0.0, + 'Today': 0.0, 'Power': 0, 'ApparentPower': 0, 'ReactivePower': 0, 'Factor': 0.0, + 'Voltage': 0, 'Current': 0.0}}, 'ver': 1} + """ + + # get payload with Sensor information + sensor_payload = payload['sn'] + if 'Time' in sensor_payload: + sensor_payload.pop('Time') + + # find matching tasmota_topic + tasmota_topic = None + for entry in self.tasmota_devices: + if self.tasmota_devices[entry].get('device_id') == device_id: + tasmota_topic = entry + break + + # hand over sensor information payload for parsing + if sensor_payload and tasmota_topic: + self.logger.info(f"Discovered Tasmota Device with topic={tasmota_topic} and SensorInformation") + self._handle_sensor(tasmota_topic, '', sensor_payload) + + def on_mqtt_lwt_message(self, topic: str, payload: bool, qos: int = None, retain: bool = None) -> None: + """ + Callback function to handle received lwt messages - # update tasmota_meta auf Basis von tasmota_devices - self._update_tasmota_meta() + :param topic: MQTT topic + :param payload: MQTT message payload + :param qos: qos for this message (optional) + :param retain: retain flag for this message (optional) - def add_tasmota_subscription(self, prefix, topic, detail, payload_type, bool_values=None, item=None, callback=None): """ - build the topic in Tasmota style and add the subscription to mqtt + self._handle_retained_message(topic, retain) - :param prefix: prefix of topic to subscribe to - :type prefix: str - :param topic: unique part of topic to subscribe to - :type topic: str - :param detail: detail of topic to subscribe to - :type detail: str - :param payload_type: payload type of the topic (for this subscription to the topic) - :type payload_type: str - :param bool_values: bool values (for this subscription to the topic) - :type bool_values list - :param item: item that should receive the payload as value. Used by the standard handler (if no callback function is specified) - :type item: item - :param callback: a plugin can provide an own callback function, if special handling of the payload is needed - :return: None + try: + (topic_type, tasmota_topic, info_topic) = topic.split('/') + except Exception as e: + self.logger.error(f"received topic {topic} is not in correct format. Error was: {e}") + else: + self.logger.info(f"Received LWT Message for {tasmota_topic} with value={payload} and retain={retain}") + + if payload: + if tasmota_topic not in self.tasmota_devices: + self.logger.debug(f"New online device based on LWT Message discovered.") + self._handle_new_discovered_device(tasmota_topic) + self.tasmota_devices[tasmota_topic]['online_timeout'] = datetime.now() + timedelta(seconds=self.telemetry_period + 5) + + if tasmota_topic in self.tasmota_devices: + self.tasmota_devices[tasmota_topic]['online'] = payload + self._set_item_value(tasmota_topic, 'item_online', payload, info_topic) + + def on_mqtt_status0_message(self, topic: str, payload: dict, qos: int = None, retain: bool = None) -> None: """ + Callback function to handle received messages - tpc = self.full_topic.replace("%prefix%", prefix) - tpc = tpc.replace("%topic%", topic) - tpc += detail - self.add_subscription(tpc, payload_type, bool_values=bool_values, callback=callback) + :param topic: MQTT topic + :param payload: MQTT message payload + :param qos: qos for this message + :param retain: retain flag for this message - def publish_tasmota_topic(self, prefix, topic, detail, payload, item=None, qos=None, retain=False, - bool_values=None): """ - build the topic in Tasmota style and publish to mqtt - :param prefix: prefix of topic to publish - :type prefix: str - :param topic: unique part of topic to publish - :type topic: str - :param detail: detail of topic to publish - :type detail: str - :param payload: payload to publish - :type payload: any - :param item: item (if relevant) - :type item: item - :param qos: qos for this message (optional) - :type qos: int - :param retain: retain flag for this message (optional) - :type retain: bool - :param bool_values: bool values (for publishing this topic, optional) - :type bool_values list - :return: None + """ + Example payload + + payload = {'Status': {'Module': 75, 'DeviceName': 'ZIGBEE_Bridge01', 'FriendlyName': ['SONOFF_ZB1'], + 'Topic': 'SONOFF_ZB1', 'ButtonTopic': '0', 'Power': 0, 'PowerOnState': 3, 'LedState': 1, + 'LedMask': 'FFFF', 'SaveData': 1, 'SaveState': 1, 'SwitchTopic': '0', + 'SwitchMode': [0, 0, 0, 0, 0, 0, 0, 0], 'ButtonRetain': 0, 'SwitchRetain': 0, + 'SensorRetain': 0, 'PowerRetain': 0, 'InfoRetain': 0, 'StateRetain': 0}, + 'StatusPRM': {'Baudrate': 115200, 'SerialConfig': '8N1', 'GroupTopic': 'tasmotas', + 'OtaUrl': 'http://ota.tasmota.com/tasmota/release/tasmota-zbbridge.bin.gz', + 'RestartReason': 'Software/System restart', 'Uptime': '0T23:18:30', + 'StartupUTC': '2022-11-19T12:10:15', 'Sleep': 50, 'CfgHolder': 4617, 'BootCount': 116, + 'BCResetTime': '2021-04-28T08:32:10', 'SaveCount': 160, 'SaveAddress': '1FB000'}, + 'StatusFWR': {'Version': '12.1.1(zbbridge)', 'BuildDateTime': '2022-08-25T11:37:17', 'Boot': 31, + 'Core': '2_7_4_9', 'SDK': '2.2.2-dev(38a443e)', 'CpuFrequency': 160, + 'Hardware': 'ESP8266EX', 'CR': '372/699'}, + 'StatusLOG': {'SerialLog': 0, 'WebLog': 2, 'MqttLog': 0, 'SysLog': 0, 'LogHost': '', 'LogPort': 514, + 'SSId': ['WLAN-Access', ''], 'TelePeriod': 300, 'Resolution': '558180C0', + 'SetOption': ['00008009', '2805C80001000600003C5A0A002800000000', '00000080', + '40046002', '00004810', '00000000']}, + 'StatusMEM': {'ProgramSize': 685, 'Free': 1104, 'Heap': 25, 'ProgramFlashSize': 2048, + 'FlashSize': 2048, 'FlashChipId': '1540A1', 'FlashFrequency': 40, 'FlashMode': 3, + 'Features': ['00000809', '0F1007C6', '04400001', '00000003', '00000000', '00000000', + '00020080', '00200000', '04000000', '00000000'], + 'Drivers': '1,2,4,7,9,10,12,20,23,38,41,50,62', 'Sensors': '1'}, + 'StatusNET': {'Hostname': 'SONOFF-ZB1-6926', 'IPAddress': '192.168.2.24', 'Gateway': '192.168.2.1', + 'Subnetmask': '255.255.255.0', 'DNSServer1': '192.168.2.1', 'DNSServer2': '0.0.0.0', + 'Mac': '84:CC:A8:AA:1B:0E', 'Webserver': 2, 'HTTP_API': 1, 'WifiConfig': 0, + 'WifiPower': 17.0}, + 'StatusMQT': {'MqttHost': '192.168.2.12', 'MqttPort': 1883, 'MqttClientMask': 'DVES_%06X', + 'MqttClient': 'DVES_AA1B0E', 'MqttUser': 'DVES_USER', 'MqttCount': 1, + 'MAX_PACKET_SIZE': 1200, 'KEEPALIVE': 30, 'SOCKET_TIMEOUT': 4}, + 'StatusTIM': {'UTC': '2022-11-20T11:28:45', 'Local': '2022-11-20T12:28:45', + 'StartDST': '2022-03-27T02:00:00', 'EndDST': '2022-10-30T03:00:00', + 'Timezone': '+01:00', 'Sunrise': '08:07', 'Sunset': '17:04'}, + 'StatusSNS': {'Time': '2022-11-20T12:28:45'}, + 'StatusSTS': {'Time': '2022-11-20T12:28:45', 'Uptime': '0T23:18:30', 'UptimeSec': 83910, 'Vcc': 3.41, + 'Heap': 24, 'SleepMode': 'Dynamic', 'Sleep': 50, 'LoadAvg': 19, 'MqttCount': 1, + 'Wifi': {'AP': 1, 'SSId': 'WLAN-Access', 'BSSId': '38:10:D5:15:87:69', 'Channel': 1, + 'Mode': '11n', 'RSSI': 50, 'Signal': -75, 'LinkCount': 1, + 'Downtime': '0T00:00:03'}}} + """ - tpc = self.full_topic.replace("%prefix%", prefix) - tpc = tpc.replace("%topic%", topic) - tpc += detail - self.publish_topic(tpc, payload, item, qos, retain, bool_values) - def on_discovery(self, topic, payload, qos=None, retain=None): + self._handle_retained_message(topic, retain) + + try: + (topic_type, tasmota_topic, info_topic) = topic.split('/') + self.logger.info(f"on_mqtt_status0_message: topic_type={topic_type}, tasmota_topic={tasmota_topic}, info_topic={info_topic}, payload={payload}") + except Exception as e: + self.logger.error(f"received topic {topic} is not in correct format. Error was: {e}") + + else: + self.logger.info(f"Received Status0 Message for {tasmota_topic} with value={payload} and retain={retain}") + self.tasmota_devices[tasmota_topic]['status'] = 'interviewed' + + # handle teleperiod + self._handle_teleperiod(tasmota_topic, payload['StatusLOG']) + + if self.tasmota_devices[tasmota_topic]['status'] != 'interviewed': + if self.tasmota_devices[tasmota_topic]['status'] != 'discovered': + # friendly name + self.tasmota_devices[tasmota_topic]['friendly_name'] = payload['Status']['FriendlyName'][0] + + # IP Address + ip = payload['StatusNET']['IPAddress'] + ip_eth = payload['StatusNET'].get('Ethernet', {}).get('IPAddress') + ip = ip_eth if ip == '0.0.0.0' else None + self.tasmota_devices[tasmota_topic]['ip'] = ip + + # Firmware + self.tasmota_devices[tasmota_topic]['fw_ver'] = payload['StatusFWR']['Version'].split('(')[0] + + # MAC + self.tasmota_devices[tasmota_topic]['mac'] = payload['StatusNET']['Mac'] + + # Module No + self.tasmota_devices[tasmota_topic]['template'] = payload['Status']['Module'] + + # get detailed status using payload['StatusSTS'] + status_sts = payload['StatusSTS'] + + # Handling Lights and Dimmer + if any([i in status_sts for i in self.LIGHT_MSG]): + self._handle_lights(tasmota_topic, info_topic, status_sts) + + # Handling of Power + if any(item.startswith("POWER") for item in status_sts.keys()): + self._handle_power(tasmota_topic, info_topic, status_sts) + + # Handling of RF messages + if any(item.startswith("Rf") for item in status_sts.keys()): + self._handle_rf(tasmota_topic, info_topic, status_sts) + + # Handling of Wi-Fi + if 'Wifi' in status_sts: + self._handle_wifi(tasmota_topic, status_sts['Wifi']) + + # Handling of Uptime + if 'Uptime' in status_sts: + self._handle_uptime(tasmota_topic, status_sts['Uptime']) + + # Handling of UptimeSec + if 'UptimeSec' in status_sts: + self.logger.info(f"Received Message contains UptimeSec information.") + self._handle_uptime_sec(tasmota_topic, status_sts['UptimeSec']) + + def on_mqtt_info_message(self, topic: str, payload: dict, qos: int = None, retain: bool = None) -> None: """ - Callback function to handle received discovery messages + Callback function to handle received messages :param topic: MQTT topic - :type topic: str :param payload: MQTT message payload - :type payload: dict :param qos: qos for this message (optional) - :type qos: int :param retain: retain flag for this message (optional) - :type retain: bool + """ - # device_id=2C3AE82EB8AE, type=config, payload={"ip":"192.168.2.25","dn":"SONOFF_B1","fn":["SONOFF_B1",null,null,null,null,null,null,null],"hn":"SONOFF-B1-6318","mac":"2C3AE82EB8AE","md":"Sonoff Basic","ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON","TOGGLE","HOLD"],"sw":"11.0.0","t":"SONOFF_B1","ft":"%prefix%/%topic%/","tp":["cmnd","stat","tele"],"rl":[1,0,0,0,0,0,0,0],"swc":[-1,-1,-1,-1,-1,-1,-1,-1],"swn":[null,null,null,null,null,null,null,null],"btn":[0,0,0,0,0,0,0,0],"so":{"4":0,"11":0,"13":0,"17":1,"20":0,"30":0,"68":0,"73":0,"82":0,"114":0,"117":0},"lk":0,"lt_st":0,"sho":[0,0,0,0],"ver":1} - # device_id=2C3AE82EB8AE, type=sensors, payload={"sn":{"Time":"2022-02-23T11:00:43","DS18B20":{"Id":"00000938355C","Temperature":18.1},"TempUnit":"C"},"ver":1} - # device_id=2CF432CC2FC5, type=config, payload={"ip":"192.168.2.33","dn":"NXSM200_01","fn":["NXSM200_01",null,null,null,null,null,null,null],"hn":"NXSM200-01-4037","mac":"2CF432CC2FC5","md":"NXSM200","ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON","TOGGLE","HOLD"],"sw":"11.0.0","t":"NXSM200_01","ft":"%prefix%/%topic%/","tp":["cmnd","stat","tele"],"rl":[1,0,0,0,0,0,0,0],"swc":[-1,-1,-1,-1,-1,-1,-1,-1],"swn":[null,null,null,null,null,null,null,null],"btn":[0,0,0,0,0,0,0,0],"so":{"4":0,"11":0,"13":0,"17":0,"20":0,"30":0,"68":0,"73":0,"82":0,"114":0,"117":0},"lk":0,"lt_st":0,"sho":[0,0,0,0],"ver":1} - # device_id=2CF432CC2FC5, type=sensors, payload={"sn":{"Time":"2022-02-23T11:02:48","ENERGY":{"TotalStartTime":"2019-12-23T17:02:03","Total":72.814,"Yesterday":0.000,"Today":0.000,"Power": 0,"ApparentPower": 0,"ReactivePower": 0,"Factor":0.00,"Voltage": 0,"Current":0.000}},"ver":1} - # device_id=6001946F966E, type=config, payload={"ip":"192.168.2.31","dn":"SONOFF_RGBW1","fn":["SONOFF_RGBW",null,null,null,null,null,null,null],"hn":"SONOFF-RGBW1-5742","mac":"6001946F966E","md":"H801","ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON","TOGGLE","HOLD"],"sw":"11.0.0","t":"SONOFF_RGBW1","ft":"%prefix%/%topic%/","tp":["cmnd","stat","tele"],"rl":[2,0,0,0,0,0,0,0],"swc":[-1,-1,-1,-1,-1,-1,-1,-1],"swn":[null,null,null,null,null,null,null,null],"btn":[0,0,0,0,0,0,0,0],"so":{"4":0,"11":0,"13":0,"17":0,"20":0,"30":0,"68":0,"73":0,"82":0,"114":0,"117":0},"lk":1,"lt_st":5,"sho":[0,0,0,0],"ver":1} - # device_id=6001946F966E, type=sensors, payload={"sn":{"Time":"2022-02-23T11:00:43"},"ver":1} + self._handle_retained_message(topic, retain) + try: - (tasmota, discovery, device_id, type) = topic.split('/') - self.logger.info(f"on_discovery: device_id={device_id}, type={type}, payload={payload}") + (topic_type, tasmota_topic, info_topic) = topic.split('/') + self.logger.debug(f"on_mqtt_message: topic_type={topic_type}, tasmota_topic={tasmota_topic}, info_topic={info_topic}, payload={payload}") except Exception as e: self.logger.error(f"received topic {topic} is not in correct format. Error was: {e}") else: - if type == 'config': - tasmota_topic = payload.get('dn', None) - if tasmota_topic: - self.discovered_devices.append(tasmota_topic) + if info_topic == 'INFO1': + # payload={'Info1': {'Module': 'Sonoff Basic', 'Version': '11.0.0(tasmota)', 'FallbackTopic': 'cmnd/DVES_2EB8AE_fb/', 'GroupTopic': 'cmnd/tasmotas/'}} + self.logger.debug(f"Received Message decoded as INFO1 message.") + self.tasmota_devices[tasmota_topic]['fw_ver'] = payload['Info1']['Version'].split('(')[0] + self.tasmota_devices[tasmota_topic]['module_no'] = payload['Info1']['Module'] + + elif info_topic == 'INFO2': + # payload={'Info2': {'WebServerMode': 'Admin', 'Hostname': 'SONOFF-B1-6318', 'IPAddress': '192.168.2.25'}} + self.logger.debug(f"Received Message decoded as INFO2 message.") + self.tasmota_devices[tasmota_topic]['ip'] = payload['Info2']['IPAddress'] - def on_mqtt_announce(self, topic, payload, qos=None, retain=None): + elif info_topic == 'INFO3': + # payload={'Info3': {'RestartReason': 'Software/System restart', 'BootCount': 1395}} + self.logger.debug(f"Received Message decoded as INFO3 message.") + restart_reason = payload['Info3']['RestartReason'] + self.logger.warning(f"Device {tasmota_topic} (IP={self.tasmota_devices[tasmota_topic]['ip']}) just startet. Reason={restart_reason}") + + def on_mqtt_message(self, topic: str, payload: dict, qos: int = None, retain: bool = None) -> None: """ Callback function to handle received messages :param topic: MQTT topic - :type topic: str :param payload: MQTT message payload - :type payload: dict :param qos: qos for this message (optional) - :type qos: int :param retain: retain flag for this message (optional) - :type retain: bool + """ + + self._handle_retained_message(topic, retain) + try: (topic_type, tasmota_topic, info_topic) = topic.split('/') - self.logger.info( - f"on_mqtt_announce: topic_type={topic_type}, tasmota_topic={tasmota_topic}, info_topic={info_topic}, payload={payload}") + self.logger.info(f"on_mqtt_message: topic_type={topic_type}, tasmota_topic={tasmota_topic}, info_topic={info_topic}, payload={payload}") except Exception as e: self.logger.error(f"received topic {topic} is not in correct format. Error was: {e}") else: - # ask for status info of this newly discovered device - if info_topic != 'ZbReceived' and not self.tasmota_devices.get(tasmota_topic): - self.tasmota_devices[tasmota_topic] = {} - self.tasmota_devices[tasmota_topic]['connected_to_item'] = False - self.tasmota_devices[tasmota_topic]['uptime'] = '-' - self.tasmota_devices[tasmota_topic]['lights'] = {} - self.tasmota_devices[tasmota_topic]['rf'] = {} - self.tasmota_devices[tasmota_topic]['sensors'] = {} - self.tasmota_devices[tasmota_topic]['relais'] = {} - self.tasmota_devices[tasmota_topic]['zigbee'] = {} - self.logger.debug(f"on_mqtt_announce: new device discovered, publishing 'cmnd/{topic}/STATUS'") - self.publish_topic(f"cmnd/'{tasmota_topic}/STATUS", 0) - - if info_topic == 'LWT': - # Handling of LWT - self.logger.debug(f"LWT: info_topic: {info_topic} datetime: {datetime.now()} payload: {payload}") - self.tasmota_devices[tasmota_topic]['online'] = payload - self._set_item_value(tasmota_topic, 'item_online', payload, info_topic) - if payload is True: - self.tasmota_devices[tasmota_topic]['online_timeout'] = datetime.now() + timedelta( - seconds=self.telemetry_period + 5) - # self.logger.info(f" - new 'online_timeout'={self.tasmota_devices[tasmota_topic]['online_timeout']}") - elif info_topic == 'STATE' or info_topic == 'RESULT': + # handle unknown device + if tasmota_topic not in self.tasmota_devices: + self._handle_new_discovered_device(tasmota_topic) + + # handle message + if isinstance(payload, dict) and info_topic in ['STATE', 'RESULT']: + + # Handling of TelePeriod + if 'TelePeriod' in payload: + self.logger.info(f"Received Message decoded as teleperiod message.") + self._handle_teleperiod(tasmota_topic, payload['TelePeriod']) + + elif 'Module' in payload: + self.logger.info(f"Received Message decoded as Module message.") + self._handle_module(tasmota_topic, payload['Module']) + # Handling of Light messages - if type(payload) is dict and ( - 'HSBColor' or 'Dimmer' or 'Color' or 'CT' or 'Scheme' or 'Fade' or 'Speed' or 'LedTable' or 'White') in payload: + elif any([i in payload for i in self.LIGHT_MSG]): self.logger.info(f"Received Message decoded as light message.") self._handle_lights(tasmota_topic, info_topic, payload) @@ -604,170 +766,80 @@ def on_mqtt_announce(self, topic, payload, qos=None, retain=None): self.logger.info(f"Received Message decoded as power message.") self._handle_power(tasmota_topic, info_topic, payload) - # Handling of RF messages - elif any(item.startswith("Rf") for item in payload.keys()): - self.logger.info(f"Received Message decoded as RF type message.") - self._handle_rf(tasmota_topic, info_topic, payload) + # Handling of RF messages payload={'Time': '2022-11-21T11:22:55', 'RfReceived': {'Sync': 10120, 'Low': 330, 'High': 980, 'Data': '3602B8', 'RfKey': 'None'}} + elif 'RfReceived' in payload: + self.logger.info(f"Received Message decoded as RF message.") + self._handle_rf(tasmota_topic, info_topic, payload['RfReceived']) - # Handling of Module messages - elif type(payload) is dict and 'Module' in payload: - self.logger.info(f"Received Message decoded as Module type message.") - self._handle_module(tasmota_topic, payload) - - # Handling of Zigbee Bridge Setting messages - elif type(payload) is dict and any(item.startswith("SetOption") for item in payload.keys()): - self.logger.info(f"Received Message decoded as Zigbee Bridge Setting message.") - self._handle_zbbridge_setting(payload) + # Handling of Setting messages + elif next(iter(payload)).startswith("SetOption"): + # elif any(item.startswith("SetOption") for item in payload.keys()): + self.logger.info(f"Received Message decoded as Tasmota Setting message.") + self._handle_setting(tasmota_topic, payload) # Handling of Zigbee Bridge Config messages - elif type(payload) is dict and any(item.startswith("ZbConfig") for item in payload.keys()): + elif 'ZbConfig' in payload: self.logger.info(f"Received Message decoded as Zigbee Config message.") - self._handle_zbconfig(tasmota_topic, payload) + self._handle_zbconfig(tasmota_topic, payload['ZbConfig']) # Handling of Zigbee Bridge Status messages elif any(item.startswith("ZbStatus") for item in payload.keys()): self.logger.info(f"Received Message decoded as Zigbee ZbStatus message.") self._handle_zbstatus(tasmota_topic, payload) - # Handling of WIFI - if type(payload) is dict and 'Wifi' in payload: + # Handling of Wi-Fi + if 'Wifi' in payload: self.logger.info(f"Received Message contains Wifi information.") - self._handle_wifi(tasmota_topic, payload) + self._handle_wifi(tasmota_topic, payload['Wifi']) # Handling of Uptime - if tasmota_topic in self.tasmota_devices: - self.logger.info(f"Received Message will be checked for Uptime.") - self.tasmota_devices[tasmota_topic]['uptime'] = payload.get('Uptime', '-') - - # setting new online-timeout - self.tasmota_devices[tasmota_topic]['online_timeout'] = datetime.now() + timedelta( - seconds=self.telemetry_period + 5) + if 'Uptime' in payload: + self.logger.info(f"Received Message contains Uptime information.") + self._handle_uptime(tasmota_topic, payload['Uptime']) - # setting online_item to True - self._set_item_value(tasmota_topic, 'item_online', True, info_topic) + # Handling of UptimeSec + if 'UptimeSec' in payload: + self.logger.info(f"Received Message contains UptimeSec information.") + self._handle_uptime_sec(tasmota_topic, payload['UptimeSec']) - elif info_topic == 'SENSOR': - self.logger.info(f"Received Message contain sensor information.") + elif isinstance(payload, dict) and info_topic == 'SENSOR': + self.logger.info(f"Received Message contains sensor information.") self._handle_sensor(tasmota_topic, info_topic, payload) - # setting new online-timeout - self.tasmota_devices[tasmota_topic]['online_timeout'] = datetime.now() + timedelta( - seconds=self.telemetry_period + 5) - - # setting online_item to True - self._set_item_value(tasmota_topic, 'item_online', True, info_topic) - - elif info_topic == 'STATUS0': - # payload={'Status': {'Module': 1, 'DeviceName': 'SONOFF_B1', 'FriendlyName': ['SONOFF_B1'], 'Topic': 'SONOFF_B1', 'ButtonTopic': '0', 'Power': 1, 'PowerOnState': 3, 'LedState': 1, 'LedMask': 'FFFF', 'SaveData': 1, 'SaveState': 1, 'SwitchTopic': '0', 'SwitchMode': [0, 0, 0, 0, 0, 0, 0, 0], 'ButtonRetain': 0, 'SwitchRetain': 0, 'SensorRetain': 0, 'PowerRetain': 0, 'InfoRetain': 0, 'StateRetain': 0}, 'StatusPRM': {'Baudrate': 115200, 'SerialConfig': '8N1', 'GroupTopic': 'tasmotas', 'OtaUrl': 'http://ota.tasmota.com/tasmota/release/tasmota.bin.gz', 'RestartReason': 'Software/System restart', 'Uptime': '0T20:03:04', 'StartupUTC': '2022-02-22T11:36:56', 'Sleep': 50, 'CfgHolder': 4617, 'BootCount': 1394, 'BCResetTime': '2021-11-19T09:08:33', 'SaveCount': 1571, 'SaveAddress': 'F9000'}, 'StatusFWR': {'Version': '11.0.0(tasmota)', 'BuildDateTime': '2022-02-12T14:13:50', 'Boot': 6, 'Core': '2_7_4_9', 'SDK': '2.2.2-dev(38a443e)', 'CpuFrequency': 80, 'Hardware': 'ESP8266EX', 'CR': '354/699'}, 'StatusLOG': {'SerialLog': 2, 'WebLog': 2, 'MqttLog': 0, 'SysLog': 0, 'LogHost': '', 'LogPort': 514, 'SSId': ['WLAN-Access', ''], 'TelePeriod': 300, 'Resolution': '558180C0', 'SetOption': ['000A8009', '2805C80001000600003C5A0A000000000000', '000002A0', '00006000', '00004000']}, 'StatusMEM': {'ProgramSize': 620, 'Free': 380, 'Heap': 25, 'ProgramFlashSize': 1024, 'FlashSize': 1024, 'FlashChipId': '14405E', 'FlashFrequency': 40, 'FlashMode': 3, 'Features': ['00000809', '8FDAC787', '04368001', '000000CF', '010013C0', 'C000F981', '00004004', '00001000', '00000020'], 'Drivers': '1,2,3,4,5,6,7,8,9,10,12,16,18,19,20,21,22,24,26,27,29,30,35,37,45', 'Sensors': '1,2,3,4,5,6'}, 'StatusNET': {'Hostname': 'SONOFF-B1-6318', 'IPAddress': '192.168.2.25', 'Gateway': '192.168.2.1', 'Subnetmask': '255.255.255.0', 'DNSServer1': '192.168.2.1', 'DNSServer2': '0.0.0.0', 'Mac': '2C:3A:E8:2E:B8:AE', 'Webserver': 2, 'HTTP_API': 1, 'WifiConfig': 4, 'WifiPower': 17.0}, 'StatusMQT': {'MqttHost': '192.168.2.12', 'MqttPort': 1883, 'MqttClientMask': 'DVES_%06X', 'MqttClient': 'DVES_2EB8AE', 'MqttUser': 'DVES_USER', 'MqttCount': 3, 'MAX_PACKET_SIZE': 1200, 'KEEPALIVE': 30, 'SOCKET_TIMEOUT': 4}, 'StatusTIM': {'UTC': '2022-02-23T07:40:00', 'Local': '2022-02-23T08:40:00', 'StartDST': '2022-03-27T02:00:00', 'EndDST': '2022-10-30T03:00:00', 'Timezone': '+01:00', 'Sunrise': '07:43', 'Sunset': '18:23'}, 'StatusSNS': {'Time': '2022-02-23T08:40:00', 'DS18B20': {'Id': '00000938355C', 'Temperature': 18.1}, 'TempUnit': 'C'}, 'StatusSTS': {'Time': '2022-02-23T08:40:00', 'Uptime': '0T20:03:04', 'UptimeSec': 72184, 'Heap': 24, 'SleepMode': 'Dynamic', 'Sleep': 50, 'LoadAvg': 19, 'MqttCount': 3, 'POWER': 'ON', 'Wifi': {'AP': 1, 'SSId': 'WLAN-Access', 'BSSId': 'DC:39:6F:15:58:0B', 'Channel': 11, 'Mode': '11n', 'RSSI': 76, 'Signal': -62, 'LinkCount': 3, 'Downtime': '0T00:00:07'}}} - # payload={'Status': {'Module': 7, 'DeviceName': 'SONOFF_B1', 'FriendlyName': ['SONOFF_B1', '', '', ''], 'Topic': 'SONOFF_B1', 'ButtonTopic': '0', 'Power': 1, 'PowerOnState': 3, 'LedState': 1, 'LedMask': 'FFFF', 'SaveData': 1, 'SaveState': 1, 'SwitchTopic': '0', 'SwitchMode': [0, 0, 0, 0, 0, 0, 0, 0], 'ButtonRetain': 0, 'SwitchRetain': 0, 'SensorRetain': 0, 'PowerRetain': 0, 'InfoRetain': 0, 'StateRetain': 0}, 'StatusPRM': {'Baudrate': 115200, 'SerialConfig': '8N1', 'GroupTopic': 'tasmotas', 'OtaUrl': 'http://ota.tasmota.com/tasmota/release/tasmota.bin.gz', 'RestartReason': 'Software/System restart', 'Uptime': '0T00:00:14', 'StartupUTC': '2022-02-23T09:12:09', 'Sleep': 50, 'CfgHolder': 4617, 'BootCount': 1397, 'BCResetTime': '2021-11-19T09:08:33', 'SaveCount': 1578, 'SaveAddress': 'FA000'}, 'StatusFWR': {'Version': '11.0.0(tasmota)', 'BuildDateTime': '2022-02-12T14:13:50', 'Boot': 6, 'Core': '2_7_4_9', 'SDK': '2.2.2-dev(38a443e)', 'CpuFrequency': 80, 'Hardware': 'ESP8266EX', 'CR': '354/699'}, 'StatusLOG': {'SerialLog': 2, 'WebLog': 2, 'MqttLog': 0, 'SysLog': 0, 'LogHost': '', 'LogPort': 514, 'SSId': ['WLAN-Access', ''], 'TelePeriod': 300, 'Resolution': '558180C0', 'SetOption': ['00028009', '2805C80001000600003C5A0A000000000000', '000002A0', '00006000', '00004000']}, 'StatusMEM': {'ProgramSize': 620, 'Free': 380, 'Heap': 26, 'ProgramFlashSize': 1024, 'FlashSize': 1024, 'FlashChipId': '14405E', 'FlashFrequency': 40, 'FlashMode': 3, 'Features': ['00000809', '8FDAC787', '04368001', '000000CF', '010013C0', 'C000F981', '00004004', '00001000', '00000020'], 'Drivers': '1,2,3,4,5,6,7,8,9,10,12,16,18,19,20,21,22,24,26,27,29,30,35,37,45', 'Sensors': '1,2,3,4,5,6'}, 'StatusNET': {'Hostname': 'SONOFF-B1-6318', 'IPAddress': '192.168.2.25', 'Gateway': '192.168.2.1', 'Subnetmask': '255.255.255.0', 'DNSServer1': '192.168.2.1', 'DNSServer2': '0.0.0.0', 'Mac': '2C:3A:E8:2E:B8:AE', 'Webserver': 2, 'HTTP_API': 1, 'WifiConfig': 4, 'WifiPower': 17.0}, 'StatusMQT': {'MqttHost': '192.168.2.12', 'MqttPort': 1883, 'MqttClientMask': 'DVES_%06X', 'MqttClient': 'DVES_2EB8AE', 'MqttUser': 'DVES_USER', 'MqttCount': 1, 'MAX_PACKET_SIZE': 1200, 'KEEPALIVE': 30, 'SOCKET_TIMEOUT': 4}, 'StatusTIM': {'UTC': '2022-02-23T09:12:23', 'Local': '2022-02-23T10:12:23', 'StartDST': '2022-03-27T02:00:00', 'EndDST': '2022-10-30T03:00:00', 'Timezone': '+01:00', 'Sunrise': '07:43', 'Sunset': '18:23'}, 'StatusSNS': {'Time': '2022-02-23T10:12:23'}, 'StatusSTS': {'Time': '2022-02-23T10:12:23', 'Uptime': '0T00:00:14', 'UptimeSec': 14, 'Heap': 25, 'SleepMode': 'Dynamic', 'Sleep': 50, 'LoadAvg': 19, 'MqttCount': 1, 'POWER1': 'ON', 'POWER2': 'OFF', 'POWER3': 'OFF', 'POWER4': 'OFF', 'Wifi': {'AP': 1, 'SSId': 'WLAN-Access', 'BSSId': 'DC:39:6F:15:58:0B', 'Channel': 11, 'Mode': '11n', 'RSSI': 80, 'Signal': -60, 'LinkCount': 1, 'Downtime': '0T00:00:03'}}} - # payload={'Status': {'Module': 20, 'DeviceName': 'SONOFF_RGBW1', 'FriendlyName': ['SONOFF_RGBW'], 'Topic': 'SONOFF_RGBW1', 'ButtonTopic': '0', 'Power': 1, 'PowerOnState': 3, 'LedState': 1, 'LedMask': 'FFFF', 'SaveData': 1, 'SaveState': 1, 'SwitchTopic': '0', 'SwitchMode': [0, 0, 0, 0, 0, 0, 0, 0], 'ButtonRetain': 0, 'SwitchRetain': 0, 'SensorRetain': 0, 'PowerRetain': 0, 'InfoRetain': 0, 'StateRetain': 0}, 'StatusPRM': {'Baudrate': 115200, 'SerialConfig': '8N1', 'GroupTopic': 'sonoffs', 'OtaUrl': 'http://ota.tasmota.com/tasmota/release/tasmota.bin.gz', 'RestartReason': 'Software/System restart', 'Uptime': '0T00:02:06', 'StartupUTC': '2022-02-23T09:20:17', 'Sleep': 50, 'CfgHolder': 4617, 'BootCount': 123, 'BCResetTime': '2021-03-12T16:54:51', 'SaveCount': 449, 'SaveAddress': 'F8000'}, 'StatusFWR': {'Version': '11.0.0(tasmota)', 'BuildDateTime': '2022-02-12T14:13:50', 'Boot': 31, 'Core': '2_7_4_9', 'SDK': '2.2.2-dev(38a443e)', 'CpuFrequency': 80, 'Hardware': 'ESP8266EX', 'CR': '429/699'}, 'StatusLOG': {'SerialLog': 2, 'WebLog': 2, 'MqttLog': 0, 'SysLog': 0, 'LogHost': '', 'LogPort': 514, 'SSId': ['WLAN-Access', ''], 'TelePeriod': 300, 'Resolution': '55C180C0', 'SetOption': ['00008009', '2805C80001000600003C5AFF000000000000', '00000080', '00006000', '00004000']}, 'StatusMEM': {'ProgramSize': 620, 'Free': 380, 'Heap': 25, 'ProgramFlashSize': 1024, 'FlashSize': 1024, 'FlashChipId': '1440EF', 'FlashFrequency': 40, 'FlashMode': 3, 'Features': ['00000809', '8FDAC787', '04368001', '000000CF', '010013C0', 'C000F981', '00004004', '00001000', '00000020'], 'Drivers': '1,2,3,4,5,6,7,8,9,10,12,16,18,19,20,21,22,24,26,27,29,30,35,37,45', 'Sensors': '1,2,3,4,5,6'}, 'StatusNET': {'Hostname': 'SONOFF-RGBW1-5742', 'IPAddress': '192.168.2.31', 'Gateway': '192.168.2.1', 'Subnetmask': '255.255.255.0', 'DNSServer1': '192.168.2.1', 'DNSServer2': '0.0.0.0', 'Mac': '60:01:94:6F:96:6E', 'Webserver': 2, 'HTTP_API': 1, 'WifiConfig': 5, 'WifiPower': 17.0}, 'StatusMQT': {'MqttHost': '192.168.2.12', 'MqttPort': 1883, 'MqttClientMask': 'DVES_%06X', 'MqttClient': 'DVES_6F966E', 'MqttUser': 'DVES_USER', 'MqttCount': 1, 'MAX_PACKET_SIZE': 1200, 'KEEPALIVE': 30, 'SOCKET_TIMEOUT': 4}, 'StatusTIM': {'UTC': '2022-02-23T09:22:23', 'Local': '2022-02-23T10:22:23', 'StartDST': '2022-03-27T02:00:00', 'EndDST': '2022-10-30T03:00:00', 'Timezone': '+01:00', 'Sunrise': '07:43', 'Sunset': '18:23'}, 'StatusSNS': {'Time': '2022-02-23T10:22:23'}, 'StatusSTS': {'Time': '2022-02-23T10:22:23', 'Uptime': '0T00:02:06', 'UptimeSec': 126, 'Heap': 24, 'SleepMode': 'Dynamic', 'Sleep': 10, 'LoadAvg': 99, 'MqttCount': 1, 'POWER': 'ON', 'Dimmer': 100, 'Color': '65FF3F0000', 'HSBColor': '108,75,100', 'White': 0, 'CT': 153, 'Channel': [40, 100, 25, 0, 0], 'Scheme': 0, 'Fade': 'ON', 'Speed': 1, 'LedTable': 'OFF', 'Wifi': {'AP': 1, 'SSId': 'WLAN-Access', 'BSSId': '38:10:D5:15:87:69', 'Channel': 1, 'Mode': '11n', 'RSSI': 64, 'Signal': -68, 'LinkCount': 1, 'Downtime': '0T00:00:03'}}} - self.logger.info(f"Received Message decoded as STATUS0 message.") - - # get friendly name - friendly_name = payload['Status'].get('FriendlyName', None) - if friendly_name and isinstance(friendly_name, list): - friendly_name = ''.join(friendly_name) - self.tasmota_devices[tasmota_topic]['friendly_name'] = friendly_name - - # get Module - module = payload['Status'].get('Module', None) - if module: - self.tasmota_devices[tasmota_topic]['module'] = module - - # get Firmware - firmware = payload['StatusFWR'].get('Version', None) - if firmware: - self.tasmota_devices[tasmota_topic]['fw_ver'] = firmware - - # get IP Address - ip = payload['StatusNET'].get('IPAddress', None) - if ip: - self.tasmota_devices[tasmota_topic]['ip'] = ip - - # get MAC - mac = payload['StatusNET'].get('Mac', None) - if mac: - self.tasmota_devices[tasmota_topic]['mac'] = mac - - # get detailed status using payload['StatusSTS'] - status_sts = payload.get('StatusSTS', None) - - if status_sts: - # Handling Lights and Dimmer - self.logger.debug(f"{tasmota_topic} status_sts={status_sts}") - - if type(payload) is dict and ( - 'HSBColor' or 'Dimmer' or 'Color' or 'CT' or 'Scheme' or 'Fade' or 'Speed' or 'LedTable' or 'White') in status_sts: - self.logger.debug('status lights') - self._handle_lights(tasmota_topic, info_topic, status_sts) - - # Handling of Power - if any(item.startswith("POWER") for item in status_sts.keys()): - self.logger.debug('status power') - self._handle_power(tasmota_topic, info_topic, status_sts) - - # Handling of RF messages - if any(item.startswith("Rf") for item in status_sts.keys()): - self.logger.debug('status rf') - self._handle_rf(tasmota_topic, info_topic, status_sts) - - # Handling of WIFI - if type(payload) is dict and 'Wifi' in status_sts: - self.logger.debug('status wifi') - self._handle_wifi(tasmota_topic, status_sts) - - # Handling of Uptime - if tasmota_topic in self.tasmota_devices: - self.tasmota_devices[tasmota_topic]['uptime'] = status_sts.get('Uptime', '-') - - elif info_topic == 'INFO1': - # payload={'Info1': {'Module': 'Sonoff Basic', 'Version': '11.0.0(tasmota)', 'FallbackTopic': 'cmnd/DVES_2EB8AE_fb/', 'GroupTopic': 'cmnd/tasmotas/'}} - self.logger.info(f"Received Message decoded as INFO1 message.") - self.tasmota_devices[tasmota_topic]['fw_ver'] = payload['Info1'].get('Version', '') - self.tasmota_devices[tasmota_topic]['module'] = payload['Info1'].get('Module', '') - - elif info_topic == 'INFO2': - # payload={'Info2': {'WebServerMode': 'Admin', 'Hostname': 'SONOFF-B1-6318', 'IPAddress': '192.168.2.25'}} - self.logger.info(f"Received Message decoded as INFO2 message.") - self.tasmota_devices[tasmota_topic]['ip'] = payload['Info2'].get('IPAddress', '') - - elif info_topic == 'INFO3': - # payload={'Info3': {'RestartReason': 'Software/System restart', 'BootCount': 1395}} - self.logger.info(f"Received Message decoded as INFO3 message.") - restart_reason = payload['Info3'].get('RestartReason', '') - self.logger.warning( - f"Device {tasmota_topic} (IP={self.tasmota_devices[tasmota_topic]['ip']}) just startet. Reason={restart_reason}") - - elif info_topic == 'ZbReceived': - self.logger.info(f"Received Message decoded as ZbReceived message.") - self._handle_ZbReceived(payload) + else: + self.logger.warning(f"Received Message '{payload}' not handled within plugin.") - # setting new online-timeout - self.tasmota_devices[tasmota_topic]['online_timeout'] = datetime.now() + timedelta( - seconds=self.telemetry_period + 5) + # setting new online-timeout + self.tasmota_devices[tasmota_topic]['online_timeout'] = datetime.now() + timedelta(seconds=self.telemetry_period + 5) - # setting online_item to True - self._set_item_value(tasmota_topic, 'item_online', True, info_topic) - else: - self.logger.info(f"Topic {info_topic} not handled in plugin.") + # setting online_item to True + self._set_item_value(tasmota_topic, 'item_online', True, info_topic) - def on_mqtt_message(self, topic, payload, qos=None, retain=None): + def on_mqtt_power_message(self, topic: str, payload: dict, qos: int = None, retain: bool = None) -> None: """ Callback function to handle received messages :param topic: MQTT topic - :type topic: str :param payload: MQTT message payload - :type payload: dict :param qos: qos for this message (optional) - :type qos: int :param retain: retain flag for this message (optional) - :type retain: bool + """ + + self._handle_retained_message(topic, retain) + + # check for retained message and handle it + if bool(retain): + if topic not in self.topics_of_retained_messages: + self.topics_of_retained_messages.append(topic) + else: + if topic in self.topics_of_retained_messages: + self.topics_of_retained_messages.remove(topic) + + # handle incoming message try: (topic_type, tasmota_topic, info_topic) = topic.split('/') - self.logger.info( - f"on_mqtt_message: topic_type={topic_type}, tasmota_topic={tasmota_topic}, info_topic={info_topic}, payload={payload}") + self.logger.info(f"on_mqtt_power_message: topic_type={topic_type}, tasmota_topic={tasmota_topic}, info_topic={info_topic}, payload={payload}") except Exception as e: self.logger.error(f"received topic {topic} is not in correct format. Error was: {e}") else: @@ -775,212 +847,206 @@ def on_mqtt_message(self, topic, payload, qos=None, retain=None): if device: if info_topic.startswith('POWER'): tasmota_relay = str(info_topic[5:]) - if not tasmota_relay: - tasmota_relay = '1' - item_relay = 'item_relay' + tasmota_relay + tasmota_relay = '1' if not tasmota_relay else None + item_relay = f'item_relay{tasmota_relay}' self._set_item_value(tasmota_topic, item_relay, payload == 'ON', info_topic) self.tasmota_devices[tasmota_topic]['relais'][info_topic] = payload - self.tasmota_meta['relais'] = True - def _set_item_value(self, tasmota_topic, itemtype, value, info_topic=''): + ############################################################ + # Parse detailed messages + ############################################################ + + def _handle_sensor(self, device: str, function: str, payload: dict) -> None: """ - Sets item value - :param tasmota_topic: MQTT message payload - :type tasmota_topic: str - :param itemtype: itemtype to be set - :type itemtype: str - :param value: value to be set - :type value: any - :param info_topic: MQTT info_topic - :type info_topic: str - :return: None + :param device: + :param function: + :param payload: + :return: """ - if tasmota_topic in self.tasmota_devices: - if self.tasmota_devices[tasmota_topic].get('connected_items'): - item = self.tasmota_devices[tasmota_topic]['connected_items'].get(itemtype) - topic = '' - src = '' - if info_topic != '': - topic = f"from info_topic '{info_topic}'" - src = self.get_instance_name() - if src != '': - src += ':' - src += tasmota_topic + ':' + info_topic - - if item is not None: - item(value, self.get_shortname(), src) - self.logger.info( - f"{tasmota_topic}: Item '{item.id()}' via itemtype '{itemtype} set to value {value} provided by {src} '.") - else: - self.logger.info( - f"{tasmota_topic}: No item for itemtype '{itemtype}' defined to set to {value} provided by {src}.") - else: - self.logger.info(f"{tasmota_topic}: No items connected to {tasmota_topic}.") + # Handling of Zigbee Device Messages + if 'ZbReceived' in payload: + self.logger.info(f"Received Message decoded as Zigbee Sensor message.") + self._handle_sensor_zigbee(device, function, payload['ZbReceived']) + + # Handling of Energy Sensors + elif 'ENERGY' in payload: + self.logger.info(f"Received Message decoded as Energy Sensor message.") + self._handle_sensor_energy(device, function, payload['ENERGY']) + + # Handling of Environmental Sensors + elif any([i in payload for i in self.ENV_SENSOR]): + self._handle_sensor_env(device, function, payload) + + # Handling of Analog Sensors + elif 'ANALOG' in payload: + self.logger.info(f"Received Message decoded as ANALOG Sensor message.") + self._handle_sensor_analog(device, function, payload['ANALOG']) + + # Handling of Sensors of ESP32 + elif 'ESP32' in payload: + self.logger.info(f"Received Message decoded as ESP32 Sensor message.") + self._handle_sensor_esp32(device, function, payload['ESP32']) + + # Handling of any other Sensor e.g. all SML devices else: - self.logger.info(f"Tasmota Device {tasmota_topic} unknown.") + if len(payload) == 2 and isinstance(payload[list(payload.keys())[1]], dict): # wenn payload 2 Einträge und der zweite Eintrag vom Typ dict + self.logger.info(f"Received Message decoded as other Sensor message (e.g. smartmeter).") + sensor = list(payload.keys())[1] + self._handle_sensor_other(device, sensor, function, payload[sensor]) - def _handle_ZbReceived(self, payload): + def _handle_sensor_zigbee(self, device: str, function: str, payload: dict) -> None: + """ + Handles Zigbee Sensor information and set items + + :param payload: payload containing zigbee sensor infos + :return: """ - Extracts Zigbee Received information out of payload and updates plugin dict - :param payload: MQTT message payload - :param payload: dict - :return: None """ - # topic_type=tele, tasmota_topic=SONOFF_ZB1, info_topic=ZbReceived, payload={'snzb-02_01': {'Device': '0x67FE', 'Name': 'snzb-02_01', 'Humidity': 31.94, 'Endpoint': 1, 'LinkQuality': 157}} + payload = {'Fenster_01': {'Device': '0xD4F3', 'Name': 'Fenster_01', 'Contact': 0, 'Endpoint': 1, 'LinkQuality': 92}} + """ + + # self.logger.debug(f"_handle_sensor_zigbee: {device=}, {function=}, {payload=}") + for zigbee_device in payload: - if zigbee_device not in self.tasmota_zigbee_devices: - self.logger.info(f"New Zigbee Device {zigbee_device} connected to Tasmota Zigbee Bridge discovered") + if zigbee_device != '0x0000' and zigbee_device not in self.tasmota_zigbee_devices: + self.logger.info(f"New Zigbee Device '{zigbee_device}'based on {function}-Message from {device} discovered") self.tasmota_zigbee_devices[zigbee_device] = {} - else: - if not self.tasmota_zigbee_devices[zigbee_device].get('data'): - self.tasmota_zigbee_devices[zigbee_device]['data'] = {} - if 'Device' in payload[zigbee_device]: - del payload[zigbee_device]['Device'] - if 'Name' in payload[zigbee_device]: - del payload[zigbee_device]['Name'] - self.tasmota_zigbee_devices[zigbee_device]['data'].update(payload[zigbee_device]) - def _handle_sensor(self, device, function, payload): + # Make all keys of Zigbee-Device Payload Dict lowercase to match itemtype from parse_item + zigbee_device_dict = {k.lower(): v for k, v in payload[zigbee_device].items()} + + # Korrigieren der Werte für (HSB) Dimmer (0-254 -> 0-100), Hue(0-254 -> 0-360), Saturation (0-254 -> 0-100) + if 'dimmer' in zigbee_device_dict: + zigbee_device_dict.update({'dimmer': _254_to_100(zigbee_device_dict['dimmer'])}) + if 'sat' in zigbee_device_dict: + zigbee_device_dict.update({'sat': _254_to_100(zigbee_device_dict['sat'])}) + if 'hue' in zigbee_device_dict: + zigbee_device_dict.update({'hue': _254_to_360(zigbee_device_dict['hue'])}) + if 'ct' in zigbee_device_dict: + zigbee_device_dict['ct_k'] = _mired_to_kelvin(zigbee_device_dict['ct']) + + # Korrektur des LastSeenEpoch von Timestamp zu datetime + if 'lastseenepoch' in zigbee_device_dict: + zigbee_device_dict.update({'lastseenepoch': datetime.fromtimestamp(zigbee_device_dict['lastseenepoch'])}) + if 'batterylastseenepoch' in zigbee_device_dict: + zigbee_device_dict.update({'batterylastseenepoch': datetime.fromtimestamp(zigbee_device_dict['batterylastseenepoch'])}) + + # Udpate des Sub-Dicts + self.tasmota_zigbee_devices[zigbee_device].update(zigbee_device_dict) + + # Iterate over payload and set corresponding items + for element in zigbee_device_dict: + itemtype = f"item_{zigbee_device}.{element.lower()}" + value = zigbee_device_dict[element] + self._set_item_value(device, itemtype, value, function) + + def _handle_sensor_energy(self, device: str, function: str, energy: dict): + """ + Handle Energy Sensor Information + :param device: + :param energy: + :param function: """ - Extracts Sensor information out of payload and updates plugin dict - :param device: Device, the Sensor information shall be handled (equals tasmota_topic) - :type device: str - :param function: Function of Device (equals info_topic) - :type function: str - :param payload: MQTT message payload - :type payload: dict - :return: + if 'ENERGY' not in self.tasmota_devices[device]['sensors']: + self.tasmota_devices[device]['sensors']['ENERGY'] = {} + + self.tasmota_devices[device]['sensors']['ENERGY']['period'] = energy.get('Period', None) + + for key in self.ENERGY_SENSOR_KEYS: + if key in energy: + self.tasmota_devices[device]['sensors']['ENERGY'][key.lower()] = energy[key] + self._set_item_value(device, self.ENERGY_SENSOR_KEYS[key], energy[key], function) + + def _handle_sensor_env(self, device: str, function: str, payload: dict): + """ + Handle Environmental Sensor Information + :param device: + :param function: + :param payload: """ - # topic_type=tele, tasmota_topic=SONOFF_B1, info_topic=SENSOR, payload={"Time":"2021-04-28T09:42:50","DS18B20":{"Id":"00000938355C","Temperature":18.4},"TempUnit":"C"} - # topic_type=tele, tasmota_topic=SONOFF_ZB1, info_topic=SENSOR, payload={'0x67FE': {'Device': '0x67FE', 'Humidity': 41.97, 'Endpoint': 1, 'LinkQuality': 55}} - # topic_type=tele, tasmota_topic=SONOFF_ZB1, info_topic=SENSOR, payload={"0x54EB":{"Device":"0x54EB","MultiInValue":2,"Click":"double","click":"double","Endpoint":1,"LinkQuality":173}} - # topic_type=tele, tasmota_topic=SONOFF_ZB1, info_topic=SENSOR, payload={"0x54EB":{"Device":"0x54EB","MultiInValue":255 ,"Click":"release","action":"release","Endpoint":1,"LinkQuality":175}} - # Handling of Zigbee Device Messages - if self.tasmota_devices[device]['zigbee'] != {}: - self.logger.info(f"Received Message decoded as Zigbee Device message.") - if type(payload) is dict: - for zigbee_device in payload: - if zigbee_device not in self.tasmota_zigbee_devices: - self.logger.info( - f"New Zigbee Device {zigbee_device} connected to Tasmota Zigbee Bridge discovered") - self.tasmota_zigbee_devices[zigbee_device] = {} - if not self.tasmota_zigbee_devices[zigbee_device].get('data'): - self.tasmota_zigbee_devices[zigbee_device]['data'] = {} - if 'Device' in payload[zigbee_device]: - del payload[zigbee_device]['Device'] - if 'Name' in payload[zigbee_device]: - del payload[zigbee_device]['Name'] - - self.tasmota_zigbee_devices[zigbee_device]['data'].update(payload[zigbee_device]) - - # Check and correct payload, if there is the same dict key used with different cases (upper and lower case) - new_dict = {} - for k in payload[zigbee_device]: - keys = [each_string.lower() for each_string in list(new_dict.keys())] - if k not in keys: - new_dict[k] = payload[zigbee_device][k] - payload[zigbee_device] = new_dict - - # Delete keys from 'meta', if in 'data' - for key in payload[zigbee_device]: - if self.tasmota_zigbee_devices[zigbee_device].get('meta'): - if key in self.tasmota_zigbee_devices[zigbee_device]['meta']: - self.tasmota_zigbee_devices[zigbee_device]['meta'].pop(key) - - # Iterate over payload and set corresponding items - self.logger.debug(f"Item to be checked for update based in Zigbee Message and updated") - for element in payload[zigbee_device]: - itemtype = f"item_{zigbee_device}.{element.lower()}" - value = payload[zigbee_device][element] - self._set_item_value(device, itemtype, value, function) - - # Handling of Tasmota Device Sensor Messages - else: - # Energy sensors - energy = payload.get('ENERGY') - if energy: - self.logger.info(f"Received Message decoded as Energy Sensor message.") - if not self.tasmota_devices[device]['sensors'].get('ENERGY'): - self.tasmota_devices[device]['sensors']['ENERGY'] = {} - if type(energy) is dict: - self.tasmota_devices[device]['sensors']['ENERGY']['period'] = energy.get('Period', None) - if 'Voltage' in energy: - self.tasmota_devices[device]['sensors']['ENERGY']['voltage'] = energy['Voltage'] - self._set_item_value(device, 'item_voltage', energy['Voltage'], function) - if 'Current' in energy: - self.tasmota_devices[device]['sensors']['ENERGY']['current'] = energy['Current'] - self._set_item_value(device, 'item_current', energy['Current'], function) - if 'Power' in energy: - self.tasmota_devices[device]['sensors']['ENERGY']['power'] = energy['Power'] - self._set_item_value(device, 'item_power', energy['Power'], function) - if 'ApparentPower' in energy: - self.tasmota_devices[device]['sensors']['ENERGY']['apparent_power'] = energy['ApparentPower'] - self._set_item_value(device, 'item_apparent_power', energy['ApparentPower'], function) - if 'ReactivePower' in energy: - self.tasmota_devices[device]['sensors']['ENERGY']['reactive_power'] = energy['ReactivePower'] - self._set_item_value(device, 'item_reactive_power', energy['ReactivePower'], function) - if 'Factor' in energy: - self.tasmota_devices[device]['sensors']['ENERGY']['factor'] = energy['Factor'] - self._set_item_value(device, 'item_power_factor', energy['Factor'], function) - if 'TotalStartTime' in energy: - self.tasmota_devices[device]['sensors']['ENERGY']['total_starttime'] = energy['TotalStartTime'] - self._set_item_value(device, 'item_total_starttime', energy['TotalStartTime'], function) - if 'Total' in energy: - self.tasmota_devices[device]['sensors']['ENERGY']['total'] = energy['Total'] - self._set_item_value(device, 'item_power_total', energy['Total'], function) - if 'Yesterday' in energy: - self.tasmota_devices[device]['sensors']['ENERGY']['yesterday'] = energy['Yesterday'] - self._set_item_value(device, 'item_power_yesterday', energy['Yesterday'], function) - if 'Today' in energy: - self.tasmota_devices[device]['sensors']['ENERGY']['today'] = energy['Today'] - self._set_item_value(device, 'item_power_today', energy['Today'], function) - - # DS18B20 sensors - ds18b20 = payload.get('DS18B20') - if ds18b20: - self.logger.info(f"Received Message decoded as DS18B20 Sensor message.") - if not self.tasmota_devices[device]['sensors'].get('DS18B20'): - self.tasmota_devices[device]['sensors']['DS18B20'] = {} - if type(ds18b20) is dict: - if 'Id' in ds18b20: - self.tasmota_devices[device]['sensors']['DS18B20']['id'] = ds18b20['Id'] - self._set_item_value(device, 'item_id', ds18b20['Id'], function) - if 'Temperature' in ds18b20: - self.tasmota_devices[device]['sensors']['DS18B20']['temp'] = ds18b20['Temperature'] - self._set_item_value(device, 'item_temp', ds18b20['Temperature'], function) - - # AM2301 sensors - am2301 = payload.get('AM2301') - if am2301: - self.logger.info(f"Received Message decoded as AM2301 Sensor message.") - if not self.tasmota_devices[device]['sensors'].get('AM2301'): - self.tasmota_devices[device]['sensors']['AM2301'] = {} - if type(am2301) is dict: - if 'Humidity' in am2301: - self.tasmota_devices[device]['sensors']['AM2301']['hum'] = am2301['Humidity'] - self._set_item_value(device, 'item_hum', am2301['Humidity'], function) - if 'Temperature' in am2301: - self.tasmota_devices[device]['sensors']['AM2301']['temp'] = am2301['Temperature'] - self._set_item_value(device, 'item_temp', am2301['Temperature'], function) - if 'DewPoint' in am2301: - self.tasmota_devices[device]['sensors']['AM2301']['dewpoint'] = am2301['DewPoint'] - self._set_item_value(device, 'item_dewpoint', am2301['DewPoint'], function) - - def _handle_lights(self, device, function, payload): + for sensor in self.ENV_SENSOR: + data = payload.get(sensor) + + if data and isinstance(data, dict): + self.logger.debug(f"Received Message decoded as {sensor} Sensor message.") + if sensor not in self.tasmota_devices[device]['sensors']: + self.tasmota_devices[device]['sensors'][sensor] = {} + + for key in self.ENV_SENSOR_KEYS: + if key in data: + self.tasmota_devices[device]['sensors'][sensor][key.lower()] = data[key] + self._set_item_value(device, self.ENV_SENSOR_KEYS[key], data[key], function) + + def _handle_sensor_analog(self, device: str, function: str, analog: dict): + """ + Handle Analog Sensor Information + :param device: + :param function: + :param analog: + """ + + if 'ANALOG' not in self.tasmota_devices[device]['sensors']: + self.tasmota_devices[device]['sensors']['ANALOG'] = {} + + for key in self.ANALOG_SENSOR_KEYS: + if key in analog: + self.tasmota_devices[device]['sensors']['ANALOG'][key.lower()] = analog[key] + self._set_item_value(device, self.ANALOG_SENSOR_KEYS[key], analog[key], function) + + def _handle_sensor_esp32(self, device: str, function: str, esp32: dict): + """ + Handle ESP32 Sensor Information + :param device: + :param function: + :param esp32: + """ + + if 'ESP32' not in self.tasmota_devices[device]['sensors']: + self.tasmota_devices[device]['sensors']['ESP32'] = {} + + for key in self.ESP32_SENSOR_KEYS: + if key in esp32: + self.tasmota_devices[device]['sensors']['ESP32'][key.lower()] = esp32[key] + self._set_item_value(device, self.ESP32_SENSOR_KEYS[key], esp32[key], function) + + def _handle_sensor_other(self, device: str, sensor: str, function: str, payload: dict): + """ + Handle Other Sensor Information + :param device: Tasmota Device + :param sensor: Sensor Device + :param function: Messages Information will be taken from + :param payload: dict with infos + """ + + self.logger.debug(f"Received Message decoded as {sensor} Sensor message with payload={payload}.") + + if sensor not in self.tasmota_devices[device]['sensors']: + self.tasmota_devices[device]['sensors'][sensor] = {} + + # Make all keys of SML-Device Payload Dict lowercase to match itemtype from parse_item + sensor_dict = {k.lower(): v for k, v in payload.items()} + + # Udpate des Sub-Dicts + self.tasmota_devices[device]['sensors'][sensor].update(sensor_dict) + + # Iterate over payload and set corresponding items + for element in sensor_dict: + itemtype = f"item_{sensor}.{element.lower()}" + value = sensor_dict[element] + self._set_item_value(device, itemtype, value, function) + + def _handle_lights(self, device: str, function: str, payload: dict) -> None: """ Extracts Light information out of payload and updates plugin dict :param device: Device, the Light information shall be handled (equals tasmota_topic) - :type device: str :param function: Function of Device (equals info_topic) - :type function: str :param payload: MQTT message payload - :type payload: dict - :return: + """ hsb = payload.get('HSBColor') if hsb: @@ -989,8 +1055,7 @@ def _handle_lights(self, device, function, payload): try: hsb = [int(element) for element in hsb] except Exception as e: - self.logger.info( - f"Received Data for HSBColor do not contain in values for HSB. Payload was {hsb}. Error was {e}.") + self.logger.info(f"Received Data for HSBColor do not contain in values for HSB. Payload was {hsb}. Error was {e}.") else: self.logger.info(f"Received Data for HSBColor do not contain values for HSB. Payload was {hsb}.") self.tasmota_devices[device]['lights']['hsb'] = hsb @@ -1031,290 +1096,628 @@ def _handle_lights(self, device, function, payload): if ledtable: self.tasmota_devices[device]['lights']['ledtable'] = bool(ledtable) - def _handle_power(self, device, function, payload): + def _handle_power(self, device: str, function: str, payload: dict) -> None: """ Extracts Power information out of payload and updates plugin dict :param device: Device, the Power information shall be handled (equals tasmota_topic) - :type device: str :param function: Function of Device (equals info_topic) - :type function: str :param payload: MQTT message payload - :type payload: dict - :return: + """ + # payload = {"Time": "2022-11-21T12:56:34", "Uptime": "0T00:00:11", "UptimeSec": 11, "Heap": 27, "SleepMode": "Dynamic", "Sleep": 50, "LoadAvg": 19, "MqttCount": 0, "POWER1": "OFF", "POWER2": "OFF", "POWER3": "OFF", "POWER4": "OFF", "Wifi": {"AP": 1, "SSId": "WLAN-Access", "BSSId": "38:10:D5:15:87:69", "Channel": 1, "Mode": "11n", "RSSI": 82, "Signal": -59, "LinkCount": 1, "Downtime": "0T00:00:03"}} + power_dict = {key: val for key, val in payload.items() if key.startswith('POWER')} self.tasmota_devices[device]['relais'].update(power_dict) for power in power_dict: - relay_index = str(power[5:]) - if relay_index == '': - relay_index = '1' - item_relay = 'item_relay' + relay_index + relay_index = 1 if len(power) == 5 else str(power[5:]) + item_relay = f'item_relay{relay_index}' self._set_item_value(device, item_relay, power_dict[power], function) - def _handle_module(self, device, payload): + def _handle_module(self, device: str, payload: dict) -> None: """ - Extracts Module information out of payload and updates plugin dict + Extracts Module information out of payload and updates plugin dict payload = {"0":"ZB-GW03-V1.3"}} :param device: Device, the Module information shall be handled - :type device: str :param payload: MQTT message payload - :type payload: dict - :return: - """ - module_list = payload.get('Module') - if module_list: - template, module = list(module_list.items())[0] - self.tasmota_devices[device]['module'] = module - self.tasmota_devices[device]['tasmota_template'] = template - - # Zigbee Bridge erkennen und Status setzen - if template == '75': - self.tasmota_zigbee_bridge['status'] = 'discovered' - self.tasmota_zigbee_bridge['device'] = device - self.logger.debug(f"_handle_module, ZigbeeBridge Status is: {self.tasmota_zigbee_bridge}") - - def _handle_zbstatus1(self, device, zbstatus1): - """ - Extracts ZigBee Status1 information out of payload and updates plugin dict - - :param device: Device, the Zigbee Status information shall be handled - :type device: str - :param zbstatus1: List of status information out out mqtt payload - :type zbstatus1: list - :return: - """ - # stat/SONOFF_ZB1/RESULT = {"ZbStatus1":[{"Device":"0x5A45","Name":"DJT11LM_01"},{"Device":"0x67FE","Name":"snzb-02_01"},{"Device":"0x892A","Name":"remote_mini_bl"},{"Device":"0x1FB1"}]} - if type(zbstatus1) is list: - for element in zbstatus1: - friendly_name = element.get('Name') - if friendly_name: - self.tasmota_zigbee_devices[friendly_name] = {} - else: - self.tasmota_zigbee_devices[element['Device']] = {} - # request detailed informatin of all discovered zigbee devices - self._poll_zigbee_devices(device) - else: - self.logger.debug( - f"ZbStatus1 with {zbstatus1} received but not processed. since data was not of type list.") - - def _handle_zbstatus23(self, device, zbstatus23): - """ - Extracts ZigBee Status 2 and 3 information out of payload and updates plugin dict - :param zbstatus23: ZbStatus2 or ZbStatus 3 part of MQTT message payload - :type zbstatus23: dict - :return: """ - # [{"Device":"0xD1B8","Name":"E1766_01","IEEEAddr":"0x588E81FFFE28DEC5","ModelId":"TRADFRIopen/closeremote","Manufacturer":"IKEA","Endpoints":[1],"Config":[]}]} - # [{'Device': '0x67FE', 'Name': 'snzb-02_01', 'IEEEAddr': '0x00124B00231E45B8', 'ModelId': 'TH01', 'Manufacturer': 'eWeLink', 'Endpoints': [1], 'Config': ['T01'], 'Temperature': 21.29, 'Humidity': 30.93, 'Reachable': True, 'BatteryPercentage': 100, 'LastSeen': 39, 'LastSeenEpoch': 1619350835, 'LinkQuality': 157}]} - # [{'Device': '0x9EFE', 'IEEEAddr': '0x00158D00067AA8BD', 'ModelId': 'lumi.vibration.aq1', 'Manufacturer': 'LUMI', 'Endpoints': [1, 2], 'Config': [], 'Reachable': True, 'BatteryPercentage': 100, 'LastSeen': 123, 'LastSeenEpoch': 1637134779, 'LinkQuality': 154}] - # [{'Device': '0x0A22', 'IEEEAddr': '0xF0D1B800001571C5', 'ModelId': 'CLA60 RGBW Z3', 'Manufacturer': 'LEDVANCE', 'Endpoints': [1], 'Config': ['L01', 'O01'], 'Dimmer': 128, 'Hue': 253, 'Sat': 250, 'X': 1, 'Y': 1, 'CT': 370, 'ColorMode': 0, 'RGB': 'FF0408', 'RGBb': '810204', 'Power': 1, 'Reachable': True, 'LastSeen': 11, 'LastSeenEpoch': 1638110831, 'LinkQuality': 18}] - - self.logger.debug(f'zbstatus23: {zbstatus23}') - if type(zbstatus23) is list: - for element in zbstatus23: - zigbee_device = element.get('Name') - if not zigbee_device: - zigbee_device = element.get('Device') - if zigbee_device in self.tasmota_zigbee_devices: - if not self.tasmota_zigbee_devices[zigbee_device].get('meta'): - self.tasmota_zigbee_devices[zigbee_device]['meta'] = {} - - # Korrektur des LastSeenEpoch von Timestamp zu datetime - if 'LastSeenEpoch' in element: - element.update({'LastSeenEpoch': datetime.fromtimestamp(element['LastSeenEpoch'] / 1000)}) - self.tasmota_zigbee_devices[zigbee_device]['meta'].update(element) - - # Übertragen der Werte aus der Statusmeldung in Data - bulb = ['Power', 'Dimmer', 'Hue', 'Sat', 'X', 'Y', 'CT', 'ColorMode'] - data = {} - for key in bulb: - x = element.get(key) - if x is not None: - data[key] = x - if data: - self.logger.debug(f"ZbStatus2 or ZbStatus3 received and Bulb detected. Data <{data}> extracted") - if not self.tasmota_zigbee_devices[zigbee_device].get('data'): - self.tasmota_zigbee_devices[zigbee_device]['data'] = {} - self.tasmota_zigbee_devices[zigbee_device]['data'].update(data) - - # Iterate over data and set corresponding items - self.logger.debug(f"Item to be checked for update based in Zigbee Status Message") - for entry in data: - itemtype = f"item_{zigbee_device}.{entry.lower()}" - value = data[entry] - self._set_item_value(device, itemtype, value, 'ZbStatus') - else: - self.logger.debug( - f"ZbStatus2 or ZbStatus3 with {zbstatus23} received but not processed. since data was not of type list.") + template = next(iter(payload)) + module = payload[template] + self.tasmota_devices[device]['module'] = module + self.tasmota_devices[device]['tasmota_template'] = template - def _handle_rf(self, device, function, payload): + def _handle_rf(self, device: str, function: str, payload: dict) -> None: """ Extracts RF information out of payload and updates plugin dict :param device: Device, the RF information shall be handled - :type device: str :param function: Function of Device (equals info_topic) - :type function: str :param payload: MQTT message payload - :type payload: dict - :return: + """ - rfreceived = payload.get('RfReceived') - if rfreceived: - self.logger.info(f"Received Message decoded as RF message.") - self.tasmota_devices[device]['rf']['rf_received'] = rfreceived - self._set_item_value(device, 'item_rf_recv', rfreceived['Data'], function) - if type(payload) is dict and ('RfSync' or 'RfLow' or 'RfHigh' or 'RfCode') in payload: - self.logger.info(f"Received Message decoded as RF message.") - if not self.tasmota_devices[device]['rf'].get('rf_send_result'): - self.tasmota_devices[device]['rf']['rf_send_result'] = payload - else: - self.tasmota_devices[device]['rf']['rf_send_result'].update(payload) - if any(item.startswith("RfKey") for item in payload.keys()): - self.logger.info(f"Received Message decoded as RF message.") - self.tasmota_devices[device]['rf']['rfkey_result'] = payload - def _handle_zbconfig(self, device, payload): + # payload = {'Sync': 10120, 'Low': 330, 'High': 980, 'Data': '3602B8', 'RfKey': 'None'} + + self.logger.info(f"Received Message decoded as RF message.") + self.tasmota_devices[device]['rf']['rf_received'] = payload + self._set_item_value(device, 'item_rf_recv', payload['Data'], function) + + rf_key = 0 if payload["RfKey"] == 'None' else int(payload["RfKey"]) + self._set_item_value(device, 'item_rf_key_recv', rf_key, function) + self._set_item_value(device, f'item_rf_key{rf_key}', True, function) + + def _handle_zbconfig(self, device: str, payload: dict) -> None: """ Extracts ZigBee Config information out of payload and updates plugin dict :param device: Device, the Zigbee Config information shall be handled - :type device: str :param payload: MQTT message payload - :type payload: dict - :return: + """ # stat/SONOFF_ZB1/RESULT = {"ZbConfig":{"Channel":11,"PanID":"0x0C84","ExtPanID":"0xCCCCCCCCAAA8CC84","KeyL":"0xAAA8CC841B1F40A1","KeyH":"0xAAA8CC841B1F40A1","TxRadio":20}} - zbconfig = payload.get('ZbConfig') - if zbconfig: - self.tasmota_devices[device]['zigbee']['zbconfig'] = payload + self.tasmota_devices[device]['zigbee']['zbconfig'] = payload - def _handle_zbstatus(self, device, payload): + def _handle_zbstatus(self, device: str, payload: dict) -> None: """ Extracts ZigBee Status information out of payload and updates plugin dict :param device: Device, the Zigbee Status information shall be handled - :type device: str :param payload: MQTT message payload - :type payload: dict - :return: + """ + zbstatus1 = payload.get('ZbStatus1') if zbstatus1: - self.logger.info(f"Received Message decoded as Zigbee ZbStatus1 message.") + self.logger.info(f"Received Message decoded as Zigbee ZbStatus1 message for device {device}.") self._handle_zbstatus1(device, zbstatus1) + zbstatus23 = payload.get('ZbStatus2') if not zbstatus23: zbstatus23 = payload.get('ZbStatus3') + if zbstatus23: - self.logger.info(f"Received Message decoded as Zigbee ZbStatus2 or ZbStatus3 message.") + self.logger.info(f"Received Message decoded as Zigbee ZbStatus2 or ZbStatus3 message for device {device}.") self._handle_zbstatus23(device, zbstatus23) - def _handle_wifi(self, device, payload): + def _handle_zbstatus1(self, device: str, zbstatus1: list) -> None: """ - Extracts Wifi information out of payload and updates plugin dict + Extracts ZigBee Status1 information out of payload and updates plugin dict :param device: Device, the Zigbee Status information shall be handled - :type device: str - :param payload: MQTT message payload - :type payload: dict - :return: + :param zbstatus1: List of status information out mqtt payload + + """ """ - wifi_signal = payload['Wifi'].get('Signal') + zbstatus1 = [{'Device': '0x676D', 'Name': 'SNZB-02_01'}, + {'Device': '0xD4F3', 'Name': 'Fenster_01'} + ] + """ + + for element in zbstatus1: + zigbee_device = element.get('Name') + if not zigbee_device: + zigbee_device = element['Device'] + + if zigbee_device != '0x0000' and zigbee_device not in self.tasmota_zigbee_devices: + self.logger.info(f"New Zigbee Device '{zigbee_device}'based on 'ZbStatus1'-Message from {device} discovered") + self.tasmota_zigbee_devices[zigbee_device] = {} + # request detailed information of all discovered zigbee devices + self._poll_zigbee_devices(device) + + def _handle_zbstatus23(self, device: str, zbstatus23: dict) -> None: + """ + Extracts ZigBee Status 2 and 3 information out of payload and updates plugin dict + + :param device: Device, the Zigbee Status information shall be handled + :param zbstatus23: ZbStatus2 or ZbStatus 3 part of MQTT message payload + + """ + + """ + zbstatus23 = [{'Device': '0xD4F3', 'Name': 'Fenster_01', 'IEEEAddr': '0x00158D0007005B59', + 'ModelId': 'lumi.sensor_magnet.aq2', 'Manufacturer': 'LUMI', 'Endpoints': [1], + 'Config': ['A01'], 'ZoneStatus': 29697, 'Reachable': True, 'BatteryPercentage': 100, + 'BatteryLastSeenEpoch': 1668953504, 'LastSeen': 238, 'LastSeenEpoch': 1668953504, + 'LinkQuality': 81}] + + zbstatus23 = [{'Device': '0x676D', 'Name': 'SNZB-02_01', 'IEEEAddr': '0x00124B00231E45B8', + 'ModelId': 'TH01', 'Manufacturer': 'eWeLink', 'Endpoints': [1], 'Config': ['T01'], + 'Temperature': 19.27, 'Humidity': 58.12, 'Reachable': True, 'BatteryPercentage': 73, + 'BatteryLastSeenEpoch': 1668953064, 'LastSeen': 610, 'LastSeenEpoch': 1668953064, 'LinkQuality': 66}] + + zbstatus23 = [{'Device': '0x0A22', 'IEEEAddr': '0xF0D1B800001571C5', 'ModelId': 'CLA60 RGBW Z3', + 'Manufacturer': 'LEDVANCE', 'Endpoints': [1], 'Config': ['L01', 'O01'], 'Dimmer': 100, + 'Hue': 200, 'Sat': 254, 'X': 1, 'Y': 1, 'CT': 350, 'ColorMode': 0, 'RGB': 'B600FF', + 'RGBb': '480064', 'Power': 1, 'Reachable': False, 'LastSeen': 30837743, + 'LastSeenEpoch': 1638132192, 'LinkQuality': 13}] + """ + + for element in zbstatus23: + zigbee_device = element.get('Name') + if not zigbee_device: + zigbee_device = element['Device'] + + payload = dict() + payload[zigbee_device] = element + + self._handle_sensor_zigbee(device, 'ZbStatus', payload) + + def _handle_wifi(self, device: str, payload: dict) -> None: + """ + Extracts Wi-Fi information out of payload and updates plugin dict + + :param device: Device, the Zigbee Status information shall be handled + :param payload: MQTT message payload + + """ + self.logger.debug(f"_handle_wifi: received payload={payload}") + wifi_signal = payload.get('Signal') if wifi_signal: if isinstance(wifi_signal, str) and wifi_signal.isdigit(): wifi_signal = int(wifi_signal) - self.logger.info(f"Received Message decoded as Wifi message.") self.tasmota_devices[device]['wifi_signal'] = wifi_signal - def _handle_zbbridge_setting(self, payload): + def _handle_setting(self, device: str, payload: dict) -> None: """ Extracts Zigbee Bridge Setting information out of payload and updates dict + :param device: + :param payload: MQTT message payload + """ - :param payload: MQTT message payload - :type payload: dict - :return: + # handle Setting listed in Zigbee Bridge Settings (wenn erster Key des Payload-Dict in Zigbee_Bridge_Default_Setting...) + if next(iter(payload)) in self.ZIGBEE_BRIDGE_DEFAULT_OPTIONS: + if not self.tasmota_devices[device]['zigbee'].get('setting'): + self.tasmota_devices[device]['zigbee']['setting'] = {} + self.tasmota_devices[device]['zigbee']['setting'].update(payload) + + if self.tasmota_devices[device]['zigbee']['setting'] == self.ZIGBEE_BRIDGE_DEFAULT_OPTIONS: + self.tasmota_devices[device]['zigbee']['status'] = 'set' + self.logger.info(f'_handle_setting: Setting of Tasmota Zigbee Bridge successful.') + + def _handle_teleperiod(self, tasmota_topic: str, teleperiod: dict) -> None: + + self.tasmota_devices[tasmota_topic]['teleperiod'] = teleperiod + if teleperiod != self.telemetry_period: + self._set_telemetry_period(tasmota_topic) + + def _handle_uptime(self, tasmota_topic: str, uptime: str) -> None: + self.logger.debug(f"Received Message contains Uptime information. uptime={uptime}") + self.tasmota_devices[tasmota_topic]['uptime'] = uptime + + def _handle_uptime_sec(self, tasmota_topic: str, uptime_sec: int) -> None: + self.logger.debug(f"Received Message contains UptimeSec information. uptime={uptime_sec}") + self.tasmota_devices[tasmota_topic]['uptime_sec'] = int(uptime_sec) + + ############################################################ + # MQTT Settings & Config + ############################################################ + + def add_tasmota_subscriptions(self): + self.logger.info(f"Further tasmota_subscriptions for regular/cyclic messages will be added") + + self.add_tasmota_subscription('tele', '+', 'STATE', 'dict', callback=self.on_mqtt_message) + self.add_tasmota_subscription('tele', '+', 'SENSOR', 'dict', callback=self.on_mqtt_message) + self.add_tasmota_subscription('tele', '+', 'RESULT', 'dict', callback=self.on_mqtt_message) + # self.add_tasmota_subscription('tele', '+', 'INFO1', 'dict', callback=self.on_mqtt_message) + # self.add_tasmota_subscription('tele', '+', 'INFO2', 'dict', callback=self.on_mqtt_message) + self.add_tasmota_subscription('tele', '+', 'INFO3', 'dict', callback=self.on_mqtt_info_message) + self.add_tasmota_subscription('stat', '+', 'POWER', 'num', callback=self.on_mqtt_power_message) + self.add_tasmota_subscription('stat', '+', 'POWER1', 'num', callback=self.on_mqtt_power_message) + self.add_tasmota_subscription('stat', '+', 'POWER2', 'num', callback=self.on_mqtt_power_message) + self.add_tasmota_subscription('stat', '+', 'POWER3', 'num', callback=self.on_mqtt_power_message) + self.add_tasmota_subscription('stat', '+', 'POWER4', 'num', callback=self.on_mqtt_power_message) + + def check_online_status(self): """ - if not self.tasmota_zigbee_bridge.get('setting'): - self.tasmota_zigbee_bridge['setting'] = {} - self.tasmota_zigbee_bridge['setting'].update(payload) + checks all tasmota topics, if last message is with telemetry period. If not set tasmota_topic offline - if self.tasmota_zigbee_bridge['setting'] == self.tasmota_zigbee_bridge_stetting: - self.tasmota_zigbee_bridge['status'] = 'set' - self.logger.info(f'_handle_zbbridge_setting: Setting of Tasmota Zigbee Bridge successful.') + """ - def _update_tasmota_meta(self): + self.logger.info("check_online_status: Checking online status of connected devices") + for tasmota_topic in self.tasmota_devices: + if self.tasmota_devices[tasmota_topic].get('online') is True and self.tasmota_devices[tasmota_topic].get('online_timeout'): + if self.tasmota_devices[tasmota_topic]['online_timeout'] < datetime.now(): + self._set_device_offline(tasmota_topic) + else: + self.logger.debug(f'check_online_status: Checking online status of {tasmota_topic} successful') + + def add_tasmota_subscription(self, prefix: str, topic: str, detail: str, payload_type: str, bool_values: list = None, item=None, callback=None) -> None: """ - Updates the tasmota meta information in plugin dict + build the topic in Tasmota style and add the subscription to mqtt + + :param prefix: prefix of topic to subscribe to + :param topic: unique part of topic to subscribe to + :param detail: detail of topic to subscribe to + :param payload_type: payload type of the topic (for this subscription to the topic) + :param bool_values: bool values (for this subscription to the topic) + :param item: item that should receive the payload as value. Used by the standard handler (if no callback function is specified) + :param callback: a plugin can provide an own callback function, if special handling of the payload is needed + """ - self.tasmota_meta = {} - for tasmota_topic in self.tasmota_devices: - if self.tasmota_devices[tasmota_topic]['relais']: - self.tasmota_meta['relais'] = True - if self.tasmota_devices[tasmota_topic]['rf']: - self.tasmota_meta['rf'] = True - if self.tasmota_devices[tasmota_topic]['lights']: - self.tasmota_meta['lights'] = True - if self.tasmota_devices[tasmota_topic]['sensors'].get('DS18B20'): - self.tasmota_meta['ds18b20'] = True - if self.tasmota_devices[tasmota_topic]['sensors'].get('AM2301'): - self.tasmota_meta['am2301'] = True - if self.tasmota_devices[tasmota_topic]['sensors'].get('ENERGY'): - self.tasmota_meta['energy'] = True - if self.tasmota_devices[tasmota_topic]['zigbee']: - self.tasmota_meta['zigbee'] = True - def _poll_zigbee_devices(self, device): + tpc = self.full_topic.replace("%prefix%", prefix) + tpc = tpc.replace("%topic%", topic) + tpc += detail + self.add_subscription(tpc, payload_type, bool_values=bool_values, callback=callback) + + def publish_tasmota_topic(self, prefix: str, topic: str, detail: str, payload, item=None, qos: int = None, retain: bool = False, bool_values: list = None) -> None: + """ + build the topic in Tasmota style and publish to mqtt + + :param prefix: prefix of topic to publish + :param topic: unique part of topic to publish + :param detail: detail of topic to publish + :param payload: payload to publish + :param item: item (if relevant) + :param qos: qos for this message (optional) + :param retain: retain flag for this message (optional) + :param bool_values: bool values (for publishing this topic, optional) + + """ + tpc = self.full_topic.replace("%prefix%", prefix) + tpc = tpc.replace("%topic%", topic) + tpc += detail + + self.publish_topic(tpc, payload, item, qos, retain, bool_values) + + def interview_all_devices(self): + + """ + Interview known Tasmota Devices (defined in item.yaml and self discovered) + """ + + self.logger.info(f"Interview of all known tasmota devices started.") + + tasmota_device_list = list(set(list(self.tasmota_device + self.discovered_device))) + + for device in tasmota_device_list: + self.logger.debug(f"Interview {device}.") + self._interview_device(device) + self.logger.debug(f"Set Telemetry period for {device}.") + self._set_telemetry_period(device) + + def clear_retained_messages(self, retained_msg=None): + """ + Method to clear all retained messages + """ + + if not retained_msg: + retained_msg = self.topics_of_retained_messages + + for topic in retained_msg: + try: + self.logger.warning(f"Clearing retained message for topic={topic}") + self.publish_topic(topic=topic, payload="", retain=True) + except Exception as e: + self.logger.warning(f"Clearing retained message for topic={topic}, caused error {e}") + pass + + def _interview_device(self, topic: str) -> None: + """ + ask for status info of each known tasmota_topic + + :param topic: tasmota Topic + """ + + # self.logger.debug(f"run: publishing 'cmnd/{topic}/Status0'") + self.publish_tasmota_topic('cmnd', topic, 'Status0', '') + + # self.logger.debug(f"run: publishing 'cmnd/{topic}/State'") + # self.publish_tasmota_topic('cmnd', topic, 'State', '') + + # self.logger.debug(f"run: publishing 'cmnd/{topic}/Module'") + # self.publish_tasmota_topic('cmnd', topic, 'Module', '') + + def _set_telemetry_period(self, topic: str) -> None: + """ + sets telemetry period for given topic/device + + :param topic: tasmota Topic + """ + + self.logger.info(f"run: Setting telemetry period to {self.telemetry_period} seconds") + self.publish_tasmota_topic('cmnd', topic, 'teleperiod', self.telemetry_period) + + ############################################################ + # Helper + ############################################################ + + def _set_item_value(self, tasmota_topic: str, itemtype: str, value, info_topic: str = '') -> None: + """ + Sets item value + + :param tasmota_topic: MQTT message payload + :param itemtype: itemtype to be set + :param value: value to be set + :param info_topic: MQTT info_topic + """ + + if tasmota_topic in self.tasmota_devices: + + # create source of item value + src = f"{tasmota_topic}:{info_topic}" if info_topic != '' else f"{tasmota_topic}" + + if itemtype in self.tasmota_devices[tasmota_topic]['connected_items']: + # get item to be set + item = self.tasmota_devices[tasmota_topic]['connected_items'][itemtype] + + tasmota_rf_details = self.get_iattr_value(item.conf, 'tasmota_rf_key') + if tasmota_rf_details and '=' in tasmota_rf_details: + tasmota_rf_key, tasmota_rf_key_param = tasmota_rf_details.split('=') + + if tasmota_rf_key_param.lower() == 'true': + value = True + elif tasmota_rf_key_param.lower() == 'false': + value = True + elif tasmota_rf_key_param.lower() == 'toggle': + value = not(item()) + else: + self.logger.warning(f"Paramater of tasmota_rf_key unknown, Need to be True, False, Toggle") + return + + # set item value + self.logger.info(f"{tasmota_topic}: Item '{item.id()}' via itemtype '{itemtype}' set to value '{value}' provided by '{src}'.") + item(value, self.get_shortname(), src) + + else: + self.logger.debug(f"{tasmota_topic}: No item for itemtype '{itemtype}' defined to set to '{value}' provided by '{src}'.") + else: + self.logger.debug(f"{tasmota_topic} unknown.") + + def _handle_new_discovered_device(self, tasmota_topic): + + self._add_new_device_to_tasmota_devices(tasmota_topic) + self.tasmota_devices[tasmota_topic]['status'] = 'discovered' + self._interview_device(tasmota_topic) + + def _add_new_device_to_tasmota_devices(self, tasmota_topic): + self.tasmota_devices[tasmota_topic] = self._get_device_dict_1_template() + self.tasmota_devices[tasmota_topic].update(self._get_device_dict_2_template()) + + def _set_device_offline(self, tasmota_topic): + + self.tasmota_devices[tasmota_topic]['online'] = False + self._set_item_value(tasmota_topic, 'item_online', False, 'check_online_status') + self.logger.info(f"{tasmota_topic} is not online any more - online_timeout={self.tasmota_devices[tasmota_topic]['online_timeout']}, now={datetime.now()}") + + # clean data from dict to show correct status + self.tasmota_devices[tasmota_topic].update(self._get_device_dict_2_template()) + + @staticmethod + def _rename_discovery_keys(payload: dict) -> dict: + + link = {'ip': 'IP', + 'dn': 'DeviceName', + 'fn': 'FriendlyNames', # list + 'hn': 'HostName', + 'mac': 'MAC', + 'md': 'Module', + 'ty': 'Tuya', + 'if': 'ifan', + 'ofln': 'LWT-offline', + 'onln': 'LWT-online', + 'state': 'StateText', # [0..3] + 'sw': 'FirmwareVersion', + 't': 'Topic', + 'ft': 'FullTopic', + 'tp': 'Prefix', + 'rl': 'Relays', # 0: disabled, 1: relay, 2.. future extension (fan, shutter?) + 'swc': 'SwitchMode', + 'swn': 'SwitchName', + 'btn': 'Buttons', + 'so': 'SetOption', # needed by HA to map Tasmota devices to HA entities and triggers + 'lk': 'ctrgb', + 'lt_st': 'LightSubtype', + 'sho': 'sho', + 'sht': 'sht', + 'ver': 'ProtocolVersion', + } + + new_payload = {} + for k_old in payload: + k_new = link.get(k_old) + if k_new: + new_payload[k_new] = payload[k_old] + + return new_payload + + @staticmethod + def _get_device_dict_1_template(): + return {'connected_to_item': False, + 'online': False, + 'status': None, + 'connected_items': {}, + 'uptime': '-', + } + + @staticmethod + def _get_device_dict_2_template(): + return {'lights': {}, + 'rf': {}, + 'sensors': {}, + 'relais': {}, + 'zigbee': {}, + 'sml': {}, + } + + ############################################################ + # Zigbee + ############################################################ + + def _poll_zigbee_devices(self, device: str) -> None: """ Polls information of all discovered zigbee devices from dedicated Zigbee bridge :param device: Zigbee bridge, where all Zigbee Devices shall be polled (equal to tasmota_topic) - :type device: str - :return: + """ - self.logger.info("_poll_zigbee_devices: Polling informatiopn of all discovered Zigbee devices") + self.logger.info(f"_poll_zigbee_devices: Polling information of all discovered Zigbee devices for zigbee_bridge {device}") for zigbee_device in self.tasmota_zigbee_devices: - self.logger.debug(f"_poll_zigbee_devices: publishing 'cmnd/{device}/ZbStatus3 {zigbee_device}'") + # self.logger.debug(f"_poll_zigbee_devices: publishing 'cmnd/{device}/ZbStatus3 {zigbee_device}'") self.publish_tasmota_topic('cmnd', device, 'ZbStatus3', zigbee_device) - def _discover_zigbee_bridge(self, device): + def _configure_zigbee_bridge_settings(self, device: str) -> None: """ - Configures and discovers Zigbee Bridge and all connected zigbee devices + Configures Zigbee Bridge settings - :param device: Zigbee bridge to be discovered (equal to tasmota_topic) - :type device: str - :return: None + :param device: Zigbee bridge to be set to get MQTT Messages in right format") """ - self.logger.info( - "Zigbee Bridge discovered: Prepare Settings and polling information of all connected zigbee devices") - # Configure ZigBeeBridge - self.logger.debug(f"Configuration of Tasmota Zigbee Bridge to get MQTT Messages in right format") - for setting in self.tasmota_zigbee_bridge_stetting: - self.publish_tasmota_topic('cmnd', device, setting, self.tasmota_zigbee_bridge_stetting[setting]) - self.logger.debug( - f"_discover_zigbee_bridge: publishing to 'cmnd/{device}/setting' with payload {self.tasmota_zigbee_bridge_stetting[setting]}") + self.logger.info(f"_configure_zigbee_bridge_settings: Do settings of ZigbeeBridge {device}") + bridge_setting_backlog = '; '.join(f"{key} {value}" for key, value in self.ZIGBEE_BRIDGE_DEFAULT_OPTIONS.items()) + self.publish_tasmota_topic('cmnd', device, 'Backlog', bridge_setting_backlog) + + def _request_zigbee_bridge_config(self, device: str) -> None: + """ + Request Zigbee Bridge configuration - # Request ZigBee Konfiguration - self.logger.info("_discover_zigbee_bridge: Request configuration of Zigbee bridge") - self.logger.debug(f"_discover_zigbee_bridge: publishing 'cmnd/{device}/ZbConfig'") + :param device: Zigbee bridge to be requested (equal to tasmota_topic) + """ + + self.logger.info(f"_request_zigbee_bridge_config: Request configuration of Zigbee bridge {device}") + # self.logger.debug(f"_discover_zigbee_bridge: publishing 'cmnd/{device}/ZbConfig'") self.publish_tasmota_topic('cmnd', device, 'ZbConfig', '') - # Discovery all ZigBee Devices - self.logger.info("_discover_zigbee_bridge: Discover all connected Zigbee devices") - self.logger.debug(f"_discover_zigbee_bridge: publishing 'cmnd/{device}/ZbStatus1'") + def _discover_zigbee_bridge_devices(self, device: str) -> None: + """ + Discovers all connected Zigbee devices + + :param device: Zigbee bridge where connected devices shall be discovered (equal to tasmota_topic) + """ + + self.logger.info(f"_discover_zigbee_bridge_devices: Discover all connected Zigbee devices for ZigbeeBridge {device}") self.publish_tasmota_topic('cmnd', device, 'ZbStatus1', '') - def _identify_device(self, topic): - # ask for status info of each known tasmota_topic, collected during parse_item - self.logger.debug(f"run: publishing 'cmnd/{topic}/Status0'") - self.publish_tasmota_topic('cmnd', topic, 'Status0', '') + def _handle_retained_message(self, topic: str, retain: bool) -> None: + """ + check for retained message and handle it + + :param topic: + :param retain: + """ + + if bool(retain): + if topic not in self.topics_of_retained_messages: + self.topics_of_retained_messages.append(topic) + else: + if topic in self.topics_of_retained_messages: + self.topics_of_retained_messages.remove(topic) + + ############################################################ + # Plugin Properties + ############################################################ + + @property + def log_level(self): + return self.logger.getEffectiveLevel() + + @property + def retained_msg_count(self): + return self._broker.retained_messages + + @property + def tasmota_device(self): + return list(self.tasmota_devices.keys()) + + @property + def has_zigbee(self): + for tasmota_topic in self.tasmota_devices: + if self.tasmota_devices[tasmota_topic]['zigbee']: + return True + return False + + @property + def has_lights(self): + for tasmota_topic in self.tasmota_devices: + if self.tasmota_devices[tasmota_topic]['lights']: + return True + return False + + @property + def has_rf(self): + for tasmota_topic in self.tasmota_devices: + if self.tasmota_devices[tasmota_topic]['rf']: + return True + return False + + @property + def has_relais(self): + for tasmota_topic in self.tasmota_devices: + if self.tasmota_devices[tasmota_topic]['relais']: + return True + return False + + @property + def has_energy_sensor(self): + for tasmota_topic in self.tasmota_devices: + if 'ENERGY' in self.tasmota_devices[tasmota_topic]['sensors']: + return True + return False + + @property + def has_env_sensor(self): + for tasmota_topic in self.tasmota_devices: + if any([i in self.tasmota_devices[tasmota_topic]['sensors'] for i in self.ENV_SENSOR]): + return True + return False + + @property + def has_ds18b20_sensor(self): + for tasmota_topic in self.tasmota_devices: + if 'DS18B20' in self.tasmota_devices[tasmota_topic]['sensors']: + return True + return False + + @property + def has_am2301_sensor(self): + for tasmota_topic in self.tasmota_devices: + if 'AM2301' in self.tasmota_devices[tasmota_topic]['sensors']: + return True + return False + + @property + def has_sht3x_sensor(self): + for tasmota_topic in self.tasmota_devices: + if 'SHT3X' in self.tasmota_devices[tasmota_topic]['sensors']: + return True + return False + + @property + def has_other_sensor(self): + for tasmota_topic in self.tasmota_devices: + for sensor in self.tasmota_devices[tasmota_topic]['sensors']: + if sensor not in self.SENSORS: + return True + return False + +################################################################## +# Utilities +################################################################## + + +def _254_to_100(value): + return int(round(value * 100 / 254, 0)) + + +def _254_to_360(value): + return int(round(value * 360 / 254, 0)) + + +def _100_to_254(value): + return int(round(value * 254 / 100, 0)) + + +def _360_to_254(value): + return int(round(value * 254 / 360, 0)) + + +def _kelvin_to_mired(value): + """Umrechnung der Farbtemperatur von Kelvin auf "mired scale" (Reziproke Megakelvin)""" + return int(round(1000000 / value, 0)) - self.logger.debug(f"run: publishing 'cmnd/{topic}/State'") - self.publish_tasmota_topic('cmnd', topic, 'State', '') - self.logger.debug(f"run: publishing 'cmnd/{topic}/Module'") - self.publish_tasmota_topic('cmnd', topic, 'Module', '') +def _mired_to_kelvin(value): + """Umrechnung der Farbtemperatur von "mired scale" (Reziproke Megakelvin) auf Kelvin""" + return int(round(10000 / int(value), 0)) * 100 diff --git a/tasmota/_pv_1_2_2/__init__.py b/tasmota/_pv_1_2_2/__init__.py new file mode 100755 index 000000000..f43c71de2 --- /dev/null +++ b/tasmota/_pv_1_2_2/__init__.py @@ -0,0 +1,1320 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2020- Martin Sinn m.sinn@gmx.de +######################################################################### +# This file is part of SmartHomeNG. +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.4 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +from datetime import datetime, timedelta +import time + +from lib.module import Modules +from lib.model.mqttplugin import * +from lib.item import Items + +from .webif import WebInterface + + +class Tasmota(MqttPlugin): + """ + Main class of the Plugin. Does all plugin specific stuff and provides + the update functions for the items + """ + + PLUGIN_VERSION = '1.2.2' + + def __init__(self, sh): + """ + Initalizes the plugin. + + :param sh: **Deprecated**: The instance of the smarthome object. For SmartHomeNG versions 1.4 and up: **Don't use it**! + """ + + # Call init code of parent class (MqttPlugin) + super().__init__() + if not self._init_complete: + return + + # get the parameters for the plugin (as defined in metadata plugin.yaml): + self.webif_pagelength = self.get_parameter_value('webif_pagelength') + self.telemetry_period = self.get_parameter_value('telemetry_period') + self._cycle = self.telemetry_period + + # crate full_topic + self.full_topic = self.get_parameter_value('full_topic').lower() + if self.full_topic.find('%prefix%') == -1 or self.full_topic.find('%topic%') == -1: + self.full_topic = '%prefix%/%topic%/' + if self.full_topic[-1] != '/': + self.full_topic += '/' + + # Define properties + self.tasmota_devices = {} # to hold tasmota device information for web interface + self.tasmota_zigbee_devices = {} # to hold tasmota zigbee device information for web interface + self.tasmota_items = [] # to hold item information for web interface + self.tasmota_meta = {} # to hold meta information for web interface + self.tasmota_zigbee_bridge = {} # to hold tasmota zigbee bridge status + self.alive = None + self.discovered_devices = [] + self.tasmota_zigbee_bridge_stetting = {'SetOption89': 'OFF', # SetOption89 Configure MQTT topic for Zigbee devices (also see SensorRetain); 0 = single tele/%topic%/SENSOR topic (default), 1 = unique device topic based on Zigbee device ShortAddr, Example: tele/Zigbee/5ADF/SENSOR = {"ZbReceived":{"0x5ADF":{"Dimmer":254,"Endpoint":1,"LinkQuality":70}}} + 'SetOption83': 'ON', # SetOption83 Uses Zigbee device friendly name instead of 16 bits short addresses as JSON key when reporting values and commands; 0 = JSON key as short address, 1 = JSON key as friendly name + 'SetOption100': 'ON', # SetOption100 Remove Zigbee ZbReceived value from {"ZbReceived":{xxx:yyy}} JSON message; 0 = disable (default), 1 = enable + 'SetOption125': 'ON', # SetOption125 ZbBridge only Hide bridge topic from zigbee topic (use with SetOption89) 1 = enable + 'SetOption118': 'ON', # SetOption118 Move ZbReceived from JSON message into the subtopic replacing "SENSOR" default; 0 = disable (default); 1 = enable + 'SetOption112': 'ON', # SetOption112 0 = (default); 1 = use friendly name in Zigbee topic (use with ZbDeviceTopic) + 'SetOption119': 'OFF' # SetOption119 Remove device addr from JSON payload; 0 = disable (default); 1 = enable + } + + # Add subscription to get device discovery + self.add_tasmota_subscription('tasmota', 'discovery', '#', 'dict', callback=self.on_discovery) + + # Add subscription to get device announces + self.add_tasmota_subscription('tele', '+', 'LWT', 'bool', bool_values=['Offline', 'Online'], + callback=self.on_mqtt_announce) + self.add_tasmota_subscription('tele', '+', 'STATE', 'dict', callback=self.on_mqtt_announce) + self.add_tasmota_subscription('tele', '+', 'SENSOR', 'dict', callback=self.on_mqtt_announce) + self.add_tasmota_subscription('tele', '+', 'INFO1', 'dict', callback=self.on_mqtt_announce) + self.add_tasmota_subscription('tele', '+', 'INFO2', 'dict', callback=self.on_mqtt_announce) + self.add_tasmota_subscription('tele', '+', 'INFO3', 'dict', callback=self.on_mqtt_announce) + self.add_tasmota_subscription('tele', '+', 'RESULT', 'dict', callback=self.on_mqtt_announce) + self.add_tasmota_subscription('tele', '+', 'ZbReceived', 'dict', callback=self.on_mqtt_announce) + self.add_tasmota_subscription('stat', '+', 'STATUS0', 'dict', callback=self.on_mqtt_announce) + self.add_tasmota_subscription('stat', '+', 'RESULT', 'dict', callback=self.on_mqtt_announce) + self.add_tasmota_subscription('stat', '+', 'POWER', 'num', callback=self.on_mqtt_message) + self.add_tasmota_subscription('stat', '+', 'POWER1', 'num', callback=self.on_mqtt_message) + self.add_tasmota_subscription('stat', '+', 'POWER2', 'num', callback=self.on_mqtt_message) + self.add_tasmota_subscription('stat', '+', 'POWER3', 'num', callback=self.on_mqtt_message) + self.add_tasmota_subscription('stat', '+', 'POWER4', 'num', callback=self.on_mqtt_message) + + # Init WebIF + self.init_webinterface(WebInterface) + return + + def run(self): + """ + Run method for the plugin + """ + self.logger.debug("Run method called") + + # start subscription to all defined topics + self.start_subscriptions() + + # wait 1 seconds to receive and handle retained messages for device discovery + time.sleep(1) + + # Interview known Tasmota Devices (definied in item.yaml and self discovered) + key_list = list(set(list(self.tasmota_devices.keys()) + self.discovered_devices)) + for topic in key_list: + # ask for status info of each known tasmota_topic, collected during parse_item + self._identify_device(topic) + + # set telemetry period for each known tasmota_topic, collected during parse_item + self.logger.info(f"run: Setting telemetry period to {self.telemetry_period} seconds") + self.logger.debug(f"run: publishing 'cmnd/{topic}/teleperiod'") + self.publish_tasmota_topic('cmnd', topic, 'teleperiod', self.telemetry_period) + + # Update tasmota_meta auf Basis von tasmota_devices + self._update_tasmota_meta() + + self.scheduler_add('poll_device', self.poll_device, cycle=self._cycle) + self.alive = True + + def stop(self): + """ + Stop method for the plugin + """ + self.alive = False + self.logger.debug("Stop method called") + self.scheduler_remove('poll_device') + + # stop subscription to all topics + self.stop_subscriptions() + + def parse_item(self, item): + """ + Default plugin parse_item method. Is called when the plugin is initialized. + The plugin can, corresponding to its attribute keywords, decide what to do with + the item in future, like adding it to an internal array for future reference + :param item: The item to process. + :type item: Item + :return: If the plugin needs to be informed of an items change you should return a call back function + like the function update_item down below. An example when this is needed is the knx plugin + where parse_item returns the update_item function when the attribute knx_send is found. + This means that when the items value is about to be updated, the call back function is called + with the item, caller, source and dest as arguments and in case of the knx plugin the value + can be sent to the knx with a knx write function within the knx plugin. + """ + if self.has_iattr(item.conf, 'tasmota_topic'): + self.logger.debug(f"parsing item: {item.id()}") + + tasmota_topic = self.get_iattr_value(item.conf, 'tasmota_topic') + tasmota_attr = self.get_iattr_value(item.conf, 'tasmota_attr') + tasmota_relay = self.get_iattr_value(item.conf, 'tasmota_relay') + tasmota_zb_device = self.get_iattr_value(item.conf, 'tasmota_zb_device') + if tasmota_zb_device is not None: + # check if zigbee device short name has been used without parentheses; if so this will be normally parsed to a number and therefore mismatch with defintion + try: + tasmota_zb_device = int(tasmota_zb_device) + self.logger.warning( + f"Probably for item {item.id()} the device short name as been used for attribute 'tasmota_zb_device'. Trying to make that work but it will cause exceptions. To prevent this, the short name need to be defined as string by using parentheses") + tasmota_zb_device = str(hex(tasmota_zb_device)) + tasmota_zb_device = tasmota_zb_device[0:2] + tasmota_zb_device[2:len(tasmota_zb_device)].upper() + except Exception: + pass + tasmota_zb_attr = str(self.get_iattr_value(item.conf, 'tasmota_zb_attr')).lower() + + if not tasmota_relay: + tasmota_relay = '1' + # self.logger.debug(f" - tasmota_topic={tasmota_topic}, tasmota_attr={tasmota_attr}, tasmota_relay={tasmota_relay}") + # self.logger.debug(f" - tasmota_topic={tasmota_topic}, item.conf={item.conf}") + + if not self.tasmota_devices.get(tasmota_topic): + self.tasmota_devices[tasmota_topic] = {} + self.tasmota_devices[tasmota_topic]['connected_to_item'] = False + self.tasmota_devices[tasmota_topic]['connected_items'] = {} + self.tasmota_devices[tasmota_topic]['uptime'] = '-' + self.tasmota_devices[tasmota_topic]['lights'] = {} + self.tasmota_devices[tasmota_topic]['rf'] = {} + self.tasmota_devices[tasmota_topic]['sensors'] = {} + self.tasmota_devices[tasmota_topic]['relais'] = {} + self.tasmota_devices[tasmota_topic]['zigbee'] = {} + + # handle the different topics from Tasmota devices + if tasmota_attr: + tasmota_attr = tasmota_attr.lower() + + self.tasmota_devices[tasmota_topic]['connected_to_item'] = True + if tasmota_attr == 'relay': + self.tasmota_devices[tasmota_topic]['connected_items'][ + 'item_' + tasmota_attr + str(tasmota_relay)] = item + elif tasmota_zb_device and tasmota_zb_attr: + self.tasmota_devices[tasmota_topic]['connected_items'][ + 'item_' + str(tasmota_zb_device) + '.' + str(tasmota_zb_attr.lower())] = item + else: + self.tasmota_devices[tasmota_topic]['connected_items']['item_' + tasmota_attr] = item + + if tasmota_attr == 'online': + self.tasmota_devices[tasmota_topic]['online'] = False + elif (tasmota_attr and tasmota_attr.startswith('zb')) or tasmota_zb_device: + self.tasmota_devices[tasmota_topic]['zigbee']['active'] = True + + # append to list used for web interface + if item not in self.tasmota_items: + self.tasmota_items.append(item) + + return self.update_item + + def update_item(self, item, caller=None, source=None, dest=None): + """ + Item has been updated + + This method is called, if the value of an item has been updated by SmartHomeNG. + It should write the changed value out to the device (hardware/interface) that + is managed by this plugin. + + :param item: item to be updated towards the plugin + :type item: item + :param caller: if given it represents the callers name + :type caller: str + :param source: if given it represents the source + :type source: str + :param dest: if given it represents the dest + :param dest: str + """ + self.logger.debug(f"update_item: {item.id()}") + + if self.alive and caller != self.get_shortname(): + # code to execute if the plugin is not stopped AND only, if the item has not been changed by this this plugin: + + # get tasmota attributes of item + tasmota_topic = self.get_iattr_value(item.conf, 'tasmota_topic') + tasmota_attr = self.get_iattr_value(item.conf, 'tasmota_attr') + tasmota_relay = self.get_iattr_value(item.conf, 'tasmota_relay') + tasmota_zb_device = self.get_iattr_value(item.conf, 'tasmota_zb_device') + tasmota_zb_attr = self.get_iattr_value(item.conf, 'tasmota_zb_attr') + if tasmota_zb_attr: + tasmota_zb_attr = tasmota_zb_attr.lower() + + topic = tasmota_topic + detail = None + + if tasmota_attr in ['relay', 'hsb', 'white', 'ct', 'rf_send', 'rf_key_send', 'zb_permit_join']: + self.logger.info( + f"update_item: {item.id()}, item has been changed in SmartHomeNG outside of this plugin in {caller} with value {item()}") + value = None + bool_values = None + if tasmota_attr == 'relay': + # publish topic with new relay state + if not tasmota_relay: + tasmota_relay = '1' + detail = 'POWER' + if tasmota_relay > '1': + detail += str(tasmota_relay) + bool_values = ['OFF', 'ON'] + value = item() + + elif tasmota_attr == 'hsb': + # publish topic with new hsb value + # Format aus dem Item ist eine Liste mit drei int Werten bspw. [299, 100, 94] + # Format zum Senden ist ein String mit kommagetrennten Werten '299,100,94' + detail = 'HsbColor' + hsb = item() + if type(hsb) is list and len(hsb) == 3: + hsb = list(map(int, hsb)) + value = ','.join(str(v) for v in hsb) + else: + self.logger.debug( + f"update_item: hsb value received but not in correct format/content; expected format is list like [299, 100, 94]") + + elif tasmota_attr == 'white': + # publish topic with new white value + detail = 'White' + white = item() + if type(white) is int and 0 <= white <= 100: + value = white + else: + self.logger.debug( + f"update_item: white value received but not in correct format/content; expected format is integer value between 0 and 100") + + elif tasmota_attr == 'ct': + # publish topic with new ct value + detail = 'CT' + ct = item() + if type(ct) is int and 153 <= ct <= 500: + value = ct + else: + self.logger.debug( + f"update_item: ct value received but not in correct format/content; expected format is integer value between 153 for cold white and 500 for warm white") + + elif tasmota_attr == 'rf_send': + # publish topic with new rf data + # Format aus dem Item ist ein dict in folgendem Format: {'RfSync': 12220, 'RfLow': 440, 'RfHigh': 1210, 'RfCode':'#F06104'} + # Format zum Senden ist: "RfSync 12220; RfLow 440; RfHigh 1210; RfCode #F06104" + detail = 'Backlog' + rf_send = item() + if type(rf_send) is dict: + rf_send_lower = eval(repr(rf_send).lower()) + # rf_send_lower = {k.lower(): v for k, v in rf_send.items()} + if 'rfsync' and 'rflow' and 'rfhigh' and 'rfcode' in rf_send_lower: + value = 'RfSync' + ' ' + str(rf_send_lower['rfsync']) + '; ' + 'RfLow' + ' ' + str( + rf_send_lower['rflow']) + '; ' + 'RfHigh' + ' ' + str( + rf_send_lower['rfhigh']) + '; ' + 'RfCode' + ' ' + str(rf_send_lower['rfcode']) + else: + self.logger.debug( + f"update_item: rf_send received but not with correct content; expected content is: {'RfSync': 12220, 'RfLow': 440, 'RfHigh': 1210, 'RfCode':'#F06104'}") + else: + self.logger.debug( + f"update_item: rf_send received but not in correct format; expected format is: {'RfSync': 12220, 'RfLow': 440, 'RfHigh': 1210, 'RfCode':'#F06104'}") + + elif tasmota_attr == 'rf_key_send': + # publish topic for rf_keyX Default send + try: + rf_key = int(item()) + except Exception: + self.logger.debug( + f"update_item: rf_key_send received but with correct format; expected format integer or string 1-16") + else: + if rf_key in range(1, 17): + detail = 'RfKey' + str(rf_key) + value = 1 + else: + self.logger.debug( + f"update_item: rf_key_send received but with correct content; expected format value 1-16") + + elif tasmota_attr == 'ZbPermitJoin': + # publish topic for ZbPermitJoin + detail = 'ZbPermitJoin' + bool_values = ['0', '1'] + value = item() + + elif tasmota_attr == 'ZbForget': + # publish topic for ZbForget + detail = 'ZbForget' + value = item() + if item() in self.tasmota_zigbee_devices: + value = item() + else: + self.logger.error(f"Device {item()} not known by plugin, no action taken.") + + elif tasmota_attr == 'ZbPing': + # publish topic for ZbPing + detail = 'ZbPing' + if item() in self.tasmota_zigbee_devices: + value = item() + else: + self.logger.error(f"Device {item()} not known by plugin, no action taken.") + + if value is not None: + self.publish_tasmota_topic('cmnd', topic, detail, value, item, bool_values=bool_values) + + elif tasmota_zb_attr in ['power', 'hue', 'sat', 'ct', 'dimmer']: + self.logger.info( + f"update_item: {item.id()}, item has been changed in SmartHomeNG outside of this plugin in {caller} with value {item()}") + payload = {} + bool_values = None + # Topic: cmnd//ZbSend // Payload: {"Device":"0x0A22","Send":{"Power":0}} + if tasmota_zb_device and tasmota_zb_attr == 'power': + topic = tasmota_topic + detail = 'ZbSend' + bool_values = ['OFF', 'ON'] + payload = {'Device': tasmota_zb_device, 'Send': {'Power': int(item())}} + + elif tasmota_zb_device and tasmota_zb_attr == 'dimmer': + topic = tasmota_topic + detail = 'ZbSend' + value = int(item()) + if value < 0 or value > 254: + self.logger.warning( + f' commanded value for brightness not within allowed range; set to next valid value') + value = 0 if (value < 0) else 254 + payload = {'Device': tasmota_zb_device, 'Send': {'Dimmer': value}} + + elif tasmota_zb_device and tasmota_zb_attr == 'hue': + topic = tasmota_topic + detail = 'ZbSend' + value = int(item()) + if value < 0 or value > 254: + self.logger.warning( + f' commanded value for hue not within allowed range; set to next valid value') + value = 0 if (value < 0) else 254 + payload = {'Device': tasmota_zb_device, 'Send': {'Hue': value}} + + elif tasmota_zb_device and tasmota_zb_attr == 'sat': + topic = tasmota_topic + detail = 'ZbSend' + value = int(item()) + if value < 0 or value > 254: + self.logger.warning( + f' commanded value for saturation not within allowed range; set to next valid value') + value = 0 if (value < 0) else 254 + payload = {'Device': tasmota_zb_device, 'Send': {'Sat': value}} + + elif tasmota_zb_device and tasmota_zb_attr == 'ct': + topic = tasmota_topic + detail = 'ZbSend' + value = int(item()) + if value < 0 or value > 65534: + self.logger.warning( + f' commanded value for saturation not within allowed range; set to next valid value') + value = 0 if (value < 0) else 65534 + payload = {'Device': tasmota_zb_device, 'Send': {'CT': value}} + + if payload and detail: + self.publish_tasmota_topic('cmnd', topic, detail, payload, item, bool_values=bool_values) + + else: + self.logger.warning( + f"update_item: {item.id()}, trying to change item in SmartHomeNG that is read only in tasmota device (by {caller})") + + def poll_device(self): + """ + Polls for updates of the tasmota device + + This method is only needed, if the device (hardware/interface) does not propagate + changes on it's own, but has to be polled to get the actual status. + It is called by the scheduler which is set within run() method. + """ + # check if Tasmota Zigbee Bridge needs to be configured + tasmota_zigbee_bridge_status = self.tasmota_zigbee_bridge.get('status') + if tasmota_zigbee_bridge_status == 'discovered': + self.logger.info(f'poll_device: Tasmota Zigbee Bridge discovered; Configuration will be adapted.') + zigbee_device = self.tasmota_zigbee_bridge.get('device') + if zigbee_device: + self._discover_zigbee_bridge(zigbee_device) + + self.logger.info("poll_device: Checking online status of connected devices") + for tasmota_topic in self.tasmota_devices: + if self.tasmota_devices[tasmota_topic].get('online') is True and self.tasmota_devices[tasmota_topic].get( + 'online_timeout'): + if self.tasmota_devices[tasmota_topic]['online_timeout'] < datetime.now(): + self.tasmota_devices[tasmota_topic]['online'] = False + self._set_item_value(tasmota_topic, 'item_online', False, 'poll_device') + self.logger.info( + f"poll_device: {tasmota_topic} is not online any more - online_timeout={self.tasmota_devices[tasmota_topic]['online_timeout']}, now={datetime.now()}") + # delete data from WebIF dict + self.tasmota_devices[tasmota_topic]['lights'] = {} + self.tasmota_devices[tasmota_topic]['rf'] = {} + self.tasmota_devices[tasmota_topic]['sensors'] = {} + self.tasmota_devices[tasmota_topic]['relais'] = {} + self.tasmota_devices[tasmota_topic]['zigbee'] = {} + else: + self.logger.debug(f'poll_device: Checking online status of {tasmota_topic} successfull') + + # ask for status info of reconnected tasmota_topic (which was not connected during plugin start) + if not self.tasmota_devices[tasmota_topic].get('mac'): + self.logger.debug(f"poll_device: reconnected device discovered and try to discover it") + self._identify_device(tasmota_topic) + + # update tasmota_meta auf Basis von tasmota_devices + self._update_tasmota_meta() + + def add_tasmota_subscription(self, prefix, topic, detail, payload_type, bool_values=None, item=None, callback=None): + """ + build the topic in Tasmota style and add the subscription to mqtt + + :param prefix: prefix of topic to subscribe to + :type prefix: str + :param topic: unique part of topic to subscribe to + :type topic: str + :param detail: detail of topic to subscribe to + :type detail: str + :param payload_type: payload type of the topic (for this subscription to the topic) + :type payload_type: str + :param bool_values: bool values (for this subscription to the topic) + :type bool_values list + :param item: item that should receive the payload as value. Used by the standard handler (if no callback function is specified) + :type item: item + :param callback: a plugin can provide an own callback function, if special handling of the payload is needed + :return: None + """ + + tpc = self.full_topic.replace("%prefix%", prefix) + tpc = tpc.replace("%topic%", topic) + tpc += detail + self.add_subscription(tpc, payload_type, bool_values=bool_values, callback=callback) + + def publish_tasmota_topic(self, prefix, topic, detail, payload, item=None, qos=None, retain=False, + bool_values=None): + """ + build the topic in Tasmota style and publish to mqtt + + :param prefix: prefix of topic to publish + :type prefix: str + :param topic: unique part of topic to publish + :type topic: str + :param detail: detail of topic to publish + :type detail: str + :param payload: payload to publish + :type payload: any + :param item: item (if relevant) + :type item: item + :param qos: qos for this message (optional) + :type qos: int + :param retain: retain flag for this message (optional) + :type retain: bool + :param bool_values: bool values (for publishing this topic, optional) + :type bool_values list + :return: None + """ + tpc = self.full_topic.replace("%prefix%", prefix) + tpc = tpc.replace("%topic%", topic) + tpc += detail + self.publish_topic(tpc, payload, item, qos, retain, bool_values) + + def on_discovery(self, topic, payload, qos=None, retain=None): + """ + Callback function to handle received discovery messages + + :param topic: MQTT topic + :type topic: str + :param payload: MQTT message payload + :type payload: dict + :param qos: qos for this message (optional) + :type qos: int + :param retain: retain flag for this message (optional) + :type retain: bool + """ + + # device_id=2C3AE82EB8AE, type=config, payload={"ip":"192.168.2.25","dn":"SONOFF_B1","fn":["SONOFF_B1",null,null,null,null,null,null,null],"hn":"SONOFF-B1-6318","mac":"2C3AE82EB8AE","md":"Sonoff Basic","ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON","TOGGLE","HOLD"],"sw":"11.0.0","t":"SONOFF_B1","ft":"%prefix%/%topic%/","tp":["cmnd","stat","tele"],"rl":[1,0,0,0,0,0,0,0],"swc":[-1,-1,-1,-1,-1,-1,-1,-1],"swn":[null,null,null,null,null,null,null,null],"btn":[0,0,0,0,0,0,0,0],"so":{"4":0,"11":0,"13":0,"17":1,"20":0,"30":0,"68":0,"73":0,"82":0,"114":0,"117":0},"lk":0,"lt_st":0,"sho":[0,0,0,0],"ver":1} + # device_id=2C3AE82EB8AE, type=sensors, payload={"sn":{"Time":"2022-02-23T11:00:43","DS18B20":{"Id":"00000938355C","Temperature":18.1},"TempUnit":"C"},"ver":1} + # device_id=2CF432CC2FC5, type=config, payload={"ip":"192.168.2.33","dn":"NXSM200_01","fn":["NXSM200_01",null,null,null,null,null,null,null],"hn":"NXSM200-01-4037","mac":"2CF432CC2FC5","md":"NXSM200","ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON","TOGGLE","HOLD"],"sw":"11.0.0","t":"NXSM200_01","ft":"%prefix%/%topic%/","tp":["cmnd","stat","tele"],"rl":[1,0,0,0,0,0,0,0],"swc":[-1,-1,-1,-1,-1,-1,-1,-1],"swn":[null,null,null,null,null,null,null,null],"btn":[0,0,0,0,0,0,0,0],"so":{"4":0,"11":0,"13":0,"17":0,"20":0,"30":0,"68":0,"73":0,"82":0,"114":0,"117":0},"lk":0,"lt_st":0,"sho":[0,0,0,0],"ver":1} + # device_id=2CF432CC2FC5, type=sensors, payload={"sn":{"Time":"2022-02-23T11:02:48","ENERGY":{"TotalStartTime":"2019-12-23T17:02:03","Total":72.814,"Yesterday":0.000,"Today":0.000,"Power": 0,"ApparentPower": 0,"ReactivePower": 0,"Factor":0.00,"Voltage": 0,"Current":0.000}},"ver":1} + # device_id=6001946F966E, type=config, payload={"ip":"192.168.2.31","dn":"SONOFF_RGBW1","fn":["SONOFF_RGBW",null,null,null,null,null,null,null],"hn":"SONOFF-RGBW1-5742","mac":"6001946F966E","md":"H801","ty":0,"if":0,"ofln":"Offline","onln":"Online","state":["OFF","ON","TOGGLE","HOLD"],"sw":"11.0.0","t":"SONOFF_RGBW1","ft":"%prefix%/%topic%/","tp":["cmnd","stat","tele"],"rl":[2,0,0,0,0,0,0,0],"swc":[-1,-1,-1,-1,-1,-1,-1,-1],"swn":[null,null,null,null,null,null,null,null],"btn":[0,0,0,0,0,0,0,0],"so":{"4":0,"11":0,"13":0,"17":0,"20":0,"30":0,"68":0,"73":0,"82":0,"114":0,"117":0},"lk":1,"lt_st":5,"sho":[0,0,0,0],"ver":1} + # device_id=6001946F966E, type=sensors, payload={"sn":{"Time":"2022-02-23T11:00:43"},"ver":1} + try: + (tasmota, discovery, device_id, type) = topic.split('/') + self.logger.info(f"on_discovery: device_id={device_id}, type={type}, payload={payload}") + except Exception as e: + self.logger.error(f"received topic {topic} is not in correct format. Error was: {e}") + else: + if type == 'config': + tasmota_topic = payload.get('dn', None) + if tasmota_topic: + self.discovered_devices.append(tasmota_topic) + + def on_mqtt_announce(self, topic, payload, qos=None, retain=None): + """ + Callback function to handle received messages + + :param topic: MQTT topic + :type topic: str + :param payload: MQTT message payload + :type payload: dict + :param qos: qos for this message (optional) + :type qos: int + :param retain: retain flag for this message (optional) + :type retain: bool + """ + try: + (topic_type, tasmota_topic, info_topic) = topic.split('/') + self.logger.info( + f"on_mqtt_announce: topic_type={topic_type}, tasmota_topic={tasmota_topic}, info_topic={info_topic}, payload={payload}") + except Exception as e: + self.logger.error(f"received topic {topic} is not in correct format. Error was: {e}") + else: + # ask for status info of this newly discovered device + if info_topic != 'ZbReceived' and not self.tasmota_devices.get(tasmota_topic): + self.tasmota_devices[tasmota_topic] = {} + self.tasmota_devices[tasmota_topic]['connected_to_item'] = False + self.tasmota_devices[tasmota_topic]['uptime'] = '-' + self.tasmota_devices[tasmota_topic]['lights'] = {} + self.tasmota_devices[tasmota_topic]['rf'] = {} + self.tasmota_devices[tasmota_topic]['sensors'] = {} + self.tasmota_devices[tasmota_topic]['relais'] = {} + self.tasmota_devices[tasmota_topic]['zigbee'] = {} + self.logger.debug(f"on_mqtt_announce: new device discovered, publishing 'cmnd/{topic}/STATUS'") + self.publish_topic(f"cmnd/'{tasmota_topic}/STATUS", 0) + + if info_topic == 'LWT': + # Handling of LWT + self.logger.debug(f"LWT: info_topic: {info_topic} datetime: {datetime.now()} payload: {payload}") + self.tasmota_devices[tasmota_topic]['online'] = payload + self._set_item_value(tasmota_topic, 'item_online', payload, info_topic) + if payload is True: + self.tasmota_devices[tasmota_topic]['online_timeout'] = datetime.now() + timedelta( + seconds=self.telemetry_period + 5) + # self.logger.info(f" - new 'online_timeout'={self.tasmota_devices[tasmota_topic]['online_timeout']}") + + elif info_topic == 'STATE' or info_topic == 'RESULT': + # Handling of Light messages + if type(payload) is dict and ( + 'HSBColor' or 'Dimmer' or 'Color' or 'CT' or 'Scheme' or 'Fade' or 'Speed' or 'LedTable' or 'White') in payload: + self.logger.info(f"Received Message decoded as light message.") + self._handle_lights(tasmota_topic, info_topic, payload) + + # Handling of Power messages + elif any(item.startswith("POWER") for item in payload.keys()): + self.logger.info(f"Received Message decoded as power message.") + self._handle_power(tasmota_topic, info_topic, payload) + + # Handling of RF messages + elif any(item.startswith("Rf") for item in payload.keys()): + self.logger.info(f"Received Message decoded as RF type message.") + self._handle_rf(tasmota_topic, info_topic, payload) + + # Handling of Module messages + elif type(payload) is dict and 'Module' in payload: + self.logger.info(f"Received Message decoded as Module type message.") + self._handle_module(tasmota_topic, payload) + + # Handling of Zigbee Bridge Setting messages + elif type(payload) is dict and any(item.startswith("SetOption") for item in payload.keys()): + self.logger.info(f"Received Message decoded as Zigbee Bridge Setting message.") + self._handle_zbbridge_setting(payload) + + # Handling of Zigbee Bridge Config messages + elif type(payload) is dict and any(item.startswith("ZbConfig") for item in payload.keys()): + self.logger.info(f"Received Message decoded as Zigbee Config message.") + self._handle_zbconfig(tasmota_topic, payload) + + # Handling of Zigbee Bridge Status messages + elif any(item.startswith("ZbStatus") for item in payload.keys()): + self.logger.info(f"Received Message decoded as Zigbee ZbStatus message.") + self._handle_zbstatus(tasmota_topic, payload) + + # Handling of WIFI + if type(payload) is dict and 'Wifi' in payload: + self.logger.info(f"Received Message contains Wifi information.") + self._handle_wifi(tasmota_topic, payload) + + # Handling of Uptime + if tasmota_topic in self.tasmota_devices: + self.logger.info(f"Received Message will be checked for Uptime.") + self.tasmota_devices[tasmota_topic]['uptime'] = payload.get('Uptime', '-') + + # setting new online-timeout + self.tasmota_devices[tasmota_topic]['online_timeout'] = datetime.now() + timedelta( + seconds=self.telemetry_period + 5) + + # setting online_item to True + self._set_item_value(tasmota_topic, 'item_online', True, info_topic) + + elif info_topic == 'SENSOR': + self.logger.info(f"Received Message contain sensor information.") + self._handle_sensor(tasmota_topic, info_topic, payload) + + # setting new online-timeout + self.tasmota_devices[tasmota_topic]['online_timeout'] = datetime.now() + timedelta( + seconds=self.telemetry_period + 5) + + # setting online_item to True + self._set_item_value(tasmota_topic, 'item_online', True, info_topic) + + elif info_topic == 'STATUS0': + # payload={'Status': {'Module': 1, 'DeviceName': 'SONOFF_B1', 'FriendlyName': ['SONOFF_B1'], 'Topic': 'SONOFF_B1', 'ButtonTopic': '0', 'Power': 1, 'PowerOnState': 3, 'LedState': 1, 'LedMask': 'FFFF', 'SaveData': 1, 'SaveState': 1, 'SwitchTopic': '0', 'SwitchMode': [0, 0, 0, 0, 0, 0, 0, 0], 'ButtonRetain': 0, 'SwitchRetain': 0, 'SensorRetain': 0, 'PowerRetain': 0, 'InfoRetain': 0, 'StateRetain': 0}, 'StatusPRM': {'Baudrate': 115200, 'SerialConfig': '8N1', 'GroupTopic': 'tasmotas', 'OtaUrl': 'http://ota.tasmota.com/tasmota/release/tasmota.bin.gz', 'RestartReason': 'Software/System restart', 'Uptime': '0T20:03:04', 'StartupUTC': '2022-02-22T11:36:56', 'Sleep': 50, 'CfgHolder': 4617, 'BootCount': 1394, 'BCResetTime': '2021-11-19T09:08:33', 'SaveCount': 1571, 'SaveAddress': 'F9000'}, 'StatusFWR': {'Version': '11.0.0(tasmota)', 'BuildDateTime': '2022-02-12T14:13:50', 'Boot': 6, 'Core': '2_7_4_9', 'SDK': '2.2.2-dev(38a443e)', 'CpuFrequency': 80, 'Hardware': 'ESP8266EX', 'CR': '354/699'}, 'StatusLOG': {'SerialLog': 2, 'WebLog': 2, 'MqttLog': 0, 'SysLog': 0, 'LogHost': '', 'LogPort': 514, 'SSId': ['WLAN-Access', ''], 'TelePeriod': 300, 'Resolution': '558180C0', 'SetOption': ['000A8009', '2805C80001000600003C5A0A000000000000', '000002A0', '00006000', '00004000']}, 'StatusMEM': {'ProgramSize': 620, 'Free': 380, 'Heap': 25, 'ProgramFlashSize': 1024, 'FlashSize': 1024, 'FlashChipId': '14405E', 'FlashFrequency': 40, 'FlashMode': 3, 'Features': ['00000809', '8FDAC787', '04368001', '000000CF', '010013C0', 'C000F981', '00004004', '00001000', '00000020'], 'Drivers': '1,2,3,4,5,6,7,8,9,10,12,16,18,19,20,21,22,24,26,27,29,30,35,37,45', 'Sensors': '1,2,3,4,5,6'}, 'StatusNET': {'Hostname': 'SONOFF-B1-6318', 'IPAddress': '192.168.2.25', 'Gateway': '192.168.2.1', 'Subnetmask': '255.255.255.0', 'DNSServer1': '192.168.2.1', 'DNSServer2': '0.0.0.0', 'Mac': '2C:3A:E8:2E:B8:AE', 'Webserver': 2, 'HTTP_API': 1, 'WifiConfig': 4, 'WifiPower': 17.0}, 'StatusMQT': {'MqttHost': '192.168.2.12', 'MqttPort': 1883, 'MqttClientMask': 'DVES_%06X', 'MqttClient': 'DVES_2EB8AE', 'MqttUser': 'DVES_USER', 'MqttCount': 3, 'MAX_PACKET_SIZE': 1200, 'KEEPALIVE': 30, 'SOCKET_TIMEOUT': 4}, 'StatusTIM': {'UTC': '2022-02-23T07:40:00', 'Local': '2022-02-23T08:40:00', 'StartDST': '2022-03-27T02:00:00', 'EndDST': '2022-10-30T03:00:00', 'Timezone': '+01:00', 'Sunrise': '07:43', 'Sunset': '18:23'}, 'StatusSNS': {'Time': '2022-02-23T08:40:00', 'DS18B20': {'Id': '00000938355C', 'Temperature': 18.1}, 'TempUnit': 'C'}, 'StatusSTS': {'Time': '2022-02-23T08:40:00', 'Uptime': '0T20:03:04', 'UptimeSec': 72184, 'Heap': 24, 'SleepMode': 'Dynamic', 'Sleep': 50, 'LoadAvg': 19, 'MqttCount': 3, 'POWER': 'ON', 'Wifi': {'AP': 1, 'SSId': 'WLAN-Access', 'BSSId': 'DC:39:6F:15:58:0B', 'Channel': 11, 'Mode': '11n', 'RSSI': 76, 'Signal': -62, 'LinkCount': 3, 'Downtime': '0T00:00:07'}}} + # payload={'Status': {'Module': 7, 'DeviceName': 'SONOFF_B1', 'FriendlyName': ['SONOFF_B1', '', '', ''], 'Topic': 'SONOFF_B1', 'ButtonTopic': '0', 'Power': 1, 'PowerOnState': 3, 'LedState': 1, 'LedMask': 'FFFF', 'SaveData': 1, 'SaveState': 1, 'SwitchTopic': '0', 'SwitchMode': [0, 0, 0, 0, 0, 0, 0, 0], 'ButtonRetain': 0, 'SwitchRetain': 0, 'SensorRetain': 0, 'PowerRetain': 0, 'InfoRetain': 0, 'StateRetain': 0}, 'StatusPRM': {'Baudrate': 115200, 'SerialConfig': '8N1', 'GroupTopic': 'tasmotas', 'OtaUrl': 'http://ota.tasmota.com/tasmota/release/tasmota.bin.gz', 'RestartReason': 'Software/System restart', 'Uptime': '0T00:00:14', 'StartupUTC': '2022-02-23T09:12:09', 'Sleep': 50, 'CfgHolder': 4617, 'BootCount': 1397, 'BCResetTime': '2021-11-19T09:08:33', 'SaveCount': 1578, 'SaveAddress': 'FA000'}, 'StatusFWR': {'Version': '11.0.0(tasmota)', 'BuildDateTime': '2022-02-12T14:13:50', 'Boot': 6, 'Core': '2_7_4_9', 'SDK': '2.2.2-dev(38a443e)', 'CpuFrequency': 80, 'Hardware': 'ESP8266EX', 'CR': '354/699'}, 'StatusLOG': {'SerialLog': 2, 'WebLog': 2, 'MqttLog': 0, 'SysLog': 0, 'LogHost': '', 'LogPort': 514, 'SSId': ['WLAN-Access', ''], 'TelePeriod': 300, 'Resolution': '558180C0', 'SetOption': ['00028009', '2805C80001000600003C5A0A000000000000', '000002A0', '00006000', '00004000']}, 'StatusMEM': {'ProgramSize': 620, 'Free': 380, 'Heap': 26, 'ProgramFlashSize': 1024, 'FlashSize': 1024, 'FlashChipId': '14405E', 'FlashFrequency': 40, 'FlashMode': 3, 'Features': ['00000809', '8FDAC787', '04368001', '000000CF', '010013C0', 'C000F981', '00004004', '00001000', '00000020'], 'Drivers': '1,2,3,4,5,6,7,8,9,10,12,16,18,19,20,21,22,24,26,27,29,30,35,37,45', 'Sensors': '1,2,3,4,5,6'}, 'StatusNET': {'Hostname': 'SONOFF-B1-6318', 'IPAddress': '192.168.2.25', 'Gateway': '192.168.2.1', 'Subnetmask': '255.255.255.0', 'DNSServer1': '192.168.2.1', 'DNSServer2': '0.0.0.0', 'Mac': '2C:3A:E8:2E:B8:AE', 'Webserver': 2, 'HTTP_API': 1, 'WifiConfig': 4, 'WifiPower': 17.0}, 'StatusMQT': {'MqttHost': '192.168.2.12', 'MqttPort': 1883, 'MqttClientMask': 'DVES_%06X', 'MqttClient': 'DVES_2EB8AE', 'MqttUser': 'DVES_USER', 'MqttCount': 1, 'MAX_PACKET_SIZE': 1200, 'KEEPALIVE': 30, 'SOCKET_TIMEOUT': 4}, 'StatusTIM': {'UTC': '2022-02-23T09:12:23', 'Local': '2022-02-23T10:12:23', 'StartDST': '2022-03-27T02:00:00', 'EndDST': '2022-10-30T03:00:00', 'Timezone': '+01:00', 'Sunrise': '07:43', 'Sunset': '18:23'}, 'StatusSNS': {'Time': '2022-02-23T10:12:23'}, 'StatusSTS': {'Time': '2022-02-23T10:12:23', 'Uptime': '0T00:00:14', 'UptimeSec': 14, 'Heap': 25, 'SleepMode': 'Dynamic', 'Sleep': 50, 'LoadAvg': 19, 'MqttCount': 1, 'POWER1': 'ON', 'POWER2': 'OFF', 'POWER3': 'OFF', 'POWER4': 'OFF', 'Wifi': {'AP': 1, 'SSId': 'WLAN-Access', 'BSSId': 'DC:39:6F:15:58:0B', 'Channel': 11, 'Mode': '11n', 'RSSI': 80, 'Signal': -60, 'LinkCount': 1, 'Downtime': '0T00:00:03'}}} + # payload={'Status': {'Module': 20, 'DeviceName': 'SONOFF_RGBW1', 'FriendlyName': ['SONOFF_RGBW'], 'Topic': 'SONOFF_RGBW1', 'ButtonTopic': '0', 'Power': 1, 'PowerOnState': 3, 'LedState': 1, 'LedMask': 'FFFF', 'SaveData': 1, 'SaveState': 1, 'SwitchTopic': '0', 'SwitchMode': [0, 0, 0, 0, 0, 0, 0, 0], 'ButtonRetain': 0, 'SwitchRetain': 0, 'SensorRetain': 0, 'PowerRetain': 0, 'InfoRetain': 0, 'StateRetain': 0}, 'StatusPRM': {'Baudrate': 115200, 'SerialConfig': '8N1', 'GroupTopic': 'sonoffs', 'OtaUrl': 'http://ota.tasmota.com/tasmota/release/tasmota.bin.gz', 'RestartReason': 'Software/System restart', 'Uptime': '0T00:02:06', 'StartupUTC': '2022-02-23T09:20:17', 'Sleep': 50, 'CfgHolder': 4617, 'BootCount': 123, 'BCResetTime': '2021-03-12T16:54:51', 'SaveCount': 449, 'SaveAddress': 'F8000'}, 'StatusFWR': {'Version': '11.0.0(tasmota)', 'BuildDateTime': '2022-02-12T14:13:50', 'Boot': 31, 'Core': '2_7_4_9', 'SDK': '2.2.2-dev(38a443e)', 'CpuFrequency': 80, 'Hardware': 'ESP8266EX', 'CR': '429/699'}, 'StatusLOG': {'SerialLog': 2, 'WebLog': 2, 'MqttLog': 0, 'SysLog': 0, 'LogHost': '', 'LogPort': 514, 'SSId': ['WLAN-Access', ''], 'TelePeriod': 300, 'Resolution': '55C180C0', 'SetOption': ['00008009', '2805C80001000600003C5AFF000000000000', '00000080', '00006000', '00004000']}, 'StatusMEM': {'ProgramSize': 620, 'Free': 380, 'Heap': 25, 'ProgramFlashSize': 1024, 'FlashSize': 1024, 'FlashChipId': '1440EF', 'FlashFrequency': 40, 'FlashMode': 3, 'Features': ['00000809', '8FDAC787', '04368001', '000000CF', '010013C0', 'C000F981', '00004004', '00001000', '00000020'], 'Drivers': '1,2,3,4,5,6,7,8,9,10,12,16,18,19,20,21,22,24,26,27,29,30,35,37,45', 'Sensors': '1,2,3,4,5,6'}, 'StatusNET': {'Hostname': 'SONOFF-RGBW1-5742', 'IPAddress': '192.168.2.31', 'Gateway': '192.168.2.1', 'Subnetmask': '255.255.255.0', 'DNSServer1': '192.168.2.1', 'DNSServer2': '0.0.0.0', 'Mac': '60:01:94:6F:96:6E', 'Webserver': 2, 'HTTP_API': 1, 'WifiConfig': 5, 'WifiPower': 17.0}, 'StatusMQT': {'MqttHost': '192.168.2.12', 'MqttPort': 1883, 'MqttClientMask': 'DVES_%06X', 'MqttClient': 'DVES_6F966E', 'MqttUser': 'DVES_USER', 'MqttCount': 1, 'MAX_PACKET_SIZE': 1200, 'KEEPALIVE': 30, 'SOCKET_TIMEOUT': 4}, 'StatusTIM': {'UTC': '2022-02-23T09:22:23', 'Local': '2022-02-23T10:22:23', 'StartDST': '2022-03-27T02:00:00', 'EndDST': '2022-10-30T03:00:00', 'Timezone': '+01:00', 'Sunrise': '07:43', 'Sunset': '18:23'}, 'StatusSNS': {'Time': '2022-02-23T10:22:23'}, 'StatusSTS': {'Time': '2022-02-23T10:22:23', 'Uptime': '0T00:02:06', 'UptimeSec': 126, 'Heap': 24, 'SleepMode': 'Dynamic', 'Sleep': 10, 'LoadAvg': 99, 'MqttCount': 1, 'POWER': 'ON', 'Dimmer': 100, 'Color': '65FF3F0000', 'HSBColor': '108,75,100', 'White': 0, 'CT': 153, 'Channel': [40, 100, 25, 0, 0], 'Scheme': 0, 'Fade': 'ON', 'Speed': 1, 'LedTable': 'OFF', 'Wifi': {'AP': 1, 'SSId': 'WLAN-Access', 'BSSId': '38:10:D5:15:87:69', 'Channel': 1, 'Mode': '11n', 'RSSI': 64, 'Signal': -68, 'LinkCount': 1, 'Downtime': '0T00:00:03'}}} + self.logger.info(f"Received Message decoded as STATUS0 message.") + + # get friendly name + friendly_name = payload['Status'].get('FriendlyName', None) + if friendly_name and isinstance(friendly_name, list): + friendly_name = ''.join(friendly_name) + self.tasmota_devices[tasmota_topic]['friendly_name'] = friendly_name + + # get Module + module = payload['Status'].get('Module', None) + if module: + self.tasmota_devices[tasmota_topic]['module'] = module + + # get Firmware + firmware = payload['StatusFWR'].get('Version', None) + if firmware: + self.tasmota_devices[tasmota_topic]['fw_ver'] = firmware + + # get IP Address + ip = payload['StatusNET'].get('IPAddress', None) + if ip: + self.tasmota_devices[tasmota_topic]['ip'] = ip + + # get MAC + mac = payload['StatusNET'].get('Mac', None) + if mac: + self.tasmota_devices[tasmota_topic]['mac'] = mac + + # get detailed status using payload['StatusSTS'] + status_sts = payload.get('StatusSTS', None) + + if status_sts: + # Handling Lights and Dimmer + self.logger.debug(f"{tasmota_topic} status_sts={status_sts}") + + if type(payload) is dict and ( + 'HSBColor' or 'Dimmer' or 'Color' or 'CT' or 'Scheme' or 'Fade' or 'Speed' or 'LedTable' or 'White') in status_sts: + self.logger.debug('status lights') + self._handle_lights(tasmota_topic, info_topic, status_sts) + + # Handling of Power + if any(item.startswith("POWER") for item in status_sts.keys()): + self.logger.debug('status power') + self._handle_power(tasmota_topic, info_topic, status_sts) + + # Handling of RF messages + if any(item.startswith("Rf") for item in status_sts.keys()): + self.logger.debug('status rf') + self._handle_rf(tasmota_topic, info_topic, status_sts) + + # Handling of WIFI + if type(payload) is dict and 'Wifi' in status_sts: + self.logger.debug('status wifi') + self._handle_wifi(tasmota_topic, status_sts) + + # Handling of Uptime + if tasmota_topic in self.tasmota_devices: + self.tasmota_devices[tasmota_topic]['uptime'] = status_sts.get('Uptime', '-') + + elif info_topic == 'INFO1': + # payload={'Info1': {'Module': 'Sonoff Basic', 'Version': '11.0.0(tasmota)', 'FallbackTopic': 'cmnd/DVES_2EB8AE_fb/', 'GroupTopic': 'cmnd/tasmotas/'}} + self.logger.info(f"Received Message decoded as INFO1 message.") + self.tasmota_devices[tasmota_topic]['fw_ver'] = payload['Info1'].get('Version', '') + self.tasmota_devices[tasmota_topic]['module'] = payload['Info1'].get('Module', '') + + elif info_topic == 'INFO2': + # payload={'Info2': {'WebServerMode': 'Admin', 'Hostname': 'SONOFF-B1-6318', 'IPAddress': '192.168.2.25'}} + self.logger.info(f"Received Message decoded as INFO2 message.") + self.tasmota_devices[tasmota_topic]['ip'] = payload['Info2'].get('IPAddress', '') + + elif info_topic == 'INFO3': + # payload={'Info3': {'RestartReason': 'Software/System restart', 'BootCount': 1395}} + self.logger.info(f"Received Message decoded as INFO3 message.") + restart_reason = payload['Info3'].get('RestartReason', '') + self.logger.warning( + f"Device {tasmota_topic} (IP={self.tasmota_devices[tasmota_topic]['ip']}) just startet. Reason={restart_reason}") + + elif info_topic == 'ZbReceived': + self.logger.info(f"Received Message decoded as ZbReceived message.") + self._handle_ZbReceived(payload) + + # setting new online-timeout + self.tasmota_devices[tasmota_topic]['online_timeout'] = datetime.now() + timedelta( + seconds=self.telemetry_period + 5) + + # setting online_item to True + self._set_item_value(tasmota_topic, 'item_online', True, info_topic) + else: + self.logger.info(f"Topic {info_topic} not handled in plugin.") + + def on_mqtt_message(self, topic, payload, qos=None, retain=None): + """ + Callback function to handle received messages + + :param topic: MQTT topic + :type topic: str + :param payload: MQTT message payload + :type payload: dict + :param qos: qos for this message (optional) + :type qos: int + :param retain: retain flag for this message (optional) + :type retain: bool + """ + try: + (topic_type, tasmota_topic, info_topic) = topic.split('/') + self.logger.info( + f"on_mqtt_message: topic_type={topic_type}, tasmota_topic={tasmota_topic}, info_topic={info_topic}, payload={payload}") + except Exception as e: + self.logger.error(f"received topic {topic} is not in correct format. Error was: {e}") + else: + device = self.tasmota_devices.get(tasmota_topic, None) + if device: + if info_topic.startswith('POWER'): + tasmota_relay = str(info_topic[5:]) + if not tasmota_relay: + tasmota_relay = '1' + item_relay = 'item_relay' + tasmota_relay + self._set_item_value(tasmota_topic, item_relay, payload == 'ON', info_topic) + self.tasmota_devices[tasmota_topic]['relais'][info_topic] = payload + self.tasmota_meta['relais'] = True + + def _set_item_value(self, tasmota_topic, itemtype, value, info_topic=''): + """ + Sets item value + + :param tasmota_topic: MQTT message payload + :type tasmota_topic: str + :param itemtype: itemtype to be set + :type itemtype: str + :param value: value to be set + :type value: any + :param info_topic: MQTT info_topic + :type info_topic: str + :return: None + """ + if tasmota_topic in self.tasmota_devices: + if self.tasmota_devices[tasmota_topic].get('connected_items'): + item = self.tasmota_devices[tasmota_topic]['connected_items'].get(itemtype) + topic = '' + src = '' + if info_topic != '': + topic = f"from info_topic '{info_topic}'" + src = self.get_instance_name() + if src != '': + src += ':' + src += tasmota_topic + ':' + info_topic + + if item is not None: + item(value, self.get_shortname(), src) + self.logger.info( + f"{tasmota_topic}: Item '{item.id()}' via itemtype '{itemtype} set to value {value} provided by {src} '.") + else: + self.logger.info( + f"{tasmota_topic}: No item for itemtype '{itemtype}' defined to set to {value} provided by {src}.") + else: + self.logger.info(f"{tasmota_topic}: No items connected to {tasmota_topic}.") + else: + self.logger.info(f"Tasmota Device {tasmota_topic} unknown.") + + def _handle_ZbReceived(self, payload): + """ + Extracts Zigbee Received information out of payload and updates plugin dict + + :param payload: MQTT message payload + :param payload: dict + :return: None + """ + # topic_type=tele, tasmota_topic=SONOFF_ZB1, info_topic=ZbReceived, payload={'snzb-02_01': {'Device': '0x67FE', 'Name': 'snzb-02_01', 'Humidity': 31.94, 'Endpoint': 1, 'LinkQuality': 157}} + for zigbee_device in payload: + if zigbee_device not in self.tasmota_zigbee_devices: + self.logger.info(f"New Zigbee Device {zigbee_device} connected to Tasmota Zigbee Bridge discovered") + self.tasmota_zigbee_devices[zigbee_device] = {} + else: + if not self.tasmota_zigbee_devices[zigbee_device].get('data'): + self.tasmota_zigbee_devices[zigbee_device]['data'] = {} + if 'Device' in payload[zigbee_device]: + del payload[zigbee_device]['Device'] + if 'Name' in payload[zigbee_device]: + del payload[zigbee_device]['Name'] + self.tasmota_zigbee_devices[zigbee_device]['data'].update(payload[zigbee_device]) + + def _handle_sensor(self, device, function, payload): + """ + Extracts Sensor information out of payload and updates plugin dict + + :param device: Device, the Sensor information shall be handled (equals tasmota_topic) + :type device: str + :param function: Function of Device (equals info_topic) + :type function: str + :param payload: MQTT message payload + :type payload: dict + :return: + """ + # topic_type=tele, tasmota_topic=SONOFF_B1, info_topic=SENSOR, payload={"Time":"2021-04-28T09:42:50","DS18B20":{"Id":"00000938355C","Temperature":18.4},"TempUnit":"C"} + # topic_type=tele, tasmota_topic=SONOFF_ZB1, info_topic=SENSOR, payload={'0x67FE': {'Device': '0x67FE', 'Humidity': 41.97, 'Endpoint': 1, 'LinkQuality': 55}} + # topic_type=tele, tasmota_topic=SONOFF_ZB1, info_topic=SENSOR, payload={"0x54EB":{"Device":"0x54EB","MultiInValue":2,"Click":"double","click":"double","Endpoint":1,"LinkQuality":173}} + # topic_type=tele, tasmota_topic=SONOFF_ZB1, info_topic=SENSOR, payload={"0x54EB":{"Device":"0x54EB","MultiInValue":255 ,"Click":"release","action":"release","Endpoint":1,"LinkQuality":175}} + + # Handling of Zigbee Device Messages + if self.tasmota_devices[device]['zigbee'] != {}: + self.logger.info(f"Received Message decoded as Zigbee Device message.") + if type(payload) is dict: + for zigbee_device in payload: + if zigbee_device not in self.tasmota_zigbee_devices: + self.logger.info( + f"New Zigbee Device {zigbee_device} connected to Tasmota Zigbee Bridge discovered") + self.tasmota_zigbee_devices[zigbee_device] = {} + if not self.tasmota_zigbee_devices[zigbee_device].get('data'): + self.tasmota_zigbee_devices[zigbee_device]['data'] = {} + if 'Device' in payload[zigbee_device]: + del payload[zigbee_device]['Device'] + if 'Name' in payload[zigbee_device]: + del payload[zigbee_device]['Name'] + + self.tasmota_zigbee_devices[zigbee_device]['data'].update(payload[zigbee_device]) + + # Check and correct payload, if there is the same dict key used with different cases (upper and lower case) + new_dict = {} + for k in payload[zigbee_device]: + keys = [each_string.lower() for each_string in list(new_dict.keys())] + if k not in keys: + new_dict[k] = payload[zigbee_device][k] + payload[zigbee_device] = new_dict + + # Delete keys from 'meta', if in 'data' + for key in payload[zigbee_device]: + if self.tasmota_zigbee_devices[zigbee_device].get('meta'): + if key in self.tasmota_zigbee_devices[zigbee_device]['meta']: + self.tasmota_zigbee_devices[zigbee_device]['meta'].pop(key) + + # Iterate over payload and set corresponding items + self.logger.debug(f"Item to be checked for update based in Zigbee Message and updated") + for element in payload[zigbee_device]: + itemtype = f"item_{zigbee_device}.{element.lower()}" + value = payload[zigbee_device][element] + self._set_item_value(device, itemtype, value, function) + + # Handling of Tasmota Device Sensor Messages + else: + # Energy sensors + energy = payload.get('ENERGY') + if energy: + self.logger.info(f"Received Message decoded as Energy Sensor message.") + if not self.tasmota_devices[device]['sensors'].get('ENERGY'): + self.tasmota_devices[device]['sensors']['ENERGY'] = {} + if type(energy) is dict: + self.tasmota_devices[device]['sensors']['ENERGY']['period'] = energy.get('Period', None) + if 'Voltage' in energy: + self.tasmota_devices[device]['sensors']['ENERGY']['voltage'] = energy['Voltage'] + self._set_item_value(device, 'item_voltage', energy['Voltage'], function) + if 'Current' in energy: + self.tasmota_devices[device]['sensors']['ENERGY']['current'] = energy['Current'] + self._set_item_value(device, 'item_current', energy['Current'], function) + if 'Power' in energy: + self.tasmota_devices[device]['sensors']['ENERGY']['power'] = energy['Power'] + self._set_item_value(device, 'item_power', energy['Power'], function) + if 'ApparentPower' in energy: + self.tasmota_devices[device]['sensors']['ENERGY']['apparent_power'] = energy['ApparentPower'] + self._set_item_value(device, 'item_apparent_power', energy['ApparentPower'], function) + if 'ReactivePower' in energy: + self.tasmota_devices[device]['sensors']['ENERGY']['reactive_power'] = energy['ReactivePower'] + self._set_item_value(device, 'item_reactive_power', energy['ReactivePower'], function) + if 'Factor' in energy: + self.tasmota_devices[device]['sensors']['ENERGY']['factor'] = energy['Factor'] + self._set_item_value(device, 'item_power_factor', energy['Factor'], function) + if 'TotalStartTime' in energy: + self.tasmota_devices[device]['sensors']['ENERGY']['total_starttime'] = energy['TotalStartTime'] + self._set_item_value(device, 'item_total_starttime', energy['TotalStartTime'], function) + if 'Total' in energy: + self.tasmota_devices[device]['sensors']['ENERGY']['total'] = energy['Total'] + self._set_item_value(device, 'item_power_total', energy['Total'], function) + if 'Yesterday' in energy: + self.tasmota_devices[device]['sensors']['ENERGY']['yesterday'] = energy['Yesterday'] + self._set_item_value(device, 'item_power_yesterday', energy['Yesterday'], function) + if 'Today' in energy: + self.tasmota_devices[device]['sensors']['ENERGY']['today'] = energy['Today'] + self._set_item_value(device, 'item_power_today', energy['Today'], function) + + # DS18B20 sensors + ds18b20 = payload.get('DS18B20') + if ds18b20: + self.logger.info(f"Received Message decoded as DS18B20 Sensor message.") + if not self.tasmota_devices[device]['sensors'].get('DS18B20'): + self.tasmota_devices[device]['sensors']['DS18B20'] = {} + if type(ds18b20) is dict: + if 'Id' in ds18b20: + self.tasmota_devices[device]['sensors']['DS18B20']['id'] = ds18b20['Id'] + self._set_item_value(device, 'item_id', ds18b20['Id'], function) + if 'Temperature' in ds18b20: + self.tasmota_devices[device]['sensors']['DS18B20']['temp'] = ds18b20['Temperature'] + self._set_item_value(device, 'item_temp', ds18b20['Temperature'], function) + + # AM2301 sensors + am2301 = payload.get('AM2301') + if am2301: + self.logger.info(f"Received Message decoded as AM2301 Sensor message.") + if not self.tasmota_devices[device]['sensors'].get('AM2301'): + self.tasmota_devices[device]['sensors']['AM2301'] = {} + if type(am2301) is dict: + if 'Humidity' in am2301: + self.tasmota_devices[device]['sensors']['AM2301']['hum'] = am2301['Humidity'] + self._set_item_value(device, 'item_hum', am2301['Humidity'], function) + if 'Temperature' in am2301: + self.tasmota_devices[device]['sensors']['AM2301']['temp'] = am2301['Temperature'] + self._set_item_value(device, 'item_temp', am2301['Temperature'], function) + if 'DewPoint' in am2301: + self.tasmota_devices[device]['sensors']['AM2301']['dewpoint'] = am2301['DewPoint'] + self._set_item_value(device, 'item_dewpoint', am2301['DewPoint'], function) + + def _handle_lights(self, device, function, payload): + """ + Extracts Light information out of payload and updates plugin dict + + :param device: Device, the Light information shall be handled (equals tasmota_topic) + :type device: str + :param function: Function of Device (equals info_topic) + :type function: str + :param payload: MQTT message payload + :type payload: dict + :return: + """ + hsb = payload.get('HSBColor') + if hsb: + if hsb.count(',') == 2: + hsb = hsb.split(",") + try: + hsb = [int(element) for element in hsb] + except Exception as e: + self.logger.info( + f"Received Data for HSBColor do not contain in values for HSB. Payload was {hsb}. Error was {e}.") + else: + self.logger.info(f"Received Data for HSBColor do not contain values for HSB. Payload was {hsb}.") + self.tasmota_devices[device]['lights']['hsb'] = hsb + self._set_item_value(device, 'item_hsb', hsb, function) + + dimmer = payload.get('Dimmer') + if dimmer: + self.tasmota_devices[device]['lights']['dimmer'] = dimmer + self._set_item_value(device, 'item_dimmer', dimmer, function) + + color = payload.get('Color') + if color: + self.tasmota_devices[device]['lights']['color'] = str(color) + + ct = payload.get('CT') + if ct: + self.tasmota_devices[device]['lights']['ct'] = ct + self._set_item_value(device, 'item_ct', ct, function) + + white = payload.get('White') + if white: + self.tasmota_devices[device]['lights']['white'] = white + self._set_item_value(device, 'item_white', white, function) + + scheme = payload.get('Scheme') + if scheme: + self.tasmota_devices[device]['lights']['scheme'] = scheme + + fade = payload.get('Fade') + if fade: + self.tasmota_devices[device]['lights']['fade'] = bool(fade) + + speed = payload.get('Speed') + if speed: + self.tasmota_devices[device]['lights']['speed'] = speed + + ledtable = payload.get('LedTable') + if ledtable: + self.tasmota_devices[device]['lights']['ledtable'] = bool(ledtable) + + def _handle_power(self, device, function, payload): + """ + Extracts Power information out of payload and updates plugin dict + + :param device: Device, the Power information shall be handled (equals tasmota_topic) + :type device: str + :param function: Function of Device (equals info_topic) + :type function: str + :param payload: MQTT message payload + :type payload: dict + :return: + """ + power_dict = {key: val for key, val in payload.items() if key.startswith('POWER')} + self.tasmota_devices[device]['relais'].update(power_dict) + for power in power_dict: + relay_index = str(power[5:]) + if relay_index == '': + relay_index = '1' + item_relay = 'item_relay' + relay_index + self._set_item_value(device, item_relay, power_dict[power], function) + + def _handle_module(self, device, payload): + """ + Extracts Module information out of payload and updates plugin dict + + :param device: Device, the Module information shall be handled + :type device: str + :param payload: MQTT message payload + :type payload: dict + :return: + """ + module_list = payload.get('Module') + if module_list: + template, module = list(module_list.items())[0] + self.tasmota_devices[device]['module'] = module + self.tasmota_devices[device]['tasmota_template'] = template + + # Zigbee Bridge erkennen und Status setzen + if template == '75': + self.tasmota_zigbee_bridge['status'] = 'discovered' + self.tasmota_zigbee_bridge['device'] = device + self.logger.debug(f"_handle_module, ZigbeeBridge Status is: {self.tasmota_zigbee_bridge}") + + def _handle_zbstatus1(self, device, zbstatus1): + """ + Extracts ZigBee Status1 information out of payload and updates plugin dict + + :param device: Device, the Zigbee Status information shall be handled + :type device: str + :param zbstatus1: List of status information out out mqtt payload + :type zbstatus1: list + :return: + """ + # stat/SONOFF_ZB1/RESULT = {"ZbStatus1":[{"Device":"0x5A45","Name":"DJT11LM_01"},{"Device":"0x67FE","Name":"snzb-02_01"},{"Device":"0x892A","Name":"remote_mini_bl"},{"Device":"0x1FB1"}]} + if type(zbstatus1) is list: + for element in zbstatus1: + friendly_name = element.get('Name') + if friendly_name: + self.tasmota_zigbee_devices[friendly_name] = {} + else: + self.tasmota_zigbee_devices[element['Device']] = {} + # request detailed informatin of all discovered zigbee devices + self._poll_zigbee_devices(device) + else: + self.logger.debug( + f"ZbStatus1 with {zbstatus1} received but not processed. since data was not of type list.") + + def _handle_zbstatus23(self, device, zbstatus23): + """ + Extracts ZigBee Status 2 and 3 information out of payload and updates plugin dict + + :param zbstatus23: ZbStatus2 or ZbStatus 3 part of MQTT message payload + :type zbstatus23: dict + :return: + """ + # [{"Device":"0xD1B8","Name":"E1766_01","IEEEAddr":"0x588E81FFFE28DEC5","ModelId":"TRADFRIopen/closeremote","Manufacturer":"IKEA","Endpoints":[1],"Config":[]}]} + # [{'Device': '0x67FE', 'Name': 'snzb-02_01', 'IEEEAddr': '0x00124B00231E45B8', 'ModelId': 'TH01', 'Manufacturer': 'eWeLink', 'Endpoints': [1], 'Config': ['T01'], 'Temperature': 21.29, 'Humidity': 30.93, 'Reachable': True, 'BatteryPercentage': 100, 'LastSeen': 39, 'LastSeenEpoch': 1619350835, 'LinkQuality': 157}]} + # [{'Device': '0x9EFE', 'IEEEAddr': '0x00158D00067AA8BD', 'ModelId': 'lumi.vibration.aq1', 'Manufacturer': 'LUMI', 'Endpoints': [1, 2], 'Config': [], 'Reachable': True, 'BatteryPercentage': 100, 'LastSeen': 123, 'LastSeenEpoch': 1637134779, 'LinkQuality': 154}] + # [{'Device': '0x0A22', 'IEEEAddr': '0xF0D1B800001571C5', 'ModelId': 'CLA60 RGBW Z3', 'Manufacturer': 'LEDVANCE', 'Endpoints': [1], 'Config': ['L01', 'O01'], 'Dimmer': 128, 'Hue': 253, 'Sat': 250, 'X': 1, 'Y': 1, 'CT': 370, 'ColorMode': 0, 'RGB': 'FF0408', 'RGBb': '810204', 'Power': 1, 'Reachable': True, 'LastSeen': 11, 'LastSeenEpoch': 1638110831, 'LinkQuality': 18}] + + self.logger.debug(f'zbstatus23: {zbstatus23}') + if type(zbstatus23) is list: + for element in zbstatus23: + zigbee_device = element.get('Name') + if not zigbee_device: + zigbee_device = element.get('Device') + if zigbee_device in self.tasmota_zigbee_devices: + if not self.tasmota_zigbee_devices[zigbee_device].get('meta'): + self.tasmota_zigbee_devices[zigbee_device]['meta'] = {} + + # Korrektur des LastSeenEpoch von Timestamp zu datetime + if 'LastSeenEpoch' in element: + element.update({'LastSeenEpoch': datetime.fromtimestamp(element['LastSeenEpoch'] / 1000)}) + self.tasmota_zigbee_devices[zigbee_device]['meta'].update(element) + + # Übertragen der Werte aus der Statusmeldung in Data + bulb = ['Power', 'Dimmer', 'Hue', 'Sat', 'X', 'Y', 'CT', 'ColorMode'] + data = {} + for key in bulb: + x = element.get(key) + if x is not None: + data[key] = x + if data: + self.logger.debug(f"ZbStatus2 or ZbStatus3 received and Bulb detected. Data <{data}> extracted") + if not self.tasmota_zigbee_devices[zigbee_device].get('data'): + self.tasmota_zigbee_devices[zigbee_device]['data'] = {} + self.tasmota_zigbee_devices[zigbee_device]['data'].update(data) + + # Iterate over data and set corresponding items + self.logger.debug(f"Item to be checked for update based in Zigbee Status Message") + for entry in data: + itemtype = f"item_{zigbee_device}.{entry.lower()}" + value = data[entry] + self._set_item_value(device, itemtype, value, 'ZbStatus') + else: + self.logger.debug( + f"ZbStatus2 or ZbStatus3 with {zbstatus23} received but not processed. since data was not of type list.") + + def _handle_rf(self, device, function, payload): + """ + Extracts RF information out of payload and updates plugin dict + + :param device: Device, the RF information shall be handled + :type device: str + :param function: Function of Device (equals info_topic) + :type function: str + :param payload: MQTT message payload + :type payload: dict + :return: + """ + rfreceived = payload.get('RfReceived') + if rfreceived: + self.logger.info(f"Received Message decoded as RF message.") + self.tasmota_devices[device]['rf']['rf_received'] = rfreceived + self._set_item_value(device, 'item_rf_recv', rfreceived['Data'], function) + if type(payload) is dict and ('RfSync' or 'RfLow' or 'RfHigh' or 'RfCode') in payload: + self.logger.info(f"Received Message decoded as RF message.") + if not self.tasmota_devices[device]['rf'].get('rf_send_result'): + self.tasmota_devices[device]['rf']['rf_send_result'] = payload + else: + self.tasmota_devices[device]['rf']['rf_send_result'].update(payload) + if any(item.startswith("RfKey") for item in payload.keys()): + self.logger.info(f"Received Message decoded as RF message.") + self.tasmota_devices[device]['rf']['rfkey_result'] = payload + + def _handle_zbconfig(self, device, payload): + """ + Extracts ZigBee Config information out of payload and updates plugin dict + + :param device: Device, the Zigbee Config information shall be handled + :type device: str + :param payload: MQTT message payload + :type payload: dict + :return: + """ + # stat/SONOFF_ZB1/RESULT = {"ZbConfig":{"Channel":11,"PanID":"0x0C84","ExtPanID":"0xCCCCCCCCAAA8CC84","KeyL":"0xAAA8CC841B1F40A1","KeyH":"0xAAA8CC841B1F40A1","TxRadio":20}} + zbconfig = payload.get('ZbConfig') + if zbconfig: + self.tasmota_devices[device]['zigbee']['zbconfig'] = payload + + def _handle_zbstatus(self, device, payload): + """ + Extracts ZigBee Status information out of payload and updates plugin dict + + :param device: Device, the Zigbee Status information shall be handled + :type device: str + :param payload: MQTT message payload + :type payload: dict + :return: + """ + zbstatus1 = payload.get('ZbStatus1') + if zbstatus1: + self.logger.info(f"Received Message decoded as Zigbee ZbStatus1 message.") + self._handle_zbstatus1(device, zbstatus1) + zbstatus23 = payload.get('ZbStatus2') + if not zbstatus23: + zbstatus23 = payload.get('ZbStatus3') + if zbstatus23: + self.logger.info(f"Received Message decoded as Zigbee ZbStatus2 or ZbStatus3 message.") + self._handle_zbstatus23(device, zbstatus23) + + def _handle_wifi(self, device, payload): + """ + Extracts Wifi information out of payload and updates plugin dict + + :param device: Device, the Zigbee Status information shall be handled + :type device: str + :param payload: MQTT message payload + :type payload: dict + :return: + """ + wifi_signal = payload['Wifi'].get('Signal') + + if wifi_signal: + if isinstance(wifi_signal, str) and wifi_signal.isdigit(): + wifi_signal = int(wifi_signal) + self.logger.info(f"Received Message decoded as Wifi message.") + self.tasmota_devices[device]['wifi_signal'] = wifi_signal + + def _handle_zbbridge_setting(self, payload): + """ + Extracts Zigbee Bridge Setting information out of payload and updates dict + + :param payload: MQTT message payload + :type payload: dict + :return: + """ + if not self.tasmota_zigbee_bridge.get('setting'): + self.tasmota_zigbee_bridge['setting'] = {} + self.tasmota_zigbee_bridge['setting'].update(payload) + + if self.tasmota_zigbee_bridge['setting'] == self.tasmota_zigbee_bridge_stetting: + self.tasmota_zigbee_bridge['status'] = 'set' + self.logger.info(f'_handle_zbbridge_setting: Setting of Tasmota Zigbee Bridge successful.') + + def _update_tasmota_meta(self): + """ + Updates the tasmota meta information in plugin dict + """ + self.tasmota_meta = {} + for tasmota_topic in self.tasmota_devices: + if self.tasmota_devices[tasmota_topic]['relais']: + self.tasmota_meta['relais'] = True + if self.tasmota_devices[tasmota_topic]['rf']: + self.tasmota_meta['rf'] = True + if self.tasmota_devices[tasmota_topic]['lights']: + self.tasmota_meta['lights'] = True + if self.tasmota_devices[tasmota_topic]['sensors'].get('DS18B20'): + self.tasmota_meta['ds18b20'] = True + if self.tasmota_devices[tasmota_topic]['sensors'].get('AM2301'): + self.tasmota_meta['am2301'] = True + if self.tasmota_devices[tasmota_topic]['sensors'].get('ENERGY'): + self.tasmota_meta['energy'] = True + if self.tasmota_devices[tasmota_topic]['zigbee']: + self.tasmota_meta['zigbee'] = True + + def _poll_zigbee_devices(self, device): + """ + Polls information of all discovered zigbee devices from dedicated Zigbee bridge + + :param device: Zigbee bridge, where all Zigbee Devices shall be polled (equal to tasmota_topic) + :type device: str + :return: + """ + self.logger.info("_poll_zigbee_devices: Polling informatiopn of all discovered Zigbee devices") + for zigbee_device in self.tasmota_zigbee_devices: + self.logger.debug(f"_poll_zigbee_devices: publishing 'cmnd/{device}/ZbStatus3 {zigbee_device}'") + self.publish_tasmota_topic('cmnd', device, 'ZbStatus3', zigbee_device) + + def _discover_zigbee_bridge(self, device): + """ + Configures and discovers Zigbee Bridge and all connected zigbee devices + + :param device: Zigbee bridge to be discovered (equal to tasmota_topic) + :type device: str + :return: None + """ + self.logger.info( + "Zigbee Bridge discovered: Prepare Settings and polling information of all connected zigbee devices") + + # Configure ZigBeeBridge + self.logger.debug(f"Configuration of Tasmota Zigbee Bridge to get MQTT Messages in right format") + for setting in self.tasmota_zigbee_bridge_stetting: + self.publish_tasmota_topic('cmnd', device, setting, self.tasmota_zigbee_bridge_stetting[setting]) + self.logger.debug( + f"_discover_zigbee_bridge: publishing to 'cmnd/{device}/setting' with payload {self.tasmota_zigbee_bridge_stetting[setting]}") + + # Request ZigBee Konfiguration + self.logger.info("_discover_zigbee_bridge: Request configuration of Zigbee bridge") + self.logger.debug(f"_discover_zigbee_bridge: publishing 'cmnd/{device}/ZbConfig'") + self.publish_tasmota_topic('cmnd', device, 'ZbConfig', '') + + # Discovery all ZigBee Devices + self.logger.info("_discover_zigbee_bridge: Discover all connected Zigbee devices") + self.logger.debug(f"_discover_zigbee_bridge: publishing 'cmnd/{device}/ZbStatus1'") + self.publish_tasmota_topic('cmnd', device, 'ZbStatus1', '') + + def _identify_device(self, topic): + # ask for status info of each known tasmota_topic, collected during parse_item + self.logger.debug(f"run: publishing 'cmnd/{topic}/Status0'") + self.publish_tasmota_topic('cmnd', topic, 'Status0', '') + + self.logger.debug(f"run: publishing 'cmnd/{topic}/State'") + self.publish_tasmota_topic('cmnd', topic, 'State', '') + + self.logger.debug(f"run: publishing 'cmnd/{topic}/Module'") + self.publish_tasmota_topic('cmnd', topic, 'Module', '') diff --git a/tasmota/_pv_1_2_2/locale.yaml b/tasmota/_pv_1_2_2/locale.yaml new file mode 100755 index 000000000..62bc7ab58 --- /dev/null +++ b/tasmota/_pv_1_2_2/locale.yaml @@ -0,0 +1,23 @@ +# translations for the web interface +plugin_translations: + # Translations for the plugin specially for the web interface + 'Relais': {'de': '=', 'en': 'Relay'} + 'Mac Adresse': {'de': '=', 'en': 'Mac Address'} + 'IP Adresse': {'de': '=', 'en': 'IP Address'} + 'Firmware Version': {'de': '=', 'en': '='} + 'neue Firmware': {'de': '=', 'en': 'new Firmware'} + 'konfiguriert': {'de': '=', 'en': 'configured'} + 'Message Durchsatz': {'de': '=', 'en': 'Message throughput'} + 'letzte Minute': {'de': '=', 'en': 'last minute'} + 'letzte 5 Min.': {'de': '=', 'en': 'last 5 min'} + 'letzte 15 Min.': {'de': '=', 'en': 'last 15 min'} + 'Energie': {'de': '=', 'en': 'Energy'} + + # Alternative format for translations of longer texts: + 'Durchschnittlich Messages je Minute empfangen': + de: '=' + en: 'Messages per minute received on average' + 'Durchschnittlich Messages je Minute gesendet': + de: '=' + en: 'Messages per minute sent on average' + diff --git a/tasmota/_pv_1_2_2/plugin.yaml b/tasmota/_pv_1_2_2/plugin.yaml new file mode 100755 index 000000000..9eccc2ada --- /dev/null +++ b/tasmota/_pv_1_2_2/plugin.yaml @@ -0,0 +1,144 @@ +# Metadata for the plugin +plugin: + # Global plugin attributes + type: gateway # plugin type (gateway, interface, protocol, system, web) + description: + de: 'Plugin zur Steuerung von Switches, die mit Tasmota Firmware ausgestattet sind. Die Kommunikation erfolgt über das MQTT Module von SmartHomeNG.' + en: 'Plugin to control switches which are equipped with Tasmote firmware. Communication is handled through the MQTT module of SmartHomeNG.' + maintainer: msinn + tester: msinn # Who tests this plugin? + state: ready # change to ready when done with development + keywords: iot + documentation: http://smarthomeng.de/user/plugins/tasmota/user_doc.html + support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1520293-support-thread-für-das-tasmota-plugin + + version: 1.2.2 # Plugin version + sh_minversion: 1.7.2 # minimum shNG version to use this plugin +# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) + py_minversion: 3.6 # minimum Python version to use for this plugin + multi_instance: True # plugin supports multi instance + restartable: unknown + classname: Tasmota # class containing the plugin + +parameters: + # Definition of parameters to be configured in etc/plugin.yaml (enter 'parameters: NONE', if section should be empty) + full_topic: + type: str + default: '%prefix%/%topic%/' + description: + de: Vollständiges Topic (Prefix und Topic) zur Kommunikation mit den Tasmota Devices + en: Full topic (prefix und topic) for communication with tasmota devices + + telemetry_period: + type: int + default: 300 + valid_min: 10 + valid_max: 3600 + description: + de: Zeitabstand in Sekunden in dem die Tasmota Devices Telemetrie Daten senden sollen + en: Timeperiod in seconds in which Tasmota devices shall send telemetry data + + webif_pagelength: + type: int + default: 100 + valid_list: + - -1 + - 25 + - 50 + - 100 + description: + de: 'Anzahl an Items, die standardmäßig in einer Web Interface Tabelle pro Seite angezeigt werden' + en: 'Amount of items being listed in a web interface table per page by default' + +item_attributes: + # Definition of item attributes defined by this plugin (enter 'item_attributes: NONE', if section should be empty) + tasmota_topic: + type: str + description: + de: Topic um mit dem Tasmota Device zu kommunizieren (%topic%) + en: Topic to be used to communicate with the tasmota device (%topic%) + + tasmota_attr: + type: str + default: relay + description: + de: "Zu lesendes/schreibendes Attribut des Tasmota Devices. Achtung: Nicht jedes Attribut ist auf allen Device-Typen vorhanden." + en: "Attribute of Tasmota device that shall be read/written. Note: Not every attribute is available on all device types" + valid_list_ci: + - '' + - relay + - online + - voltage + - current + - power + - power_total + - power_yesterday + - power_today + - temp + - hum + - dewpoint + - hsb + - white + - ct + - dimmer + - rf_recv + - rf_send + - rf_key_send + - zb_permit_join + + valid_list_description: + de: + - "" + - "Schalten des Relais -> bool, r/w" + - "Online Status des Tasmota Devices -> bool, r/o" + - "Spannung in Volt bei Tasmota Devices mit ENERGY Sensor -> num, r/o" + - "Strom in Ampere bei Tasmota Devices mit ENERGY Sensor -> num, r/o" + - "Leistung in Watt bei Tasmota Devices mit ENERGY Sensor -> num, r/o" + - "Verbrauch (gesamt) in kWh bei Tasmota Devices mit ENERGY Sensor -> num, r/o" + - "Verbrauch (gestern) in kWh bei Tasmota Devices mit ENERGY Sensor -> num, r/o" + - "Verbrauch (heute) in kWh bei Tasmota Devices mit ENERGY Sensor -> num, r/o" + - "Temperatur in °C bei Tasmota Devices mit TEMP Sensor (DS18B20, AM2301) -> num, r/o" + - "Luftfeuchtigkeit in %rH bei Tasmota Devices mit HUM Sensor (AM2301) -> num, r/o" + - "Taupunkt in °C bei Tasmota Devices mit HUM und TEMP Sensor (AM2301) -> num, r/o" + - "Hue, Saturartion, Brightness (HSB) bei RGBW Tasmota Devices (H801) -> list, r/w" + - "Color Temperature in Kelvin bei RGBW Tasmota Devices (H801) -> num, r/w" + - "Color Temperature in Kelvin bei RGBW Tasmota Devices (H801) -> num, r/w" + - "Dimmwert in % Tasmota Devices -> num, r/w" + - "Emfangene RF Daten bei Tasmota Device mit RF Sendemöglichkeit (SONOFF Bridge) -> dict, r/o" + - "Zu sendende RF Daten bei Tasmota Device mit RF Sendemöglichkeit (SONOFF Bridge) -> dict, r/w" + - "Zu sendende RF-Key Tasmota Device mit RF Sendemöglichkeit (SONOFF Bridge) -> num [1-16], r/w" + - "Schaltet das Pairing an der ZigBee Bridge ein/aus -> bool, r/w" + + tasmota_relay: + type: str + default: '1' + valid_list: + - '1' + - '2' + - '3' + - '4' + description: + de: "Nummer des zu schaltenden Relais im Tasmota Device" + en: "Number of the relay in Tasmota device to use for switching command" + + tasmota_zb_device: + type: str + description: + de: "Friendly Name oder Kurzname des Zigbee Devices. ACHTUNG: Wird der Kurzname verwendet und beginnt dieser mit 0x, muss die Schreibweise '0x9CB9' verwendet werden" + en: "Friendly Name oder Short Name of Zigbee Devices" + + tasmota_zb_attr: + type: str + description: + de: "Schlüssel der Json-Dict, der vom Zigbee-Device bereitgestellt wird; Key aus dem sub-dict 'data' des tasmota_zb_device" + en: "Dict Key of provided data; can be seen in Plugin WebIF" + +item_structs: NONE + # Definition of item-structure templates for this plugin (enter 'item_structs: NONE', if section should be empty) + +plugin_functions: NONE + # Definition of plugin functions defined by this plugin (enter 'plugin_functions: NONE', if section should be empty) + +logic_parameters: NONE + # Definition of logic parameters defined by this plugin (enter 'logic_parameters: NONE', if section should be empty) + diff --git a/tasmota/_pv_1_2_2/user_doc.rst b/tasmota/_pv_1_2_2/user_doc.rst new file mode 100755 index 000000000..50b21e2db --- /dev/null +++ b/tasmota/_pv_1_2_2/user_doc.rst @@ -0,0 +1,117 @@ +.. index:: tasmota +.. index:: Plugins; tasmota +.. index:: mqtt; tasmota Plugin + + +======= +tasmota +======= + +Das Plugin dienst zur Steuerung von Tasmota Devices über MQTT. Zur Aktivierung von MQTT für die Tasmota Devices +bitte die Dokumentation des jeweiligen Devices zu Rate ziehen. + +Unterstützte Funktionen sind: +* Relays eines Tasmota Devices (bis zu 4) +* DS18B20 Temperatursensoren +* AM2301 Sensoren für Temperatur und Luftfeuchte +* RGBW Dimmer (H801) mit Senden und Empfangen von HSB +* RF-Daten Senden und Empfangen mit Sonoff Bridge RF +* Zigbee Daten Empfangen mit Sonoff Zigbee Bridge + + +.. attention:: + + Das Plugin kommuniziert über MQTT und benötigt das mqtt neues Modul, welches die Kommunikation mit dem MQTT Broker + durchführt. Dieses Modul muß geladen und konfiguriert sein, damit das Plugin funktioniert. + + +Konfiguration +============= + +Für die Nutzung eines Tasmota Devices müssen in dem entsprechenden Item die zwei Attribute ``tasmota_topic`` und +``tasmota_attr`` konfiguriert werden, wie im folgenden Beispiel gezeigt: + +.. code-block:: yaml + + schalter: + type: bool + tasmota_topic: delock_switch2 + tasmota_attr: relay + + leistung: + type: num + tasmota_topic: ..:. + tasmota_attr: power + +Für die Nutzung von Zigbee Devices über eine ZigbeeBridge mit Tasmota müssen in dem entsprechenden Item die drei Attribute +``tasmota_topic``, ``tasmota_zb_device`` und ``tasmota_zb_attr`` konfiguriert werden, wie im folgenden Beispiel gezeigt: + +.. code-block:: yaml + + temp: + type: num + tasmota_topic: SONOFF_ZB1 + tasmota_zb_device: snzb02_01 + tasmota_zb_attr: Temperature + +Dabei ist zu beachten, dass bei Verwnendung des Kurznamen (bspw. 0x9CB) zur Identifikation des Zigbee-Gerätes +diese Kurzname in Hochkommata (also '0x9CB') zu setzen ist, um ein korrektes Verarbeiten sicherzustellen. Im Abschnitt +Web Interface gibt es weitere Hinweise zur Konfiguration. + +Vollständige Informationen zur Konfiguration und die vollständige Beschreibung der Item-Attribute sind +unter **plugin.yaml** zu finden. + + +Web Interface des Plugins +========================= + +Tasmota Items +------------- + +Das Webinterface zeigt die Items an, für die ein Tasmota Device konfiguriert ist. + +.. image:: user_doc/assets/webif_tab1.jpg + :class: screenshot + + +Tasmota Devices +--------------- + +Das Webinterface zeigt Informationen zu den konfigurierten Tasmota Devices an, sowie etwa hinzugekommen Devices die +in SmartHomeNG noch nicht konfiguriert (mit einem Item vebunden) sind. + +.. image:: user_doc/assets/webif_tab2.jpg + :class: screenshot + +Ein Klick auf das Tasmota Topic öffnet Konfigurationsseite des Devices. + + +Tasmota Details +--------------- + +Das Webinterface zeigt Informationen mit Werten der Sensoren, Leuchten und RF, falls das jeweilige Tasmota Device diese +Informationen bereitstellt. + +.. image:: user_doc/assets/webif_tab3.jpg + :class: screenshot + +Tasmota Zigbee Devices +---------------------- + +Das Webinterface zeigt Informationen der ZigbeeDevices, die das jeweilige Device bereitstellt. +Dabei werden im jeweilgen Feld "Content Data" die verfügbaren Daten anzeigt. Um diese einem Item zuzuweisen, +muss die 'Device ID' als Wert für das Attribut 'tasmota_zb_device' und ein Key des Dictionary in der Spalte +'Content Data' als Wert für das Attribut 'tasmota_zb_attr' verwendet werden. + +.. image:: user_doc/assets/webif_tab4.jpg + :class: screenshot + + +Broker Information +------------------ + +Das Webinterface zeigt Informationen zum genutzten MQTT Broker an. + +.. image:: user_doc/assets/webif_tab5.jpg + :class: screenshot + diff --git a/tasmota/_pv_1_2_2/user_doc/assets/webif_tab1.jpg b/tasmota/_pv_1_2_2/user_doc/assets/webif_tab1.jpg new file mode 100755 index 000000000..2cd6ba4ea Binary files /dev/null and b/tasmota/_pv_1_2_2/user_doc/assets/webif_tab1.jpg differ diff --git a/tasmota/_pv_1_2_2/user_doc/assets/webif_tab2.jpg b/tasmota/_pv_1_2_2/user_doc/assets/webif_tab2.jpg new file mode 100755 index 000000000..a813ff31c Binary files /dev/null and b/tasmota/_pv_1_2_2/user_doc/assets/webif_tab2.jpg differ diff --git a/tasmota/_pv_1_2_2/user_doc/assets/webif_tab3.jpg b/tasmota/_pv_1_2_2/user_doc/assets/webif_tab3.jpg new file mode 100755 index 000000000..27b61942e Binary files /dev/null and b/tasmota/_pv_1_2_2/user_doc/assets/webif_tab3.jpg differ diff --git a/tasmota/_pv_1_2_2/user_doc/assets/webif_tab4.jpg b/tasmota/_pv_1_2_2/user_doc/assets/webif_tab4.jpg new file mode 100755 index 000000000..a5b5a5f02 Binary files /dev/null and b/tasmota/_pv_1_2_2/user_doc/assets/webif_tab4.jpg differ diff --git a/tasmota/_pv_1_2_2/user_doc/assets/webif_tab5.jpg b/tasmota/_pv_1_2_2/user_doc/assets/webif_tab5.jpg new file mode 100755 index 000000000..4c7bbad5a Binary files /dev/null and b/tasmota/_pv_1_2_2/user_doc/assets/webif_tab5.jpg differ diff --git a/tasmota/_pv_1_2_2/user_doc/assets/webif_tab6.jpg b/tasmota/_pv_1_2_2/user_doc/assets/webif_tab6.jpg new file mode 100755 index 000000000..354188dde Binary files /dev/null and b/tasmota/_pv_1_2_2/user_doc/assets/webif_tab6.jpg differ diff --git a/tasmota/_pv_1_2_2/webif/__init__.py b/tasmota/_pv_1_2_2/webif/__init__.py new file mode 100755 index 000000000..4adc99e05 --- /dev/null +++ b/tasmota/_pv_1_2_2/webif/__init__.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2020- Martin Sinn m.sinn@gmx.de +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.5 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +# import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + + Render the template and return the html file to be delivered to the browser + + :return: contents of the template after beeing rendered + """ + self.plugin.get_broker_info() + + tmpl = self.tplenv.get_template('index.html') + # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin, + webif_pagelength=self.plugin.webif_pagelength, + items=sorted(self.plugin.tasmota_items, key=lambda k: str.lower(k['_path'])), + item_count=len(self.plugin.tasmota_items)) + + @cherrypy.expose + def get_data_html(self, dataSet=None): + """ + Return data to update the webpage + + For the standard update mechanism of the web interface, the dataSet to return the data for is None + + :param dataSet: Dataset for which the data should be returned (standard: None) + :return: dict with the data needed to update the web page. + """ + if dataSet is None: + # get the new data + self.plugin.get_broker_info() + data = dict() + data['broker_info'] = self.plugin._broker + data['broker_uptime'] = self.plugin.broker_uptime() + + data['item_values'] = {} + for item in self.plugin.tasmota_items: + data['item_values'][item.id()] = {} + data['item_values'][item.id()]['value'] = item.property.value + data['item_values'][item.id()]['last_update'] = item.property.last_update.strftime('%d.%m.%Y %H:%M:%S') + data['item_values'][item.id()]['last_change'] = item.property.last_change.strftime('%d.%m.%Y %H:%M:%S') + + data['device_values'] = {} + for device in self.plugin.tasmota_devices: + data['device_values'][device] = {} + data['device_values'][device]['online'] = self.plugin.tasmota_devices[device].get('online', None) + data['device_values'][device]['uptime'] = self.plugin.tasmota_devices[device].get('uptime', None) + data['device_values'][device]['fw_ver'] = self.plugin.tasmota_devices[device].get('fw_ver', None) + data['device_values'][device]['wifi_signal'] = self.plugin.tasmota_devices[device].get('wifi_signal', None) + data['device_values'][device]['sensors'] = self.plugin.tasmota_devices[device].get('sensors', None) + data['device_values'][device]['lights'] = self.plugin.tasmota_devices[device].get('lights', None) + data['device_values'][device]['rf'] = self.plugin.tasmota_devices[device].get('rf', None) + + data['tasmota_zigbee_devices'] = self.plugin.tasmota_zigbee_devices + + data['tasmota_meta'] = self.plugin.tasmota_meta + + # return it as json the the web page + try: + return json.dumps(data, default=str) + except Exception as e: + self.logger.error("get_data_html exception: {}".format(e)) + return {} + return diff --git a/tasmota/_pv_1_2_2/webif/static/img/plugin_logo.svg b/tasmota/_pv_1_2_2/webif/static/img/plugin_logo.svg new file mode 100755 index 000000000..a5a7e695b --- /dev/null +++ b/tasmota/_pv_1_2_2/webif/static/img/plugin_logo.svg @@ -0,0 +1,128 @@ + + + + + + image/svg+xml + + Zeichenfläche 1 + + + + + + + + + + + Zeichenfläche 1 + + + + + + + + + + diff --git a/tasmota/_pv_1_2_2/webif/static/img/readme.txt b/tasmota/_pv_1_2_2/webif/static/img/readme.txt new file mode 100755 index 000000000..1a7c55eef --- /dev/null +++ b/tasmota/_pv_1_2_2/webif/static/img/readme.txt @@ -0,0 +1,6 @@ +This directory is for storing images that are used by the web interface. + +If you want to have your own logo on the top of the web interface, store it here and name it plugin_logo.. + +Extension can be png, svg or jpg + diff --git a/tasmota/_pv_1_2_2/webif/templates/index.html b/tasmota/_pv_1_2_2/webif/templates/index.html new file mode 100755 index 000000000..39b60d0b3 --- /dev/null +++ b/tasmota/_pv_1_2_2/webif/templates/index.html @@ -0,0 +1,610 @@ +{% extends "base_plugin.html" %} + +{% set logo_frame = false %} + + +{% set update_interval = 2000 %} + + +{% block pluginscripts %} + + +{% endblock pluginscripts %} + +{% block headtable %} + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{_('Broker Host')}}{{ p.broker_config.host }}{{_('Broker Port')}}{{ p.broker_config.port }}
{{_('Benutzer')}}{{ p.broker_config.user }}{{_('Passwort')}} + {% if p.broker_config.password %} + {% for letter in p.broker_config.password %}*{% endfor %} + {% endif %} +
{{_('QoS')}}{{ p.broker_config.qos }}{{_('full_topic')}}{{ p.full_topic }}
+{% endblock headtable %} + + + +{% block buttons %} +{% endblock %} + + +{% set tabcount = 6 %} + + + +{% if p.tasmota_items == [] %} + {% set start_tab = 2 %} +{% endif %} + + + +{% set tab1title = _("" ~ p.get_shortname() ~ " Items") %} +{% block bodytab1 %} +
+
+ +
+
+ + + + + + + + + + + + + + {% for item in p.tasmota_items %} + {% set item_id = item.id() %} + + + + + + {% if p.get_iattr_value(item.conf, 'tasmota_relay') is in ['1', '2', '3', '4', '5', '6', '7', '8'] %} + + {% elif p.get_iattr_value(item.conf, 'tasmota_attr') == 'relay' %} + + {% else %} + + {% endif %} + + + + {% endfor %} + +
{{ _('Item') }}{{ _('Typ') }}{{ _('Wert') }}{{ _('Tasmota Topic') }}{{ _('Relais') }}{{ _('Letztes Update') }}{{ _('Letzter Change') }}
{{ item._path }}{{ item._type }}{{ _('.') }}{{ item() }}{{ p.get_iattr_value(item.conf, 'tasmota_topic') }}{{ p.get_iattr_value(item.conf, 'tasmota_relay') }}1-{{ _('.') }}{{ item.last_update().strftime('%d.%m.%Y %H:%M:%S') }}{{ _('.') }}{{ item.last_change().strftime('%d.%m.%Y %H:%M:%S') }}
+
+
+{% endblock %} + + +{% set tab2title = _("" ~ p.get_shortname() ~ " Devices") %} +{% block bodytab2 %} +
+
+ +
+
+ + + + + + + + + + + + + + + + + {% for device in p.tasmota_devices %} + {% if 'fw_ver' in p.tasmota_devices[device] %} + + + + + + + + + + + {% if p.tasmota_devices[device]['wifi_signal'] %} + + {% else %} + + {% endif %} + + {% endif %} + {% endfor %} + +
{{ _('Tasmota Topic') }}{{ _('Online') }}{{ _('Friendy Name') }}{{ _('Mac Adresse') }}{{ _('IP Adresse') }}{{ _('Uptime') }}{{ _('Sensor Type') }}{{ _('Firmware Version') }}{{ _('Module') }}{{ _('Wifi') }}
{{ device }}.{{ p.tasmota_devices[device].online }}{{ p.tasmota_devices[device].friendly_name }}{{ p.tasmota_devices[device].mac }}{{ p.tasmota_devices[device].ip }}.{{ p.tasmota_devices[device].uptime }} + {% if p.tasmota_devices[device]['sensors'] != {} %} + {% for key in p.tasmota_devices[device]['sensors'] %} + {{ key }} + {%if not loop.last%}, {%endif%} + {% endfor %} + {% else %} + - + {% endif %} + .{{ p.tasmota_devices[device].fw_ver }}{{ p.tasmota_devices[device].module }}.{{ p.tasmota_devices[device].wifi_signal }} dBm
+
+
+{% endblock %} + + +{% set tab3title = _("" ~ p.get_shortname() ~ " " ~ _('Details') ~ "") %} +{% block bodytab3 %} +
+
+ + + + + + + + + + + + + + + + + {% for device in p.tasmota_devices %} + {% if p.tasmota_devices[device]['sensors']['ENERGY'] %} + + + + + + + + + + + + {% endif %} + {% endfor %} + +
ENERGY SENSORS
{{ _('Tasmota Topic') }}{{ _('Spannung') }}{{ _('Strom') }}{{ _('Leistung') }}{{ _('Heute') }}{{ _('Gestern') }}{{ _('Gesamt') }}{{ _('Gesamt - Startzeit') }}
{{ device }}{{ p.tasmota_devices[device]['sensors']['ENERGY']['voltage'] }}V.{{ p.tasmota_devices[device]['sensors']['ENERGY']['current'] }}A.{{ p.tasmota_devices[device]['sensors']['ENERGY']['power'] }}W{{ p.tasmota_devices[device]['sensors']['ENERGY']['today'] }}kWh{{ p.tasmota_devices[device]['sensors']['ENERGY']['yesterday'] }}kWh{{ p.tasmota_devices[device]['sensors']['ENERGY']['total'] }}kWh{{ p.tasmota_devices[device]['sensors']['ENERGY']['total_starttime'] }}
+
+
+ +
+ + + + + + + + + + + + + {% if p.tasmota_meta['ds18b20'] == True %} + {% for device in p.tasmota_devices %} + {% if p.tasmota_devices[device]['sensors'] %} + {% if p.tasmota_devices[device]['sensors']['DS18B20'] %} + + + + + + + + {% endif %} + {% endif %} + {% endfor %} + {% endif %} + {% if p.tasmota_meta['am2301'] == True %} + {% for device in p.tasmota_devices %} + {% if p.tasmota_devices[device]['sensors'] %} + {% if p.tasmota_devices[device]['sensors']['AM2301'] %} + + + + + + + + {% endif %} + {% endif %} + {% endfor %} + {% endif %} + +
ENVIRONMENTAL SENSORS
{{ _('Tasmota Topic') }}{{ _('Temperatur') }}{{ _('Luftfeuchtigkeit') }}{{ _('Taupunkt') }}{{ _('1w-ID') }}
{{ device }}{{ p.tasmota_devices[device]['sensors']['DS18B20'].temp }}°C.--{{ p.tasmota_devices[device]['sensors']['DS18B20'].id }}
{{ device }}{{ p.tasmota_devices[device]['sensors']['AM2301'].temp }}°C.{{ p.tasmota_devices[device]['sensors']['AM2301'].hum }}%rH.{{ p.tasmota_devices[device]['sensors']['AM2301'].dewpoint }}°C.-
+
+ +
+ + + + + + + + + + + + + + + + + {% if p.tasmota_meta['lights'] == True %} + {% for device in p.tasmota_devices %} + {% if p.tasmota_devices[device]['lights'] %} + + + + + + + + + + + + {% endif %} + {% endfor %} + {% endif %} + +
LIGHTS
{{ _('Tasmota Topic') }}{{ _('HSB') }}{{ _('Dimmer') }}{{ _('Color') }}{{ _('CT') }}{{ _('Scheme') }}{{ _('Fade') }}{{ _('Speed') }}{{ _('LED-Table') }}
{{ device }}{{ p.tasmota_devices[device]['lights'].hsb }}.{{ p.tasmota_devices[device]['lights'].dimmer }}.{{ p.tasmota_devices[device]['lights'].color }}.{{ p.tasmota_devices[device]['lights'].ct }}.{{ p.tasmota_devices[device]['lights'].scheme }}.{{ p.tasmota_devices[device]['lights'].fade }}.{{ p.tasmota_devices[device]['lights'].speed }}.{{ p.tasmota_devices[device]['lights'].ledtable }}.
+
+ +
+ + + + + + + + + + + + {% if p.tasmota_meta['rf'] == True %} + {% for device in p.tasmota_devices %} + {% if p.tasmota_devices[device]['rf'] %} + + + + + + + {% endif %} + {% endfor %} + {% endif %} + +
RF
{{ _('Tasmota Topic') }}{{ _('RF-Received') }}{{ _('RF-Send Result') }}{{ _('RF-Key Result') }}
{{ device }}{{ p.tasmota_devices[device]['rf'].rf_received }}{{ p.tasmota_devices[device]['rf'].rf_send_result }}{{ p.tasmota_devices[device]['rf'].rfkey_result }}
+
+{% endblock %} + + +{% set tab4title = _("" ~ p.get_shortname() ~ " " ~ _('Zigbee Devices') ~ "") %} +{% block bodytab4 %} +
+ + + + + + + + + + + {% for device in p.tasmota_zigbee_devices %} + + + + + + + {% endfor %} + +
{{ _('#') }}{{ _('Device ID') }}{{ _('Meta Data') }}{{ _('Content Data') }}
{{ loop.index }}{{ device }}{{ p.tasmota_zigbee_devices[device]['meta'] }}{{ p.tasmota_zigbee_devices[device]['data'] }}
+
+{% endblock %} + + +{% set tab5title = _("" ~ " Broker Information") %} +{% block bodytab5 %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if p.broker_monitoring %} + + + + + + + + + + + + {% endif %} + +
{{ 'Broker Version' }}{{ p._broker.version }}{{ connection_result }}
{{ 'Active Clients' }}.{{ p._broker.active_clients }}
{{ 'Subscriptions' }}.{{ p._broker.subscriptions }}
{{ 'Messages stored' }}.{{ p._broker.stored_messages }}
{{ 'Retained Messages' }}.{{ p._broker.retained_messages }}
 
{{ _('Laufzeit') }}.{{ p.broker_uptime() }}
 
+ +{% if p.broker_monitoring %} + + + + + + + + + + + + + + + + + + + + + + + + +
{{ _('Message Durchsatz') }}{{ _('letzte Minute') }}{{ _('letzte 5 Min.') }}{{ _('letzte 15 Min.') }}
{{ _('Durchschnittlich Messages je Minute empfangen') }}.{{ p._broker.msg_rcv_1min }}     .{{ p._broker.msg_rcv_5min }}     .{{ p._broker.msg_rcv_15min }}
{{ _('Durchschnittlich Messages je Minute gesendet') }}.{{ p._broker.msg_snt_1min }}     .{{ p._broker.msg_snt_5min }}     .{{ p._broker.msg_snt_15min }}
+{% endif %} +{% endblock %} + + +{% set tab6title = _("" ~ p.get_shortname() ~ " " ~ _('Maintenance') ~ "") %} +{% block bodytab6 %} +
+ + + + + + + + + {% for device in p.tasmota_devices %} + + + + + {% endfor %} + + + + + + + + + +
{{ _('Tasmota Device') }}{{ _('Tasmota Device Details') }}
{{ device }}{{ p.tasmota_devices[device] }}
p.tasmota_zigbee_bridge{{ p.tasmota_zigbee_bridge }}
META-Daten{{ p.tasmota_meta }}
+
+ +
+ + + + + + + + + {% for device in p.tasmota_zigbee_devices %} + + + + + {% endfor %} + +
{{ _('Zigbee Device') }}{{ _('Zigbee Device Details') }}
{{ device }}{{ p.tasmota_zigbee_devices[device] }}
+
+{% endblock %} + + + diff --git a/tasmota/plugin.yaml b/tasmota/plugin.yaml index 9eccc2ada..fcef9cc87 100755 --- a/tasmota/plugin.yaml +++ b/tasmota/plugin.yaml @@ -5,17 +5,17 @@ plugin: description: de: 'Plugin zur Steuerung von Switches, die mit Tasmota Firmware ausgestattet sind. Die Kommunikation erfolgt über das MQTT Module von SmartHomeNG.' en: 'Plugin to control switches which are equipped with Tasmote firmware. Communication is handled through the MQTT module of SmartHomeNG.' - maintainer: msinn + maintainer: sisamiwe tester: msinn # Who tests this plugin? state: ready # change to ready when done with development keywords: iot documentation: http://smarthomeng.de/user/plugins/tasmota/user_doc.html support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1520293-support-thread-für-das-tasmota-plugin - version: 1.2.2 # Plugin version - sh_minversion: 1.7.2 # minimum shNG version to use this plugin -# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) - py_minversion: 3.6 # minimum Python version to use for this plugin + version: 1.4.0 # Plugin version + sh_minversion: 1.9.3 # minimum shNG version to use this plugin +# sh_maxversion: # maximum shNG version to use this plugin (leave empty if latest) +# py_minversion: # minimum Python version to use for this plugin multi_instance: True # plugin supports multi instance restartable: unknown classname: Tasmota # class containing the plugin @@ -26,8 +26,8 @@ parameters: type: str default: '%prefix%/%topic%/' description: - de: Vollständiges Topic (Prefix und Topic) zur Kommunikation mit den Tasmota Devices - en: Full topic (prefix und topic) for communication with tasmota devices + de: 'Vollständiges Topic (Prefix und Topic) zur Kommunikation mit den Tasmota Devices' + en: 'Full topic (prefix und topic) for communication with tasmota devices' telemetry_period: type: int @@ -35,23 +35,11 @@ parameters: valid_min: 10 valid_max: 3600 description: - de: Zeitabstand in Sekunden in dem die Tasmota Devices Telemetrie Daten senden sollen - en: Timeperiod in seconds in which Tasmota devices shall send telemetry data - - webif_pagelength: - type: int - default: 100 - valid_list: - - -1 - - 25 - - 50 - - 100 - description: - de: 'Anzahl an Items, die standardmäßig in einer Web Interface Tabelle pro Seite angezeigt werden' - en: 'Amount of items being listed in a web interface table per page by default' + de: 'Zeitabstand in Sekunden in dem die Tasmota Devices Telemetrie Daten senden sollen' + en: 'Timeperiod in seconds in which Tasmota devices shall send telemetry data' + item_attributes: - # Definition of item attributes defined by this plugin (enter 'item_attributes: NONE', if section should be empty) tasmota_topic: type: str description: @@ -64,8 +52,7 @@ item_attributes: description: de: "Zu lesendes/schreibendes Attribut des Tasmota Devices. Achtung: Nicht jedes Attribut ist auf allen Device-Typen vorhanden." en: "Attribute of Tasmota device that shall be read/written. Note: Not every attribute is available on all device types" - valid_list_ci: - - '' + valid_list: - relay - online - voltage @@ -84,11 +71,21 @@ item_attributes: - rf_recv - rf_send - rf_key_send + - rf_key_recv + - rf_key - zb_permit_join - + - zb_forget + - zb_ping + - power_total + - power_today + - power_yesterday + - analog_temp + - analog_temp1 + - analog_a0 + - analog_range + - esp32_temp valid_list_description: de: - - "" - "Schalten des Relais -> bool, r/w" - "Online Status des Tasmota Devices -> bool, r/o" - "Spannung in Volt bei Tasmota Devices mit ENERGY Sensor -> num, r/o" @@ -100,45 +97,187 @@ item_attributes: - "Temperatur in °C bei Tasmota Devices mit TEMP Sensor (DS18B20, AM2301) -> num, r/o" - "Luftfeuchtigkeit in %rH bei Tasmota Devices mit HUM Sensor (AM2301) -> num, r/o" - "Taupunkt in °C bei Tasmota Devices mit HUM und TEMP Sensor (AM2301) -> num, r/o" - - "Hue, Saturartion, Brightness (HSB) bei RGBW Tasmota Devices (H801) -> list, r/w" + - "Hue, Saturation, Brightness (HSB) bei RGBW Tasmota Devices (H801) -> list, r/w" - "Color Temperature in Kelvin bei RGBW Tasmota Devices (H801) -> num, r/w" - "Color Temperature in Kelvin bei RGBW Tasmota Devices (H801) -> num, r/w" - "Dimmwert in % Tasmota Devices -> num, r/w" - - "Emfangene RF Daten bei Tasmota Device mit RF Sendemöglichkeit (SONOFF Bridge) -> dict, r/o" - - "Zu sendende RF Daten bei Tasmota Device mit RF Sendemöglichkeit (SONOFF Bridge) -> dict, r/w" - - "Zu sendende RF-Key Tasmota Device mit RF Sendemöglichkeit (SONOFF Bridge) -> num [1-16], r/w" + - "Empfangene RF Daten bei Tasmota Device mit RF Sendemöglichkeit (SONOFF RF Bridge) -> dict, r/o" + - "Zu sendende RF Daten bei Tasmota Device mit RF Sendemöglichkeit (SONOFF RF Bridge) -> dict {'RfSync': 12220, 'RfLow': 440, 'RfHigh': 1210, 'RfCode':'#F06104'}, r/w" + - "Zu sendender RF-Key Tasmota Device mit RF Sendemöglichkeit (SONOFF RF Bridge) -> num [1-16], r/w" + - "Zu empfangender RF-Key Tasmota Device mit RF Sendemöglichkeit (SONOFF RF Bridge) -> num [1-16], r/w" + - 'RF Key' - "Schaltet das Pairing an der ZigBee Bridge ein/aus -> bool, r/w" + - "Löscht das Zigbee-Gerät aus dem Item Wert aus der Liste bekannter Geräte in der Zigbee-Bridge -> str, r/w" + - "Sendet ein Ping zum Zigbee-Gerät aus dem Item Wert -> str, r/w" + - "Gemessener Gesamtenergieverbrauch" + - "Gemessener Energieverbrauch heute" + - "Gemessener Energieverbrauch gestern" + - "Temperatur am Analogeingang" + - "Temperatur am Analogeingang1" + - "ADC-Eingang eines ESPs" + - "ADC-Eingang eines ESPs" + - "Temperatur des ESP32" tasmota_relay: - type: str - default: '1' - valid_list: - - '1' - - '2' - - '3' - - '4' + type: int + default: 1 + valid_min: 1 + valid_max: 4 description: de: "Nummer des zu schaltenden Relais im Tasmota Device" en: "Number of the relay in Tasmota device to use for switching command" - + + tasmota_rf_details: + type: str + default: 1 + description: + de: "Nummer des auszulösenden RF Keys im Tasmota Device=Aktion bei Empfang" + en: "Number of rf keys to be used for sending command" + tasmota_zb_device: type: str description: de: "Friendly Name oder Kurzname des Zigbee Devices. ACHTUNG: Wird der Kurzname verwendet und beginnt dieser mit 0x, muss die Schreibweise '0x9CB9' verwendet werden" en: "Friendly Name oder Short Name of Zigbee Devices" - - tasmota_zb_attr: + + tasmota_zb_group: + type: num + description: + de: "Zigbee Control Group: Werte werden an diese Gruppe gesendet. Gruppennachrichten werden nicht empfangen. https://tasmota.github.io/docs/Device-Groups/#zigbee" + en: "Zigbee Control Group: Values will be sent to group. return messages will not be received" + + tasmota_zb_attr: type: str description: - de: "Schlüssel der Json-Dict, der vom Zigbee-Device bereitgestellt wird; Key aus dem sub-dict 'data' des tasmota_zb_device" + de: "Schlüssel der Json-Dict, der vom Zigbee-Device bereitgestellt wird; Key aus dem dict des tasmota_zb_device" en: "Dict Key of provided data; can be seen in Plugin WebIF" + valid_list_ci: + - device + - power + - dimmer + - hue + - sat + - ct + - ct_k + - temperature + - humidity + - reachable + - batterypercentage + - batterylastseenepoch + - lastseen + - lastseenepoch + - linkquality + - ieeeaddr + - modelid + - manufacturer + - colormode + - zonestatus + - contact + - movement + - colortempstepup + - colortempstepdown + - dimmerstepup + - dimmerstepdown + - dimmermove + - aqaravibrationmode + - aqaravibration505 + - batteryvoltage + - shutterclose + - shutteropen + - endpoint + - huemove + - 0300!0a + - 0300!01 + - 0300!03 + - 0300!4c + - 0006!00 + - 0006!01 + - 0008!01 + - 0008!02 + - 0008!03 + - 0008!04 + - 0008!05 + + valid_list_description: + de: + - "Geräte_ID Kurzform -> str, r/o" + - "Schalter true/false -> bool, r/w" + - "Helligkeit 0-100 -> num, r/w" + - "Farbwert 0-360 -> num, r/w" + - "Sättigung 0-100 -> num, r/w" + - "Farbtemperatur (mired scale), 150-500 -> num, r/w" + - "Farbtemperatur (Kelvin), 2000-6700 -> num, r/w" + - "Temperatur -> num, r/o" + - "Feuchtigkeit -> num, r/o" + - "Erreichbarkeit -> bool, r/o" + - "Batteriefüllung in % -> num, r/o" + - "Letzte Batteriemeldung -> datetime, r/o" + - "Letzter Kontakt vor xx Sekunden -> num, r/o" + - "Letzter Kontakt -> datetime, r/o" + - "Verbindungsqualität -> num, r/o" + - "IEEE-Adresse -> str, r/o" + - "Model-ID -> str, r/o" + - "Hersteller -> str, r/o" + - "Farbmodus -> num, r/o" + - "Zonenstatus -> num, r/o" + - "Kontakt -> bool, r/o" + - "Bewegung -> bool, r/o" + - "Farbtemperatur +" + - "Farbtemperatur -" + - "Dimmer +" + - "Dimmer -" + - "Dimmer" + - "aqaravibrationmode" + - "aqaravibration505" + - "Batteriespannung" + - "Rollo schließen" + - "Rollo öffnen" + - "Endlage erreicht" + - "Farbbewegung Hue" + - "0300!0a" + - "0300!01" + - "0300!03" + - "0300!4c" + - "0006!00" + - "0006!01" + - "0008!01" + - "0008!02" + - "0008!03" + - "0008!04" + - "0008!05" + + tasmota_zb_cluster: + type: bool + default: False + description: + de: "Ergänzung des Sendebefehls um entsprechendes Zigbee-Cluster" + en: "Use zigbee cluster in send command additionally" + + tasmota_sml_device: + type: str + description: + de: "Name des Smartmeter (SML Device)" + en: "Name of smartmeter (SML Device)" + + tasmota_sml_attr: + type: str + description: + de: "Smartmeter Attribut; muss dem Key des Dictionary dem SML Devices entsprechen" + en: "Smartmeter attribute; need to be key of SML device dictionary" + + tasmota_admin: + type: str + default: delete_retained_messages + description: + de: "" + en: "" + valid_list: + - delete_retained_messages item_structs: NONE - # Definition of item-structure templates for this plugin (enter 'item_structs: NONE', if section should be empty) plugin_functions: NONE - # Definition of plugin functions defined by this plugin (enter 'plugin_functions: NONE', if section should be empty) logic_parameters: NONE - # Definition of logic parameters defined by this plugin (enter 'logic_parameters: NONE', if section should be empty) + diff --git a/tasmota/user_doc.rst b/tasmota/user_doc.rst index 50b21e2db..d515a045f 100755 --- a/tasmota/user_doc.rst +++ b/tasmota/user_doc.rst @@ -11,12 +11,16 @@ Das Plugin dienst zur Steuerung von Tasmota Devices über MQTT. Zur Aktivierung bitte die Dokumentation des jeweiligen Devices zu Rate ziehen. Unterstützte Funktionen sind: -* Relays eines Tasmota Devices (bis zu 4) -* DS18B20 Temperatursensoren -* AM2301 Sensoren für Temperatur und Luftfeuchte -* RGBW Dimmer (H801) mit Senden und Empfangen von HSB -* RF-Daten Senden und Empfangen mit Sonoff Bridge RF -* Zigbee Daten Empfangen mit Sonoff Zigbee Bridge + * Relays eines Tasmota Devices (bis zu 4) + * DS18B20 Temperatursensoren + * AM2301 Sensoren für Temperatur und Luftfeuchte + * SHT3X Sensoren für Temperatur und Luftfeuchte + * ADC-Eingang eines ESPs + * interner Temperatursensor eines ESP32 + * RGBW Dimmer (H801) mit Senden und Empfangen von HSB + * RF-Daten Senden und Empfangen mit Sonoff Bridge RF + * Zigbee Daten Empfangen mit Sonoff Zigbee Bridge + * Tasmota SML .. attention:: @@ -44,7 +48,8 @@ Für die Nutzung eines Tasmota Devices müssen in dem entsprechenden Item die zw tasmota_attr: power Für die Nutzung von Zigbee Devices über eine ZigbeeBridge mit Tasmota müssen in dem entsprechenden Item die drei Attribute -``tasmota_topic``, ``tasmota_zb_device`` und ``tasmota_zb_attr`` konfiguriert werden, wie im folgenden Beispiel gezeigt: +``tasmota_topic``, ``tasmota_zb_device`` oder ``tasmota_zb_group`` und ``tasmota_zb_attr`` konfiguriert werden, wie im +folgenden Beispiel gezeigt: .. code-block:: yaml @@ -54,9 +59,61 @@ Für die Nutzung von Zigbee Devices über eine ZigbeeBridge mit Tasmota müssen tasmota_zb_device: snzb02_01 tasmota_zb_attr: Temperature -Dabei ist zu beachten, dass bei Verwnendung des Kurznamen (bspw. 0x9CB) zur Identifikation des Zigbee-Gerätes -diese Kurzname in Hochkommata (also '0x9CB') zu setzen ist, um ein korrektes Verarbeiten sicherzustellen. Im Abschnitt -Web Interface gibt es weitere Hinweise zur Konfiguration. +Für die Nutzung von SML Devices über ein Tasmota-Gerät müssen in dem entsprechenden Item die drei Attribute +``tasmota_topic``, ``tasmota_sml_device`` und ``tasmota_sml_attr`` konfiguriert werden, wie im +folgenden Beispiel gezeigt: + +.. code-block:: yaml + + smartmeter_1: + type: bool + tasmota_topic: tasmota_sml2mqtt + tasmota_sml_device: MT631 + tasmota_attr: online + + volt_p1: + type: num + tasmota_topic: ..:. + tasmota_sml_device: ..:. + tasmota_sml_attr: volt_p1 + + total_in: + type: num + tasmota_topic: ..:. + tasmota_sml_device: ..:. + tasmota_sml_attr: total_in + +Dabei definiert + + - ``tasmota_topic`` die Tasmota-Topic des Gerätes, an dem der SML-Lesekopf angeschlossen ist. + - ``tasmota_sml_device`` den Namen des SML-Lesekopfes (Sensorname) + - ``tasmota_sml_attr`` den Namen des Keys aus dem Werte-Dictionary, dass dem Item zugewiesen werden soll. + +Die/Eine MQTT Message zum Beispiel oben. +.. code-block:: text + ``tele/tasmota_sml2mqtt/SENSOR = {"Time":"2023-01-27T17:20:45","MT631":{"Total_in":0001.000}}`` + +Den Namen des SML-Devices (hier MT631), die Keys für das gelieferte Dictionary (Zuweisung des Werte) etc. wird direkt im +Tasmota-Script zum Konfiguration des SML-Devices definiert. + + .. code-block:: text + >D + >B + + =>sensor53 r + >M 1 + +1,3,s,0,9600,MT631 + 1,77070100010800ff@1000,Gesamtverbrauch,KWh,Total_in,2 + 1,77070100100700ff@1,aktueller Verbrauch,W,Power_curr,2 + # + +Der Sendezykus der Werte über ebenfalls in der Konfiguration des Scripts mit definiert. +"number of decimal places. Add 16 to transmit the data immediately. Otherwise it is transmitted on TelePeriod only." +Siehe hierzu: https://tasmota.github.io/docs/Smart-Meter-Interface/#meter-metrics + + .. code-block:: text + 1,1-0:1.8.0*255(@1,consumption,KWh,Total_in,4 precision of 4, transmitted only on TelePeriod + 1,1-0:1.8.0*255(@1,consumption,KWh,Total_in,20 precision of 4, transmitted immediately (4 + 16 = 20) Vollständige Informationen zur Konfiguration und die vollständige Beschreibung der Item-Attribute sind unter **plugin.yaml** zu finden. @@ -95,6 +152,7 @@ Informationen bereitstellt. .. image:: user_doc/assets/webif_tab3.jpg :class: screenshot + Tasmota Zigbee Devices ---------------------- @@ -115,3 +173,8 @@ Das Webinterface zeigt Informationen zum genutzten MQTT Broker an. .. image:: user_doc/assets/webif_tab5.jpg :class: screenshot + +Tasmota Maintenance +------------------- + +Wenn der LogLevel des Plugin "DEVELOP" ist, erscheint ein weiterer Tab mit weiteren Informationen zum Plugin. diff --git a/tasmota/user_doc/assets/webif_tab1.jpg b/tasmota/user_doc/assets/webif_tab1.jpg index 2cd6ba4ea..a86e7708e 100755 Binary files a/tasmota/user_doc/assets/webif_tab1.jpg and b/tasmota/user_doc/assets/webif_tab1.jpg differ diff --git a/tasmota/user_doc/assets/webif_tab2.jpg b/tasmota/user_doc/assets/webif_tab2.jpg index a813ff31c..e47fb487e 100755 Binary files a/tasmota/user_doc/assets/webif_tab2.jpg and b/tasmota/user_doc/assets/webif_tab2.jpg differ diff --git a/tasmota/user_doc/assets/webif_tab3.jpg b/tasmota/user_doc/assets/webif_tab3.jpg index 27b61942e..c3b01d86e 100755 Binary files a/tasmota/user_doc/assets/webif_tab3.jpg and b/tasmota/user_doc/assets/webif_tab3.jpg differ diff --git a/tasmota/user_doc/assets/webif_tab4.jpg b/tasmota/user_doc/assets/webif_tab4.jpg index a5b5a5f02..951ac0f42 100755 Binary files a/tasmota/user_doc/assets/webif_tab4.jpg and b/tasmota/user_doc/assets/webif_tab4.jpg differ diff --git a/tasmota/user_doc/assets/webif_tab5.jpg b/tasmota/user_doc/assets/webif_tab5.jpg index 4c7bbad5a..412b380a7 100755 Binary files a/tasmota/user_doc/assets/webif_tab5.jpg and b/tasmota/user_doc/assets/webif_tab5.jpg differ diff --git a/tasmota/webif/__init__.py b/tasmota/webif/__init__.py index 4adc99e05..29f12a867 100755 --- a/tasmota/webif/__init__.py +++ b/tasmota/webif/__init__.py @@ -2,14 +2,12 @@ # vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab ######################################################################### # Copyright 2020- Martin Sinn m.sinn@gmx.de +# Copyright 2021- Michael Wenzel wenzel_michael@web.de ######################################################################### # This file is part of SmartHomeNG. # https://www.smarthomeNG.de # https://knx-user-forum.de/forum/supportforen/smarthome-py # -# Sample plugin for new plugins to run with SmartHomeNG version 1.5 and -# upwards. -# # SmartHomeNG is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or @@ -36,7 +34,6 @@ # ------------------------------------------ import cherrypy -# import csv from jinja2 import Environment, FileSystemLoader @@ -65,16 +62,22 @@ def index(self, reload=None): Render the template and return the html file to be delivered to the browser - :return: contents of the template after beeing rendered + :return: contents of the template after being rendered """ self.plugin.get_broker_info() + pagelength = self.plugin.get_parameter_value('webif_pagelength') tmpl = self.tplenv.get_template('index.html') - # add values to be passed to the Jinja2 template eg: tmpl.render(p=self.plugin, interface=interface, ...) + return tmpl.render(p=self.plugin, - webif_pagelength=self.plugin.webif_pagelength, - items=sorted(self.plugin.tasmota_items, key=lambda k: str.lower(k['_path'])), - item_count=len(self.plugin.tasmota_items)) + webif_pagelength=pagelength, + items=self.plugin.tasmota_items, + item_count=len(self.plugin.tasmota_items), + plugin_shortname=self.plugin.get_shortname(), + plugin_version=self.plugin.get_version(), + plugin_info=self.plugin.get_info(), + maintenance=True if self.plugin.log_level == 10 else False, + ) @cherrypy.expose def get_data_html(self, dataSet=None): @@ -103,19 +106,17 @@ def get_data_html(self, dataSet=None): data['device_values'] = {} for device in self.plugin.tasmota_devices: data['device_values'][device] = {} - data['device_values'][device]['online'] = self.plugin.tasmota_devices[device].get('online', None) - data['device_values'][device]['uptime'] = self.plugin.tasmota_devices[device].get('uptime', None) - data['device_values'][device]['fw_ver'] = self.plugin.tasmota_devices[device].get('fw_ver', None) - data['device_values'][device]['wifi_signal'] = self.plugin.tasmota_devices[device].get('wifi_signal', None) - data['device_values'][device]['sensors'] = self.plugin.tasmota_devices[device].get('sensors', None) - data['device_values'][device]['lights'] = self.plugin.tasmota_devices[device].get('lights', None) - data['device_values'][device]['rf'] = self.plugin.tasmota_devices[device].get('rf', None) + data['device_values'][device]['online'] = self.plugin.tasmota_devices[device].get('online', '-') + data['device_values'][device]['uptime'] = self.plugin.tasmota_devices[device].get('uptime', '-') + data['device_values'][device]['fw_ver'] = self.plugin.tasmota_devices[device].get('fw_ver', '-') + data['device_values'][device]['wifi_signal'] = self.plugin.tasmota_devices[device].get('wifi_signal', '-') + data['device_values'][device]['sensors'] = self.plugin.tasmota_devices[device].get('sensors', '-') + data['device_values'][device]['lights'] = self.plugin.tasmota_devices[device].get('lights', '-') + data['device_values'][device]['rf'] = self.plugin.tasmota_devices[device].get('rf', '-') data['tasmota_zigbee_devices'] = self.plugin.tasmota_zigbee_devices - data['tasmota_meta'] = self.plugin.tasmota_meta - - # return it as json the the web page + # return it as json the web page try: return json.dumps(data, default=str) except Exception as e: diff --git a/tasmota/webif/templates/index.html b/tasmota/webif/templates/index.html index 39b60d0b3..da031567f 100755 --- a/tasmota/webif/templates/index.html +++ b/tasmota/webif/templates/index.html @@ -1,32 +1,99 @@ {% extends "base_plugin.html" %} - {% set logo_frame = false %} -{% set update_interval = 2000 %} - - +{% set update_interval = [(((10 * item_count) / 1000) | round | int) * 1000, 5000]|max %} + + +{% block pluginstyles %} + +{% endblock pluginstyles %} + + {% block pluginscripts %} +{% endblock %} diff --git a/webpush/user_doc.rst b/webpush/user_doc.rst new file mode 100644 index 000000000..34e475474 --- /dev/null +++ b/webpush/user_doc.rst @@ -0,0 +1,170 @@ + +.. index:: Plugins; webpush +.. index:: webpush + +======= +webpush +======= + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + +Plugin zum Versenden von web push Nachrichten an Browser Clients. + +Anforderungen +============= +Auf Client Seite wird ein Browser benötigt der web push Nachrichten unterstützt. Dies ist bei nahezu allen Browsern der +Fall. Lediglich bei einigen mobilen Android Geräten unterstützt der Standardbrowser diese Funktionalität nicht, wie +z.B. der MiBrowser auf Xiaomi Android Geräten. Für den größten Funktionsumfang von web push Nachrichten wird jedoch +der Chrome Browser empfohlen. + +**Der web push Dienst erfordert SSL Verschlüsselung.** Auch selbst signierte Zertifikate werden bei manchen +Browsern wie z.B. dem Chrome nicht akzeptiert, Firefox hingegen funktioniert mit https über selbst signierte +Zertifikate. + +ACHTUNG: +Der Firefox Browser auf Android enthält einen Bug, weshalb web push Nachrichten nicht immer zugestellt werden. Dazu +gibt es jedoch schon seit einiger Zeit ein Issue (https://github.com/mozilla-mobile/fenix/issues/19152). + + +Notwendige Software +------------------- + +Für die Kommunikation wird die Python-Bibliothek pywebpush benötigt. Des weiteren wird py-vapid für die Schlüssel +Verwaltung benutzt. + +Diese werden bei der ersten Verwendung des Plugins automatisch zu SmarthomeNG hinzugefügt. + +| + +Konfiguration +============= + +Die Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/webpush` beschrieben. + +plugin.yaml +----------- + +Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. + +.. code-block:: yaml + + webpush: + plugin_name: webpush + grouplist: + - alarm + - info + +Items +----- + +Für die Kommunikation und die Übertragung der Einstellungen vom Plugin an die Visu des Clients müssen die Items aus dem +webpush.basic Struct in der Konfiguration enthalten sein, ohne diese startet das Plugin nicht. Weiters sind diese +drei Items auch die notwendigen Parameter für das webpush.config Widget. + +.. code-block:: yaml + + System: + webpush: + struct: webpush.basic + +Funktionen +---------- + +Logik Funktion zum senden einer Nachricht an eine Gruppe: + +**sh.webpush.sendPushNotification** (msg, group, title="", url="", requireInteraction=True, icon="", badge="", image="", +silent=False, vibrate=[], ttl=604800, highpriority=True, returnval=True, timestamp=True) + +Die Funktion kann aber auch in einem eval oder on_change bzw. on_update Ausdruck verwendet werden, dazu ist der +returnval Parameter hilfreich z.B.: + +.. code-block:: yaml + + TestBool: + type: bool + eval: sh.webpush.sendPushNotification("Test Bool" + str(value), "info", "INFO Bool", returnval=value) + TestNum: + type: num + eval: sh.webpush.sendPushNotification("Test Num = 100", "alarm", "ALARM Num", returnval=value) if int(value)==100 else value + + TestBoolUpdate: + type: bool + on_update: + - sh.webpush.sendPushNotification("Test Bool", "info", "INFO Bool", returnval=None) if int(value)==1 else None + TestNumChange: + type: num + on_change: + - sh.webpush.sendPushNotification("Test Num = 50", "alarm", "ALARM Num", returnval=None) if int(value)==50 else None + - sh.webpush.sendPushNotification("Test Num = 100", "alarm", "ALARM Num", returnval=None) if int(value)==100 else None + +Für eine genaue Beschreibung aller Parameter, bitte die aus der plugin.yaml erzeugte Dokumentation beachten. + +Infos zum web push Standard sind unter folgenden Links zu finden: + +[1] https://www.rfc-editor.org/rfc/rfc8030.txt + +[2] https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification + +[3] https://developer.mozilla.org/en-US/docs/Web/API/Push_API + +Weitere Infos (aus [1] Seite 12) zum highpriority Parameter der die Urgency von normal auf high stellt: + +.. list-table:: Urgency Parameter + :widths: 15 45 40 + :header-rows: 1 + + * - Urgency + - Device State + - Example Application Scenario + * - very-low + - On power and Wi-Fi + - Advertisements + * - low + - On either power or Wi-Fi + - Topic updates + * - normal + - On neither power nor Wi-Fi + - Chat or Calendar Message + * - high + - Low battery + - Incoming phone call or time-sensitive alert + +| + +SV Widget +========= + +Nachfolgend sind die Parameter für das Widget aufgelistet. + +.. code-block:: html + + {{ webpush.config(id, grouplist, publickey, fromclient, buttontext) }} + +Eine Beispielhafte Verwendung könnte dabei so aussehen: + +.. code-block:: html + + {{ webpush.config('', 'System.webpush.config.grouplist', 'System.webpush.config.publickey', 'System.webpush.comunication.fromclient', 'Übernehmen') }} + +| + +Web Interface +============= + +Im Webinterface werden die Grundlegenden Parameter des Plugins angezeigt. Weiters ist dort eine Auflistung der Anzahl an +Abonnenten pro Gruppe gezeigt. Über einen Button kann die Datenbank geleert werden. Achtung dadurch werden alle +Abonnenten gelöscht und können nicht wiederhergestellt werden, jeder Client muss sich erneut zu Nachrichten Gruppen +anmelden. + + +Credits +======= + +* SmartHome NG Team +* WebPush libraries Team (https://github.com/web-push-libs) and their [pywebpush](https://github.com/web-push-libs/pywebpush) and [py-vapid](https://github.com/web-push-libs/vapid) projects) diff --git a/webpush/webif/static/img/plugin_logo.png b/webpush/webif/static/img/plugin_logo.png new file mode 100644 index 000000000..7242e9562 Binary files /dev/null and b/webpush/webif/static/img/plugin_logo.png differ diff --git a/webpush/webif/static/img/readme.txt b/webpush/webif/static/img/readme.txt new file mode 100644 index 000000000..1a7c55eef --- /dev/null +++ b/webpush/webif/static/img/readme.txt @@ -0,0 +1,6 @@ +This directory is for storing images that are used by the web interface. + +If you want to have your own logo on the top of the web interface, store it here and name it plugin_logo.. + +Extension can be png, svg or jpg + diff --git a/webpush/webif/templates/index.html b/webpush/webif/templates/index.html new file mode 100644 index 000000000..20c4b5ccd --- /dev/null +++ b/webpush/webif/templates/index.html @@ -0,0 +1,126 @@ +{% extends "base_plugin.html" %} + +{% set logo_frame = false %} + +{% block headtable %} + + + + + + + + + + + + + + + + + +
Application Server Key{{ p.getPublicKey() }}
{{ p.translate("Database path") }}{{ p.getDatabasePath() }}
{{ p.translate("Private key path") }}{{ p.getPrivateKeyFilePath() }}
+{% endblock headtable %} + + + +{% block buttons %} + +{% endblock %} + + +{% set tabcount = 2 %} + + + + +{% set start_tab = 1 %} + + +{% set tab1title = "" ~ p.get_shortname() ~ " " %} +{% block bodytab1 %} +
+ + + + + + + + + +
+
+
+ +
+
+
+
+

{{ p.translate("Message groups") }} ({{ p.getSubscriptionGroupCount() }})

+ + + + + + + + + {% for group, count in p.getSubscritionsPerGroup() %} + + + + + {% endfor %} + +
{{ p.translate("Group") }}{{ p.translate("Number of subscribers") }}
{{ group }}{{ count }}
+
+ +
+{% endblock bodytab1 %} + + +{% set tab2title = "" ~ p.get_shortname() ~ " Items" %} +{% block bodytab2 %} +
+

Basic Items

+ + + + + + + + + + {% for name, path, value in p.getPluginItems() %} + + + + + + {% endfor %} + +
{{ p.translate("Item name") }}{{ p.translate("Item path") }}{{ p.translate("Item value") }}
{{ name }}{{ path }}{{ value }}
+ +
+{% endblock bodytab2 %} + + +{% block pluginscripts %} + +{% endblock pluginscripts %} + diff --git a/wol/plugin.yaml b/wol/plugin.yaml index 22ee09b4b..de60e8522 100755 --- a/wol/plugin.yaml +++ b/wol/plugin.yaml @@ -9,7 +9,7 @@ plugin: tester: ohinckel state: qa-passed keywords: Wake, on, LAN, magic, paket, packet, - documentation: https://www.smarthomeng.de/user/plugins_doc/config/wol.html + #documentation: support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1661427 version: 1.2.0 # Plugin version sh_minversion: 1.8 # minimum shNG version to use this plugin diff --git a/wol/user_doc.rst b/wol/user_doc.rst index 7397c58b7..1a3363cae 100755 --- a/wol/user_doc.rst +++ b/wol/user_doc.rst @@ -5,51 +5,58 @@ wol === -`Wake on LAN `_ +.. image:: webif/static/img/plugin_logo.svg + :alt: plugin logo + :width: 300px + :height: 300px + :scale: 50 % + :align: left + +`Wake on LAN `_ (kurz WOL) ist ein 1995 von AMD in Zusammenarbeit mit Hewlett-Packard veröffentlichter Standard, um einen ausgeschalteten Computer über die eingebaute Netzwerkkarte zu starten. Anforderungen -------------- +============= Es sollte vorab mit einer zusätzlichen Software geprüft werden, ob ein Gerät überhaupt mit Wake-on-Lan eingeschaltet werden kann. Gerade Geräte die mit Windows 10 ausgestattet sind, funktionieren nicht immer ohne spezielle Einstellungen. Notwendige Software -~~~~~~~~~~~~~~~~~~~~~~ +------------------- Es wird keine weitere Software benötigt. Unterstützte Geräte -~~~~~~~~~~~~~~~~~~~~~ +------------------- -Alle Netzwerkkarten die WOL bzw. Wake-on-LAN unterstützen. +Alle Netzwerkkarten die WOL bzw. Wake-on-LAN unterstützen. Wenn eine Netzwerkverbindunge über einen Switch oder einen Hub hergestellt wird sollte geprüft werden, ob dieser ein sogenanntes Magic Packet auch weiterleitet. Anderenfalls funktioniert das Aufwecken nicht. Konfiguration -------------- +============= plugin.yaml -~~~~~~~~~~~ +----------- Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. items.yaml -~~~~~~~~~~ +---------- Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. logic.yaml -~~~~~~~~~~ +---------- Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. Funktionen -~~~~~~~~~~ +---------- Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. @@ -59,7 +66,7 @@ Wer kein extra Item erstellen möchte und nur mit Logiken arbeitet, kann innerha diese Funktion nutzen und braucht das Plugin nicht. Beispiele ---------- +========= .. code:: yaml @@ -68,10 +75,10 @@ Beispiele type: bool wol_mac: fc:02:dc:04:aa:06 # wol_ip: 1.2.3.4 - + Web Interface -------------- +============= Im Web Interface werden die aktuell registrierten Items mit dem Attribut ``wol_mac`` angezeigt und wenn vorhanden auch der Wert des Attributs ``wol_ip`` @@ -79,7 +86,7 @@ wenn vorhanden auch der Wert des Attributs ``wol_ip`` .. image:: assets/webif_tab1.png :class: screenshot -Im zweiten Tab des Web Interface kann eine Mac Adresse und optional eine IP Adresse eingegeben werden +Im zweiten Tab des Web Interface kann eine Mac Adresse und optional eine IP Adresse eingegeben werden um dann auf Knopfdruck das entsprechende Gerät aufzuwecken. .. image:: assets/webif_tab2.png diff --git a/xiaomi_vac/__init__.py b/xiaomi_vac/__init__.py index d93ea383a..6eff7cb9b 100755 --- a/xiaomi_vac/__init__.py +++ b/xiaomi_vac/__init__.py @@ -29,6 +29,7 @@ import re import time from datetime import datetime, timedelta +from .webif import WebInterface try: from miio.vacuum import Vacuum, VacuumException, Consumable @@ -50,7 +51,7 @@ class Robvac(SmartPlugin): ALLOW_MULTIINSTANCE = False - PLUGIN_VERSION = "1.2.1" + PLUGIN_VERSION = "1.2.3" def __init__(self, smarthome): self._ip = self.get_parameter_value("ip") @@ -68,8 +69,7 @@ def __init__(self, smarthome): self._connected = False self._data = {} self._data['state'] = 'disconnected' - if not self.init_webinterface(): - self._init_complete = False + self.init_webinterface(WebInterface) if self._token == '': self.logger.error("Xiaomi_Robvac: No Key for Communication given, Plugin would not start!") @@ -483,6 +483,7 @@ def update_item_read(self, item, caller=None, source=None, dest=None): if self.has_iattr(item.conf, 'robvac'): for message in item.get_iattr_value(item.conf, 'robvac'): self.logger.debug("Xiaomi_Robvac: update_item_read {0}".format(message)) + # ------------------------------------------ # Webinterface Methoden # ------------------------------------------ @@ -494,89 +495,3 @@ def get_connection_info(self): info['cycle'] = self._cycle info['connected'] = self._connected return info - - def init_webinterface(self): - """" - Initialize the web interface for this plugin - - This method is only needed if the plugin is implementing a web interface - """ - try: - self.mod_http = Modules.get_instance().get_module('http') - except Exception: - self.mod_http = None - if self.mod_http is None: - self.logger.error("Plugin '{}': Not initializing the web interface".format(self.get_shortname())) - return False - - # set application configuration for cherrypy - webif_dir = self.path_join(self.get_plugin_dir(), 'webif') - config = { - '/': { - 'tools.staticdir.root': webif_dir, - }, - '/static': { - 'tools.staticdir.on': True, - 'tools.staticdir.dir': 'static' - } - } - - self.logger.debug("Plugin '{0}': {1}, {2}, {3}, {4}, {5}".format( - self.get_shortname(), webif_dir, self.get_shortname(), - config, self.get_classname(), self.get_instance_name())) - # Register the web interface as a cherrypy app - self.mod_http.register_webif(WebInterface(webif_dir, self), - self.get_shortname(), - config, - self.get_classname(), self.get_instance_name(), - description='') - - return True - - -# ------------------------------------------ -# Webinterface of the plugin -# ------------------------------------------ - -import cherrypy -from jinja2 import Environment, FileSystemLoader - - -class WebInterface(SmartPluginWebIf): - - def __init__(self, webif_dir, plugin): - """ - Initialization of instance of class WebInterface - - :param webif_dir: directory where the webinterface of the plugin resides - :param plugin: instance of the plugin - :type webif_dir: str - :type plugin: object - """ - self.logger = logging.getLogger(__name__) - self.webif_dir = webif_dir - self.plugin = plugin - self.tplenv = self.init_template_environment() - self.logger.debug("Plugin : Init Webif") - self.items = Items.get_instance() - - @cherrypy.expose - def index(self, reload=None): - """ - Build index.html for cherrypy - Render the template and return the html file to be delivered to the browser - :return: contents of the template after beeing rendered - """ - plgitems = [] - for item in self.items.return_items(): - if ('robvac' in item.conf): - plgitems.append(item) - self.logger.debug("Plugin : Render index Webif") - tmpl = self.tplenv.get_template('index.html') - return tmpl.render(plugin_shortname=self.plugin.get_shortname(), - plugin_version=self.plugin.get_version(), - plugin_info=self.plugin.get_info(), - p=self.plugin, - connection=self.plugin.get_connection_info(), - webif_dir=self.webif_dir, - items=sorted(plgitems, key=lambda k: str.lower(k['_path']))) diff --git a/xiaomi_vac/assets/webif_xiaomi.png b/xiaomi_vac/assets/webif_xiaomi.png new file mode 100755 index 000000000..aab63bee7 Binary files /dev/null and b/xiaomi_vac/assets/webif_xiaomi.png differ diff --git a/xiaomi_vac/locale.yaml b/xiaomi_vac/locale.yaml index 8ae70a21d..dcfdd35f9 100755 --- a/xiaomi_vac/locale.yaml +++ b/xiaomi_vac/locale.yaml @@ -8,3 +8,4 @@ plugin_translations: 'Host': {'de': '=', 'en': '='} 'Token': {'de': '=', 'en': '='} 'Item': {'de': '=', 'en': '='} + 'Die folgenden Items sind dem Xiamo_Vac Plugin zugewiesen': {'de': '=', 'en': 'The following items are assigned to the Xiamo_Vac plugin'} diff --git a/xiaomi_vac/plugin.yaml b/xiaomi_vac/plugin.yaml index 10140efca..96a37f94c 100755 --- a/xiaomi_vac/plugin.yaml +++ b/xiaomi_vac/plugin.yaml @@ -22,7 +22,7 @@ plugin: In newer Valetudo versions you find the token in the log as LocalSecret. Convert the token at https://gchq.github.io/CyberChef/#recipe=To_Hex to 32 characters. ' support: https://knx-user-forum.de/forum/supportforen/smarthome-py/1453597-support-thread-f%C3%BCr-xiaomi-saugroboter-plugin - version: 1.2.01 # Plugin version + version: 1.2.3 # Plugin version sh_minversion: 1.4 # minimum shNG version to use this plugin multi_instance: False # plugin supports multi instance classname: Robvac # class containing the plugin diff --git a/xiaomi_vac/user_doc.rst b/xiaomi_vac/user_doc.rst index d0f984c3f..099b636d3 100755 --- a/xiaomi_vac/user_doc.rst +++ b/xiaomi_vac/user_doc.rst @@ -1,8 +1,16 @@ -.. index:: Plugins; xiamo_vac -.. index:: xiamo_vac +.. index:: Plugins; xiaomi_vac +.. index:: xiaomi_vac -xiamo_vac -######### +========== +xiaomi_vac +========== + +.. image:: webif/static/img/plugin_logo.png + :alt: plugin logo + :width: 303px + :height: 166px + :scale: 100 % + :align: left Konfiguration ============= @@ -13,6 +21,7 @@ Konfiguration Das Plugin basiert auf der `python-miio `_ Bibliothek. Zuerst muss gemäß `Anleitung auf der github Seite `_ des python-miio Moduls das Token eures Roboters eruiert werden. +Folgender Befehl via SSH auf dem Staubsauger selbst offenbart das unverschlüsselte Token: ``printf $(cat /mnt/data/miio/device.token)``. Dieses muss schließlich noch umgewandelt werden: ``printf "" | xxd -p``. Im Anschluss macht es Sinn, die Kommunikation mit dem Roboter in der Kommandozeile eures Rechners zu testen: .. code-block:: console @@ -49,11 +58,11 @@ Folgende Werte/Funktionen können vom Saugroboter ausgelesen bzw. gestartet werd - Spotreinigung -- Lüftergesschwindigkeit ändern +- Lüftergeschwindigkeit ändern - Lautstärke ansage ändern -- gerenigte Fläche +- gereinigte Fläche - Reinigungszeit @@ -62,7 +71,14 @@ Folgende Werte/Funktionen können vom Saugroboter ausgelesen bzw. gestartet werd und viele mehr. Es wird empfohlen, die Items durch das ``struct`` Template **saugroboter** des Plugins automatisch zu implementieren. -Webinterface -============ +Web Interface +============= + +Das Webinterface bietet einen schnellen und einfachen Überblick über die Statusinformationen des Roboters. -Das Webinterface bietet einen schnelle und einfachen Überblick über die Statusinformationen des Roboters. +.. image:: assets/webif_xiaomi.png + :height: 1652px + :width: 3326px + :scale: 25% + :alt: Web Interface + :align: center diff --git a/xiaomi_vac/webif/__init__.py b/xiaomi_vac/webif/__init__.py new file mode 100755 index 000000000..e4451693f --- /dev/null +++ b/xiaomi_vac/webif/__init__.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab +######################################################################### +# Copyright 2020- +######################################################################### +# This file is part of SmartHomeNG. +# https://www.smarthomeNG.de +# https://knx-user-forum.de/forum/supportforen/smarthome-py +# +# Sample plugin for new plugins to run with SmartHomeNG version 1.5 and +# upwards. +# +# SmartHomeNG is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SmartHomeNG is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SmartHomeNG. If not, see . +# +######################################################################### + +import datetime +import time +import os +import json + +from lib.item import Items +from lib.model.smartplugin import SmartPluginWebIf + + +# ------------------------------------------ +# Webinterface of the plugin +# ------------------------------------------ + +import cherrypy +import csv +from jinja2 import Environment, FileSystemLoader + + +class WebInterface(SmartPluginWebIf): + + def __init__(self, webif_dir, plugin): + """ + Initialization of instance of class WebInterface + + :param webif_dir: directory where the webinterface of the plugin resides + :param plugin: instance of the plugin + :type webif_dir: str + :type plugin: object + """ + self.logger = plugin.logger + self.webif_dir = webif_dir + self.plugin = plugin + self.items = Items.get_instance() + + self.tplenv = self.init_template_environment() + + @cherrypy.expose + def index(self, reload=None): + """ + Build index.html for cherrypy + Render the template and return the html file to be delivered to the browser + :return: contents of the template after beeing rendered + """ + plgitems = [] + for item in self.items.return_items(): + if ('robvac' in item.conf): + plgitems.append(item) + self.logger.debug("Plugin : Render index Webif") + tmpl = self.tplenv.get_template('index.html') + pagelength = self.plugin.get_parameter_value('webif_pagelength') + return tmpl.render(plugin_shortname=self.plugin.get_shortname(), + plugin_version=self.plugin.get_version(), + plugin_info=self.plugin.get_info(), + p=self.plugin, + webif_pagelength=pagelength, + connection=self.plugin.get_connection_info(), + webif_dir=self.webif_dir, + items=sorted(plgitems, key=lambda k: str.lower(k['_path'])), + item_count=len(plgitems)) diff --git a/xiaomi_vac/webif/templates/index.html b/xiaomi_vac/webif/templates/index.html index 1a7d4c7df..9627a2c1e 100755 --- a/xiaomi_vac/webif/templates/index.html +++ b/xiaomi_vac/webif/templates/index.html @@ -23,19 +23,27 @@ }, 30000 ); {% endblock pluginscripts %} @@ -70,15 +78,16 @@ {{ connection.info}} {% endblock headtable %} - - {% set tab1title = "" ~ p.get_shortname() ~ " " ~ _('Items') ~ " (" ~ items|length ~ ")" %} {% block bodytab1 %} -
-
- +
+
+ {{ _('Die folgenden Items sind dem Xiaomi_Vac Plugin zugewiesen') }}. +
+
+ @@ -89,6 +98,7 @@ {% for item in items %} + @@ -99,6 +109,5 @@ {% endfor %}
# {{ _('Item') }}
{{ loop.index}} {{ item._path }}
-
{% endblock bodytab1 %} diff --git a/yamaha/plugin.yaml b/yamaha/plugin.yaml index 6a6dae71a..60762f9f8 100755 --- a/yamaha/plugin.yaml +++ b/yamaha/plugin.yaml @@ -8,8 +8,8 @@ plugin: maintainer: bmxp tester: None state: ready - keywords: Yamaha receiver music - documentation: http://smarthomeng.de/user/plugins/yamaha/user_doc.html + keywords: Yamaha receiver music + documentation: '' support: https://knx-user-forum.de/forum/supportforen/smarthome-py/896468-plugin-yamaha version: 1.0.3 # Plugin version diff --git a/yamaha/user_doc.rst b/yamaha/user_doc.rst index 7a936a82b..a4cd6259e 100755 --- a/yamaha/user_doc.rst +++ b/yamaha/user_doc.rst @@ -1,4 +1,8 @@ -Yamaha +.. index:: Plugins; yamaha (Raumtemperatur Regler v2) +.. index:: yamaha + +====== +yamaha ====== Plugin zur Steuerung von Yamaha RX-V- und RX-S-Receivern, z. B.: Power On/Off, @@ -6,36 +10,36 @@ Auswahl des Eingangs, Lautstärke einstellen und stumm schalten. Dieses Plugin befindet sich noch in der Entwicklung, ist jedoch für den Autor im täglichen Gebrauch. Dort wird das Plugin zum Einschalten des Yamaha RX-S600 und RX-V475 verwendet -den Eingangskanal auszuwählen. +den Eingangskanal auszuwählen. Je nach Eingang wird die Lautstärke auch angepasst, was für den Autor gut funktioniert. Stummschaltung wird aktuell nicht verwendet. -Das Plugin verwendet das Yamaha Network Control (YNC) Protokoll. +Das Plugin verwendet das Yamaha Network Control (YNC) Protokoll. Das Protokoll arbeitet intern mit Datenaustausch im XML Format. Ereignisbenachrichtigungen werden über UDP multicast empfangen (SSDP), wenn das Gerät sie sendet. -Um die Benachrichtigungen zu erhalten, muss das Yamaha-Gerät das selbe Subnetz +Um die Benachrichtigungen zu erhalten, muss das Yamaha-Gerät das selbe Subnetz wie der SmartHomeNG-Host verwenden. Derzeit wird nur die Hauptzone unterstützt. Anforderungen -------------- +============= Notwendige Software -~~~~~~~~~~~~~~~~~~~ +------------------- Es ist keine zusätzliche Software erforderlich Unterstützte Geräte -~~~~~~~~~~~~~~~~~~~ +------------------- -Alle Serien +Alle Serien * RX-V4xx * RX-V5xx * RX-V6xx -* RX-V7xx -* RX-Sxxx +* RX-V7xx +* RX-Sxxx haben das selbe API, also sollten sie mit diesem Plugin funktionieren. @@ -43,22 +47,25 @@ Der RXS-602D ist ebenfalls getestet und funktioniert im Grunde genommen mit Ausn Benachrichtigungen, die überhaupt nicht gesendet werden. Da dieses Gerät auch unterstützt MusicCast, alternativ kann das Yamahaxyc-Plugin verwendet werden. -Nach der Installation des Plugins kann es sein, dass keine Ereignisbenachrichtigungen +Nach der Installation des Plugins kann es sein, dass keine Ereignisbenachrichtigungen über multicast empfangen werden. -Um Ereignisbenachrichtigungen zu aktivieren muss das Gerät mindestens einmal +Um Ereignisbenachrichtigungen zu aktivieren muss das Gerät mindestens einmal einmal mit SmartHomeNG eingeschaltet werden. Konfiguration -------------- +============= + +Diese Plugin Parameter und die Informationen zur Item-spezifischen Konfiguration des Plugins sind +unter :doc:`/plugins_doc/config/yamaha` beschrieben. plugin.yaml -~~~~~~~~~~~ +----------- Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. items.yaml -~~~~~~~~~~ +---------- Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. @@ -92,30 +99,29 @@ Beispiel für Items: enforce_updates: 'True' **Achtung:** - Der oberste Item Name kann mit Plugin Namen kollidieren + Der oberste Item Name kann mit Plugin Namen kollidieren. logic.yaml -~~~~~~~~~~ +---------- Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. Funktionen -~~~~~~~~~~ +========== Bitte die Dokumentation lesen, die aus den Metadaten der plugin.yaml erzeugt wurde. Beispiele ---------- +========= Hier können ausführlichere Beispiele und Anwendungsfälle beschrieben werden. Web Interface -------------- +============= SmartHomeNG liefert eine Reihe Komponenten von Drittherstellern mit, die für die Gestaltung des Webinterfaces genutzt werden können. Erweiterungen dieser Komponenten usw. finden sich im Ordner ``/modules/http/webif/gstatic``. Wenn das Plugin darüber hinaus noch Komponenten benötigt, werden diese im Ordner ``webif/static`` des Plugins abgelegt. - \ No newline at end of file diff --git a/zigbee2mqtt/plugin.yaml b/zigbee2mqtt/plugin.yaml index 4e5d586e3..6747491d6 100755 --- a/zigbee2mqtt/plugin.yaml +++ b/zigbee2mqtt/plugin.yaml @@ -9,7 +9,7 @@ plugin: tester: Michael Wenzel # Who tests this plugin? state: develop # change to ready when done with development keywords: iot - documentation: + documentation: support: version: 1.1.1 # Plugin version @@ -45,27 +45,6 @@ parameters: de: Einlesen aller Werte beim Start en: Read all values at init - webif_pagelength: - type: int - default: -1 - valid_list: - - -1 - - 0 - - 25 - - 50 - - 100 - description: - de: 'Anzahl an Items, die standardmäßig in einer Web Interface Tabelle pro Seite angezeigt werden. - 0 = automatisch, -1 = alle' - en: 'Amount of items being listed in a web interface table per page by default. - 0 = automatic, -1 = all' - description_long: - de: 'Anzahl an Items, die standardmäßig in einer Web Interface Tabelle pro Seite angezeigt werden.\n - Bei 0 wird die Tabelle automatisch an die Höhe des Browserfensters angepasst.\n - Bei -1 werden alle Tabelleneinträge auf einer Seite angezeigt.' - en: 'Amount of items being listed in a web interface table per page by default.\n - 0 adjusts the table height automatically based on the height of the browser windows.\n - -1 shows all table entries on one page.' item_attributes: # Definition of item attributes defined by this plugin (enter 'item_attributes: NONE', if section should be empty) @@ -125,7 +104,7 @@ item_attributes: - color_x - color_y - color_mode - + valid_list_description: de: - ""