Beispielplugin update

Dieser Abschnitt zeigt ein Beispielplugin. Es kann mit oder ohne Webinterface umgesetzt werden.

Das komplette Plugin mit allen Dateien kann auf github unter https://github.com/smarthomeng/smarthome im /dev-Ordner gefunden werden.

Note:

Diese Dokumentation bezieht sich auf Versionen nach v1.4.2. Sie gilt nicht für Versionen bis inklusive v1.4.2

Auf dieser Seiten finden sich Dateien und Anregungen für neue Plugins. Das Plugin selbst besteht aus einer Datei mit Python-Code (__init__.py), einer Datei mit Metadaten (plugin.yaml) und der Dokumentation (user_doc.rst). Ein Satz Beispieldateien wird weiter unten gezeigt.

Eine formatierte Version der Beispieldokumentation findet sich hier: user_doc.rst

Eine Version im Quelltext als Grundlage für eigene Dokumentationen ist unterhalb der Python-Quellcodes verfügbar.

Die Metadaten-Datei:

plugin.yaml
# Metadata for the plugin
plugin:
    # Global plugin attributes
    type: unknown                   # plugin type (gateway, interface, protocol, system, web)
    description:
        de: 'Beispiel Plugin für SmartHomeNG v1.8 und höher'
        en: 'Sample plugin for SmartHomeNG v1.8 and up'
    maintainer: <autor>
#    tester:                         # Who tests this plugin?
    state: develop                  # Initial 'develop'. change to 'ready' when done with development
#    keywords: iot xyz
#    documentation: ''              # An url to optional plugin doc - NOT the url to user_doc!!!
#    support: https://knx-user-forum.de/forum/supportforen/smarthome-py

    version: 1.0.0                  # Plugin version (must match the version specified in __init__.py)

    # these min/max-versions MUST be given in quotes, or e.g. 3.10 will be interpreted as 3.1 (3.1 < 3.9 < 3.10)
    sh_minversion: '1.10'           # minimum shNG version to use this plugin
#    sh_maxversion: '1.11'          # maximum shNG version to use this plugin (omit if latest)
#    py_minversion: '3.10'          # minimum Python version to use for this plugin
#    py_maxversion: '4.25'          # maximum Python version to use for this plugin (omit if latest)

    multi_instance: false           # plugin supports multi instance
    restartable: unknown            # plugin supports stopping and starting again, must be implemented
    #configuration_needed: False     # False: The plugin will be enabled by the Admin GUI without configuration
    classname: SamplePlugin         # class containing the plugin

parameters:
    # Definition of parameters to be configured in etc/plugin.yaml (enter 'parameters: NONE', if section should be empty)

    # item to toggle plugin execution, remove if not wanted
    # (needs plugin to be restartable)
    pause_item:
        type: str
        default: ''
        description:
            de: 'Item, um die Ausführung des Plugins zu steuern'
            en: 'item for controlling plugin execution'

    param1:
        type: str
        description:
            de: 'Demo Parameter'
            en: 'Parameter for demonstration purposes'

    param2:
        type: str
        default: 'value2'
        valid_list:
            - 'value1'
            - 'value2'
            - 'value3'
            - 'value4'
            - 'value5'
        description:
            de: 'Demo Parameter mit Gültigkeitsliste und Standardwert'
            en: 'Demonstration parameter with valid-list and default value'

    param3:
        type: str
        # If 'mandatory' is specified, a 'default' attribute must not be specified
        mandatory: true
        description:
            de: 'Demo Parameter der angegeben werden muss'
            en: 'Demonstration parameter which has to be specified'

item_attributes:
    # Definition of item attributes defined by this plugin (enter 'item_attributes: NONE', if section should be empty)

item_structs:
    # Definition of item-structure templates for this plugin (enter 'item_structs: NONE', if section should be empty)

#item_attribute_prefixes:
    # Definition of item attributes that only have a common prefix (enter 'item_attribute_prefixes: NONE' or ommit this section, if section should be empty)
    # NOTE: This section should only be used, if really nessesary (e.g. for the stateengine plugin)

plugin_functions:
    # Definition of plugin functions defined by this plugin (enter 'plugin_functions: NONE', if section should be empty)

logic_parameters:
    # Definition of logic parameters defined by this plugin (enter 'logic_parameters: NONE', if section should be empty)

Der Quelltext:

__init__.py
#!/usr/bin/env python3
# vim: set encoding=utf-8 tabstop=4 softtabstop=4 shiftwidth=4 expandtab
#########################################################################
#  Copyright 2020-      <AUTHOR>                                  <EMAIL>
#########################################################################
#  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.10
#  and up.
#
#  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 <http://www.gnu.org/licenses/>.
#
#########################################################################

import asyncio

from lib.model.smartplugin import SmartPlugin
from lib.item import Items

from .webif import WebInterface


# 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 SamplePlugin(SmartPlugin):
    """
    Main class of the Plugin. Does all plugin specific stuff and provides
    the update functions for the items

    HINT: Please have a look at the SmartPlugin class to see which
    class properties and methods (class variables and class functions)
    are already available!
    """

    PLUGIN_VERSION = '1.0.0'    # (must match the version specified in plugin.yaml), use '1.0.0' for your initial plugin Release

    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__()

        # cycle time in seconds, only needed, if hardware/interface needs to be
        # polled for value changes by adding a scheduler entry in the run method of this plugin
        # (maybe you want to make it a plugin parameter?)
        # self._cycle = 60

        # if you want to use an item to toggle plugin execution, enable the
        # definition in plugin.yaml and uncomment the following line
        #self._pause_item_path = self.get_parameter_value('pause_item')

        # Initialization code goes here

        # On initialization error use:
        #   self._init_complete = False
        #   return

        self.init_webinterface(WebInterface)
        # if plugin should not start without web interface
        # if not self.init_webinterface():
        #     self._init_complete = False

        return

    def run(self):
        """
        Run method for the plugin
        """
        self.logger.dbghigh(self.translate("Methode '{method}' aufgerufen", {'method': 'run()'}))

        # connect to network / web / serial device
        # (enable the following lines if you want to open a connection
        #  don't forget to implement a connect (and disconnect) method.. :) )
        #self.connect()

        # setup scheduler for device poll loop
        # (enable the following line, if you need to poll the device.
        #  Rember to un-comment the self._cycle statement in __init__ as well)
        #self.scheduler_add(self.get_fullname() + '_poll', self.poll_device, cycle=self._cycle)

        # Start the asyncio eventloop in it's own thread
        # and set self.alive to True when the eventloop is running
        # (enable the following line, if you need to use asyncio in the plugin)
        #self.start_asyncio(self.plugin_coro())

        self.alive = True     # if using asyncio, do not set self.alive here. Set it in the session coroutine

        # let the plugin change the state of pause_item
        if self._pause_item:
            self._pause_item(False, self.get_fullname())

        # if you need to create child threads, do not make them daemon = True!
        # They will not shutdown properly. (It's a python bug)
        # Also, don't create the thread in __init__() and start them here, but
        # create and start them here. Threads can not be restarted after they
        # have been stopped...

    def stop(self):
        """
        Stop method for the plugin
        """
        self.logger.dbghigh(self.translate("Methode '{method}' aufgerufen", {'method': 'stop()'}))
        self.alive = False     # if using asyncio, do not set self.alive here. Set it in the session coroutine

        # let the plugin change the state of pause_item
        if self._pause_item:
            self._pause_item(True, self.get_fullname())

        # this stops all schedulers the plugin has started.
        # you can disable/delete the line if you don't use schedulers
        self.scheduler_remove_all()

        # stop the asyncio eventloop and it's thread
        # If you use asyncio, enable the following line
        #self.stop_asyncio()

        # If you called connect() on run(), disconnect here
        # (remember to write a disconnect() method!)
        #self.disconnect()

        # also, clean up anything you set up in run(), so the plugin can be
        # cleanly stopped and started again

    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.
        """
        # check for pause item
        if item.property.path == self._pause_item_path:
            self.logger.debug(f'pause item {item.property.path} registered')
            self._pause_item = item
            self.add_item(item, updating=True)
            return self.update_item

        if self.has_iattr(item.conf, 'foo_itemtag'):
            self.logger.debug(f"parse item: {item}")

        # todo
        # if interesting item for sending values:
        #   self._itemlist.append(item)
        #   return self.update_item

    def parse_logic(self, logic):
        """
        Default plugin parse_logic method
        """
        if 'xxx' in logic.conf:
            # self.function(logic['name'])
            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.

        To prevent a loop, the changed value should only be written to the device, if the plugin is running and
        the value was changed outside of this plugin(-instance). That is checked by comparing the caller parameter
        with the fullname (plugin name & instance) of the 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
        """
        # check for pause item
        if item is self._pause_item:
            if caller != self.get_shortname():
                self.logger.debug(f'pause item changed to {item()}')
                if item() and self.alive:
                    self.stop()
                elif not item() and not self.alive:
                    self.run()
            return

        if self.alive and caller != self.get_fullname():
            # code to execute if the plugin is not stopped
            # and only, if the item has not been changed by this plugin:
            self.logger.info(f"update_item: '{item.property.path}' has been changed outside this plugin by caller '{self.callerinfo(caller, source)}'")

            pass

    def poll_device(self):
        """
        Polls for updates of the 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.
        """
        # # get the value from the device
        # device_value = ...
        #
        # # find the item(s) to update:
        # for item in self.sh.find_items('...'):
        #
        #     # update the item by calling item(value, caller, source=None, dest=None)
        #     # - value and caller must be specified, source and dest are optional
        #     #
        #     # The simple case:
        #     item(device_value, self.get_fullname())
        #     # if the plugin is a gateway plugin which may receive updates from several external sources,
        #     # the source should be included when updating the value:
        #     item(device_value, self.get_fullname(), source=device_source_id)
        pass

    async def plugin_coro(self):
        """
        Coroutine for the plugin session (only needed, if using asyncio)

        This coroutine is run as the PluginTask and should
        only terminate, when the plugin is stopped
        """
        self.logger.notice("plugin_coro started")

        self.alive = True

        # ...

        self.alive = False

        self.logger.notice("plugin_coro finished")
        return

Das Webinterface (template file):

Das Template hat bis zu fünf Inhaltsblöcke, die mit den Daten des Plugins befüllt werden können:

  1. Kopfdaten auf der rechten Seite {% block headtable %}

  2. Tab 1 der Hauptteils der Seite {% block bodytab1 %}

  3. Tab 2 der Hauptteils der Seite {% block bodytab2 %}

  4. Tab 3 der Hauptteils der Seite {% block bodytab3 %}

  5. Tab 4 der Hauptteils der Seite {% block bodytab4 %}

Die Anzahl der anzuzeigenden bodytab-Blöcke wird mit der folgenden Anweisung festgelegt: {% set tabcount = 4 %}

templates/index.html
{% extends "base_plugin.html" %}

{% set logo_frame = false %}

<!-- set update_interval to a value > 0 (in milliseconds) to enable periodic data updates -->
{% set update_interval = 0 %}
<!-- for data-heavy plugins it might be useful to set the update interval based on the amount of data -->
{% set update_interval = (200 * (log_array | length)) %}

<!-- set dataSet if you need specific data to be updated automatically. Also see init.py in plugin webif!-->
{% set dataSet = 'devices_info' %}

<!-- set update_params if you need to provide additional parameters for the auto-update function-->
{% set update_params = item_id %}

<!-- if you don't need any buttons in the header, disable them completely-->
{% set buttons = false %}

<!-- if you don't need any auto-refresh elements in the header, disable them-->
{% set autorefresh_buttons = false %}

<!-- if you don't need the reload_button in the header, disable it-->
{% set reload_button = false %}

<!-- if you don't need the close in the header, disable it-->
{% set close_button = false %}

<!-- for some situations it might be useful to know the number of datatable rows shown on the current page.
Activate that function if needed, otherwise just remove the line to increase performance -->
{% set row_count = true %}

<!-- if new values should be retrieved using automatic page update right in the beginning and on page changes -->
{% set initial_update = true %}

<!--
    Additional styles go into this block. Examples are for datatables
-->
{% block pluginstyles %}
<style>
  table th.value {
    width: 100px;
  }
  /*
  These are used for highligt effect in web interface when a value changes. If you don't want to overwrite the
  default color, you can remove the entries here as the classes are already defined in smarthomeng.css
  */
  .shng_effect_highlight {
    background-color: #FFFFE0;
  }
  .shng_effect_standard {
    background-color: none;
  }
</style>
{% endblock pluginstyles %}

<!--
    Additional script tag for plugin specific javascript code go into this block
-->
{% block pluginscripts %}
<script>
    function handleUpdatedData(response, dataSet=null) {
        if (dataSet === 'devices_info' || dataSet === null) {
            var objResponse = JSON.parse(response);
            myProto = document.getElementById(dataSet);
            for (item in objResponse) {
                /*
        Parameters for shngInsertText:
          0: mandatory, ID of the HTML element, e.g. the table cell
          1: mandatory, Value to be written - taken from the objResponse dict
          2: optional, If element of parameter 0 is in a (data)table, the ID of the table has to be put here
          3: optional, If you want a visual highlight effect when a value changes, provide the duration in seconds.
                shngInsertText (item+'_value', objResponse[item]['value'], 'maintable', 5);
                */
            }
      // Redraw datatable after cell updates
      // $('#maintable').DataTable().draw(false);
        }
    }
</script>
<!--
    This part is used to implement datatable JS for the tables. It allows resorting tables by column, etc.
    For each table you have to implement the code part $('#<table_id>').DataTable(..); where the <table_id> matches the id of a table tag
-->
<script>
    $(document).ready( function () {
        /*
        loading defaults from /modules/http/webif/gstatic/datatables/datatables.defaults.js
        You can copy that file, put it in your plugin directory, rename the "bind" function and
        trigger that function here instead of datatables_defaults if you want to change the behaviour.
        Of course you can also overwrite defaults by putting the option declarations in {} below.
        */
        $(window).trigger('datatables_defaults');
        try {
            /*
            Copy this part for every datatable on your page. Adjust options if necessary.
            */
            maintable = $('#maintable').DataTable( {
              /* If you want to define your own columnDefs options (e.g. for hiding a column by default), use the concat function shown here.
              */
              columnDefs: [{ "targets": [2], "className": "value"}].concat($.fn.dataTable.defaults.columnDefs),
              });
        }
        catch (e) {
            console.warn("Datatable JS not loaded, showing standard table without reorder option " + e);
        }
    /*
    This part creates tooltips for buttons/inputs that have the class "button-tooltip"
    */
    const tooltipList = ['Nach Devices suchen'];
    createTooltips(tooltipList);
    /*
    This part reads cookies for the attribute "sort_order" and activates the resepctive button after page load
    */
    order = getCookie('sort_order');
    if (order == '')
      order = 'time-desc';
    const button = document.getElementById(order);
    button.click();
    });
</script>
<!--
This is an example on how to update the page refresh method. You can set the dataSet, update interval, special parameters or (de)activate the auto refresh
In the example the update is deactivated on the 12th of December 2022 (what might make no sense at all)
<script>
  var today = new Date();
  var today_date = String(today.getDate()) + String(today.getMonth() + 1) + today.getFullYear();
  let test_date = "12122022";
  if (today_date === test_date)
      window.refresh.update({dataSet:'test', update_params:'specialitem', update_interval: 2000, update_active:false});
  </script>
-->
{% endblock pluginscripts %}


{% block headtable %}
<!-- add a style="min-width:..px;" if you want to define a minimum width for responsive behaviour -->
<table class="table table-striped table-hover">
    <tbody>
        <tr>
            <td class="py-1"><strong>Prompt 1</strong></td>
            <td class="py-1">{% if 1 == 2 %}{{ _('Ja') }}{% else %}{{ _('Nein') }}{% endif %}</td>
            <td class="py-1" width="50px"></td>
            <td class="py-1"><strong>Prompt 4</strong></td>
            <td class="py-1">{{ _('Wert 4') }}</td>
            <td class="py-1" width="50px"></td>
        </tr>
        <tr>
            <td class="py-1"><strong>Prompt 2</strong></td>
            <td class="py-1">{{ _('Wert 2') }}</td>
            <td></td>
            <td class="py-1"><strong>Prompt 5</strong></td>
            <td class="py-1">-</td>
            <td></td>
        </tr>
        <tr>
            <td class="py-1"><strong>Prompt 3</strong></td>
            <td class="py-1">-</td>
            <td></td>
            <td class="py-1"><strong>Prompt 6</strong></td>
            <td class="py-1">-</td>
            <td></td>
        </tr>
    </tbody>
</table>
{% endblock headtable %}


<!--
    Additional buttons for the web interface (if any are needed) - displayed below the headtable-section
-->
{% block buttons %}
{% if 1==2 %}
    <div>
        <button id="btn1" class="btn btn-shng btn-sm" name="scan" onclick="shngPost('', {learn: 'on'})"><i class="fas fa-question button-tooltip"></i>&nbsp;&nbsp;&nbsp;{{ _('nach Devices suchen') }}&nbsp;</button>
    <button onclick="setCookie('sort_order', 'time-desc', 365, window.pluginname);" type="button" class="button-tooltip" id="time-desc">Button</button>
    </div>
{% endif %}
{% endblock %}

<!--
    Define the number of tabs for the body of the web interface (1 - 6)
-->
{% set tabcount = 4 %}


<!--
    Set the tab that will be visible on start, if another tab that 1 is wanted (1 - 3)
-->
{% if item_count==0 %}
    {% set start_tab = 2 %}
{% endif %}


<!--
    Content block for the first tab of the Webinterface
-->
{% set tab1title = "<strong>" ~ p.get_shortname() ~ " Items</strong> (" ~ item_count ~ ")" %}
{% block bodytab1 %}
  <!-- remove this div if not needed -->
  <div class="m-2">
    {{ _('Hier kommen bei Bedarf Informationen des Webinterfaces oberhalb der Tabelle hin.') }} (optional)
  </div>

    <!-- set id accordingly -->
    <table id="maintable" class="dataTableAdditional m-2">
        <thead>
            <tr>
        <!-- You HAVE to add an empty th here for correct implementation of the responsive plugin -->
        <th></th>
              <th>{{ _('Item') }}</th>
              <th class="value">{{ _('Wert') }}</th>
            </tr>
        </thead>
        <tbody>
            {% for item in items %}
              {% if p.has_iattr(item.conf, '<plugin_attribute>') %}
                 <tr>
                   <!-- You HAVE to add an empty td here for correct implementation of the responsive plugin -->
                   <td></td>
                   <td class="py-1" id="{{ item._path }}">{{ item._path }}</td>
                   <td class="py-1" id="{{ item._path }}_value">{{ item() }}</td>
                 </tr>
              {% endif %}
            {% endfor %}
        </tbody>
    </table>
  <!-- remove this div if not needed -->
  <div class="m-2">
    Etwaige Informationen unterhalb der Tabelle (optional)
  </div>

{% endblock bodytab1 %}


<!--
    Content block for the second tab of the Webinterface
-->
{% set tab2title = "<strong>" ~ p.get_shortname() ~ " Geräte</strong> (" ~ device_count ~ ")" %}
{% block bodytab2 %}
{% endblock bodytab2 %}


<!--
    Content block for the third tab of the Webinterface
    If wanted, a title for the tab can be defined as:
        {% set tab3title = "<strong>" ~ p.get_shortname() ~ " Geräte</strong>" %}

    It has to be defined before (and outside) the block bodytab3
-->
{% block bodytab3 %}
{% endblock bodytab3 %}


<!--
    Content block for the fourth tab of the Webinterface
    If wanted, a title for the tab can be defined as:
        {% set tab4title = "<strong>" ~ p.get_shortname() ~ " Geräte</strong>" %}

    It has to be defined before (and outside) the block bodytab4
-->
{% block bodytab4 %}
{% endblock bodytab4 %}

Die Datei für Mehrsprachigkeit:

locale.yaml
# 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.'

Die Dokumentation:

Die folgende Datei skizziert den Mindestumfang für die Plugin-Dokumentation.

user_doc.rst

.. index:: Plugins; Pluginname (in Kleinbuchstaben)
.. index:: Pluginname (in Kleinbuchstaben)


===============================
Pluginname (in Kleinbuchstaben)
===============================


.. comment set image name and extension according to the image file you use for the plugin-logo

.. image:: webif/static/img/plugin_logo.png
   :alt: plugin logo
   :width: 300px
   :height: 300px
   :scale: 50 %
   :align: left

<Hier erfolgt die allgemeine Beschreibung des Zwecks des Plugins>


Anforderungen
=============

...

Notwendige Software
-------------------

<Hier wird weitere benötigte Software beschrieben. Falls keine weitere Software benötigt wird, kann dieser
Abschnitt entfallen.>

Unterstützte Geräte
-------------------

<Hier werden unterstützte Geräte beschrieben. Falls keine keine speziell zu beschreibenden Geräte unterstützt
werden, kann dieser Abschnitt entfallen.>


Konfiguration
=============

.. comment Den Text **Pluginname (in Kleinbuchstaben)** durch :doc:`/plugins_doc/config/pluginname` ersetzen

Die Plugin Parameter, die Informationen zur Item-spezifischen Konfiguration des Plugins und zur Logik-spezifischen
Konfiguration sind unter **Pluginname (in Kleinbuchstaben)** beschrieben.

Dort findet sich auch die Dokumentation zu Funktionen, die das Plugin evtl. bereit stellt.


Funktionen
----------

<Hier können bei Bedarf ausführliche Beschreibungen zu den Funktionen dokumentiert werden.>

<Sonst diesen Abschnitt löschen>

|

Beispiele
=========

Hier können bei Bedarf Konfigurationsbeispiele dokumentiert werden.

|

Web Interface
=============

<Hier erfolgt die Beschreibung des Web Interfaces>

Tab 1: <Name des Tabs>
----------------------

<Hier wird der Inhalt und die Funktionalität des Tabs beschrieben.>

.. image:: assets/webif_tab1.jpg
   :class: screenshot

<Zu dem Tab ist ein Screenshot im Unterverzeichnis ``assets`` des Plugins abzulegen.

|

Version History
===============

<In diesem Abschnitt kann die Versionshistorie dokumentiert werden, falls der Plugin Autor dieses möchte.
Diese Abschnitt ist optional.>


Hinweis

Das in früheren Versionen verwendete README-Format für die Dokumentation von Plugins ist veraltet. Ein Großteil der Dokumentation ist in die Metadaten-Dokumentation in plugin.yaml übergegangen. Die restliche Dokumentation sollte nur noch im user_doc-Format erfolgen.

Soweit möglich, sollten bestehende README im Rahmen von Aktualisierungen in entsprechende user_doc überführt werden.