Graph-IT

Wie schreibe ich ein ControlPi-Plugin?

Ziel des Tutorials

Das ControlPi-System soll um ein neues Plugin erweitert werden.

In diesem Tutorial wird anhand eines kleinen Beispiels demonstriert, wie ein ControlPi-Plugin entwickelt wird.

Unser Beispiel soll einen Nachlauf implementieren, also beispielsweise, dass ein Arbeitslicht, eine Schmierung oder eine Kühlung noch einige Zeit weiter läuft, bevor sie tatsächlich ausgeschaltet wird.

Vorlage und Übersicht über Dateien

Im Repository graphit/controlpi-template befindet sich eine Vorlage eines Controlpi-Plugins. Dieses kann mit git clone git://git.graph-it.com/graphit/controlpi-template.git oder git clone ssh://git@git.graph-it.com:44022/graphit/controlpi-template.git ausgecheckt werden.

Dann kann es mit mv controlpi-template controlpi-<NAME> oder cp -r controlpi-template controlpi-<NAME> (falls das Template wiederverwendet werden soll) in das Verzeichnis für das neue Plugin verschoben oder kopiert werden. In diesem Verzeichnis löschen wir zunächst das .git-Verzeichnis (rm -rf .git/), da wir Änderungen nicht in das Template-Repository, sondern (falls überhaupt) in ein neues Repository für das neue Plugin einchecken wollen.

Für unser Beispiel führen wir also

$ cp -r controlpi-template controlpi-overshoot
$ cd controlpi-overshoot
$ rm -rf .git/

aus.

Die Verbindung zu einem neuen, bisher leeren Repository kann durch

$ git init
$ git remote add origin ssh://git@git.graph-it.com:44022/graphit/controlpi-<NAME>.git
$ git add .
$ git commit -m "Initial commit: Template"
$ git push --set-upstream origin master

hergestellt werden.

In diesem Repository befinden sich zunächst die folgenden Dateien:

$ tree -a
.
├── LICENSE
├── README.md
├── conf.json
├── controlpi_plugins
│   └── example.py
├── doc
│   └── index.md
└── setup.py

Das eigentliche Plugin ist in controlpi_plugins. In diesem Package werden alle Plugins implementiert, egal in welchem Repository sie entwickelt werden. Namens-Kollisionen sowohl beim Namen der Python-Datei als auch beim Namen der darin enthaltenen Plugin-Klasse sollten vermieden werden.

Wir benennen also zunächst die Datei um:

$ git mv controlpi_plugins/example.py controlpi_plugins/overshoot.py

Der Inhalt der Datei wird weiter unten besprochen.

setup.py enthält die Metadaten, die Python bzw. pip zur Installation des Paketes braucht. Hierbei wird auch die sehr kurze Beschreibung (auf Englisch) in README.md verwendet. Beide sollten für unser Projekt angepasst werden. In setup.py können auch Abhängigkeiten zu anderen Python-Bibliotheken deklariert werden.

README.md:

# ControlPi Plugin for Giving a State Overshoot Time
This distribution package contains a plugin for the
[ControlPi system](https://docs.graph-it.com/graphit/controlpi), that
lets a state active for a configured time after setting its controlling
overshoot state to `False`.

Documentation (in German) can be found at [doc/index.md](doc/index.md) in
the source repository and at
[https://docs.graph-it.com/graphit/controlpi-overshoot/](https://docs.graph-it.com/graphit/controlpi-overshoot/).
Code documentation (in English) including doctests is contained in the
source files.

setup.py:

import setuptools

with open("README.md", "r") as readme_file:
    long_description = readme_file.read()

setuptools.setup(
    name="controlpi-overshoot",
    version="0.1.0",
    author="Graph-IT GmbH",
    author_email="info@graph-it.com",
    description="ControlPi Plugin for Giving a State Overshoot Time",
    long_description=long_description,
    long_description_content_type="text/markdown",
    url="http://docs.graph-it.com/graphit/controlpi-overshoot",
    packages=["controlpi_plugins"],
    install_requires=[
        "controlpi @ git+git://git.graph-it.com/graphit/controlpi.git",
    ],
    classifiers=[
        "Programming Language :: Python",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent",
    ],
)

In doc/index.md befindet sich die längere Dokumentation unseres neuen Plugins (auf Deutsch). In conf.json befindet sich eine minimale Beispiel-Konfiguration für dieses Plugin. Die Beispiel-Konfiguration sollte mit möglichst wenig Abhängigkeiten zu anderen Plugins auskommen und auch in der Dokumentation erläutert werden.

Einrichten eines Virtual Environments zum Entwickeln und Testen

Um die Installation von Paketen und Abhängigkeiten lokal zu halten, werden in Python “Virtual Environments” eingesetzt. Es bietet sich an, ein solches im Projekt-Verzeichnis anzulegen:

$ python3 -m venv venv/

Es wird mit

$ source venv/bin/activate

aktiviert, wodurch die Python- und pip-Versionen auf der aktuellen Shell diejenigen genau dieser virtuellen Umgebung werden.

Zunächst bringen wir die in einem Venv immer installierten Pakete auf den neuesten Stand:

$ pip install -U pip setuptools wheel

Dann installieren wir das aktuelle Paket in editierbarer Form:

$ pip install -e .

„In editierbarer Form“ bzw. -e bedeutet dabei, dass Änderungen an den Quell-Dateien in controlpi_plugins sofort wirksam werden.

Da in setup.py eine Abhängigkeit auf das controlpi-Basispaket deklariert ist, hat dieser Befehl auch dieses und all seine Abhängigkeiten installiert:

$ pip list -v
Package             Version Editable project location     Location                                                        Installer
------------------- ------- ----------------------------- --------------------------------------------------------------- ---------
controlpi           0.3.0                                 /home/.../controlpi-overshoot/venv/lib/python3.11/site-packages pip
controlpi-overshoot 0.1.0   /home/.../controlpi-overshoot /home/.../controlpi-overshoot
fastjsonschema      2.18.0                                /home/.../controlpi-overshoot/venv/lib/python3.11/site-packages pip
pip                 23.2.1                                /home/.../controlpi-overshoot/venv/lib/python3.11/site-packages pip
pyinotify           0.9.6                                 /home/.../controlpi-overshoot/venv/lib/python3.11/site-packages pip
setuptools          68.1.2                                /home/.../controlpi-overshoot/venv/lib/python3.11/site-packages pip
wheel               0.41.2                                /home/.../controlpi-overshoot/venv/lib/python3.11/site-packages pip

Solange das Venv aktiviert ist, kann das ControlPi-System jetzt theoretisch gestartet werden:

$ python -m controlpi conf.json
Given configuration JSON file is not a JSON file!
^CShutting down on signal SIGINT.

Da wir aber noch nichts entwickelt, die conf.json noch nicht angepasst haben und diese noch einen Platzhalter enthält, passiert hier noch nichts.

Implementierung des Plugins selbst

Das Plugin wird wie gesagt in controlpi_plugins/overshoot.py implementiert:

"""ControlPi Plugin for Giving a State Overshoot Time."""
import asyncio

from controlpi import BasePlugin, Message, MessageTemplate
from controlpi.baseplugin import JSONSchema

Zunächst werden die notwendigen Module und einzelne Klassen aus diesen importiert. Für unser einfaches Beispiel brauchen wir nur das Modul asyncio für asynchrone Programmierung und die Klassen BasePlugin, Message und MessageTemplate aus dem ControlPi-Basis-System. JSONSchema ist eine Typ-Definition für JSON-Schemata.

class Overshoot(BasePlugin):
    """ControlPi plugin for Giving a State Overshoot Time."""

Jedes ControlPi-Plugin erbt von der Klasse BasePlugin, die unter anderem festlegt, dass die weiter unten besprochenen Methoden process_conf und run implementiert werden müssen.

Konfigurations-Schema

Die Konfiguration eines Plugins wird durch die Konstante CONF_SCHEMA als JSON-Schema beschrieben:

    CONF_SCHEMA: JSONSchema = {'properties':
                               {'state': {'type': 'string'},
                                'overshoot': {'type': 'integer',
                                              'default': 10}},
                               'required': ['state']}

Für unser Beispiel gibt es zwei Attribute in der Konfiguration. state ist ein String, der festlegt, welcher andere Zustand durch den Nachlauf geschaltet werden soll, und overshoot ist ein Integer, der festlegt, wie lang der Nachlauf in Sekunden sein soll. Für overshoot ist ein Default-Wert von 10 Sekunden vorgegeben, während für state festgelegt ist, dass dieses Attribut in der Konfiguration vorhanden sein muss.

Die Methode process_conf wird für alle Plugins während der Initialisierung des ControlPi-Systems synchron aufgerufen. Sie hat die Aufgabe, die Konfiguration abzuarbeiten und eventuelle Instanz-Attribute zu setzen:

    def process_conf(self) -> None:
        """Register bus client."""
        self._state = False
        self._delayed_off = 0
        self._cancelled_off = 0

In unserem Beispiel muss von der Konfiguration nichts bearbeitet werden. Wir brauchen aber einige Instanz-Attribute: self._state hält den momentanen Zustand, self._delayed_off die Anzahl der Ausschalt-Vorgänge, die noch auf ihre Abarbeitung warten und self._cancelled_off die Anzahl der durch Wieder-Einschalten abgebrochenen Ausschalt-Vorgänge.

Registrierung am Message-Bus

Außerdem wird in process_conf auch häufig das Plugin am Message-Bus registriert (wenn es nicht – wie beispielsweise ein Web-Server – mehrere Klienten nach Bedarf registriert):

        self.bus.register(self.name, 'Overshoot',
                          [MessageTemplate({'event':
                                            {'const': 'changed'},
                                            'state':
                                            {'type': 'boolean'}}),
                           MessageTemplate({'state':
                                            {'type': 'boolean'}}),
                           MessageTemplate({'target':
                                            {'const': self.conf['state']},
                                            'command':
                                            {'const': 'set state'},
                                            'new state':
                                            {'type': 'boolean'}})],
                          [([MessageTemplate({'target':
                                              {'const': self.name},
                                              'command':
                                              {'const': 'get state'}})],
                            self._get_state),
                           ([MessageTemplate({'target':
                                              {'const': self.name},
                                              'command':
                                              {'const': 'set state'},
                                              'new state':
                                              {'type': 'boolean'}})],
                            self._set_state)])

Die Registrierung enthält zunächst den Namen der Instanz und des Plugins. Dann folgt ein Array mit Nachrichten-Templates für alle Nachrichten, die das Plugin senden möchte. In unserem Fall sind dies 'state'-Nachrichten, die den momentanen Zustand mitteilen, optional mit einem Attribut 'event': 'changed', das angibt, dass sich der Zustand geändert hat, und 'command'-Nachrichten an den untergeordneten, von unserem Nachlauf-Plugin kontrollierten Zustand.

Nach den gesendeten Nachrichten werden die empfangenen Nachrichten konfiguriert, hierbei wird für ein Array von Nachrichten-Templates jeweils ein Callback angegeben, das für diese Nachrichten aufgerufen werden soll. Für 'command': 'get state'-Nachrichten ist dies in unserem Fall die Methode self._get_state und für 'command': 'set state'-Nachrichten die Methode self._set_state, wobei im Template zusätzlich spezifiziert ist, dass 'command': 'set state'-Nachrichten ein 'boolean'-Attribut 'new state' enthalten müssen.

Callbacks

Ein großer Teil der Funktionalität von Plugins wird durch solche Callbacks implementiert, die auf Nachrichten von anderen Clients im ControlPi-System reagieren.

In unserem Fall sind dies explizite Kommandos, den momentanen Zustand auszulesen oder zu ändern, aber es kann auch auf beliebige andere Nachrichten – beispielsweise die von unserem Beispiel-Plugin gesendeten 'event'- und 'state'-Nachrichten – reagiert werden.

Das Callback self._get_state sendet einfach den momentanen Zustand auf den Bus:

    async def _get_state(self, message) -> None:
        await self.bus.send(Message(self.name, {'state': self._state}))

Das Callback self._set_state sendet eine 'event': 'changed'-Nachricht, wenn der Zustand sich geändert hat:

    async def _set_state(self, message) -> None:
        if self._state != message['new state']:
            self._state = message['new state']
            await self.bus.send(Message(self.name,
                                        {'event': 'changed',
                                         'state': self._state}))

Hat er sich zu True geändert, werden alle Ausschalt-Vorgänge abgebrochen, indem die Zahl der abgebrochenen auf die Zahl der noch wartenden Ausschalt-Vorgänge gesetzt wird. Außerdem wird durch eine 'command'-Nachricht sichergestellt, dass der kontrollierte Zustand ebenfalls True ist:

            if message['new state']:
                # Cancel all turning off:
                self._cancelled_off = self._delayed_off
                await self.bus.send(Message(self.name,
                                            {'target': self.conf['state'],
                                             'command': 'set state',
                                             'new state': True}))

Hat er sich zu False geändert, wird die Anzahl der Ausschalt-Vorgänge um 1 erhöht und die konfigurierte Zeit gewartet. Wenn nach diesem Warten die Anzahl der abgebrochenen Vorgänge noch größer als 0 ist, wird diese um 1 reduziert und nichts weiter unternommen. Nur, wenn die abgebrochenen Vorgänge schon 0 waren, wird der kontrollierte Zustand auf False gesetzt:

            else:
                self._delayed_off += 1
                await asyncio.sleep(self.conf['overshoot'])
                if self._cancelled_off > 0:
                    self._cancelled_off -= 1
                else:
                    await self.bus.send(Message(self.name,
                                                {'target': self.conf['state'],
                                                 'command': 'set state',
                                                 'new state': False}))
                self._delayed_off -= 1

Wenn der Zustand sich durch 'command': 'set state' gar nicht geändert hat, wird einfach nur der aktuelle Zustand als Nachricht gemeldet:

        else:
            await self.bus.send(Message(self.name,
                                        {'state': self._state}))

Schließlich muss jede Implementierung von BasePlugin eine asynchrone run-Methode enthalten, die durch das ControlPi-System für alle Plugins gestartet wird:

    async def run(self) -> None:
        """Do nothing."""
        pass

Für unser Beispiel-Plugin hat diese nichts zu tun. Für andere Plugins kann sie vor allem von der Kommunikation mit anderen Clients unabhängige Kommunikation mit der Außenwelt implementieren:

Beispiel-Konfiguration

Die Beispiel-Konfiguration sieht so aus:

{
    "Debug": {
        "plugin": "WSServer",
        "port": 8000,
        "web": {
            "/": {
                "module": "controlpi_plugins.wsserver",
                "location": "Debug"
            }
        }
    },
    "Light": {
        "plugin": "State"
    },
    "Light-Overshoot": {
        "plugin": "Overshoot",
        "state": "Light"
    }
}

Es bietet sich oft an, das Beispiel mit einer Debug-Oberfläche auszustatten. Um es laufen zu lassen, muss dann controlpi-wsserver zusätzlich (bei aktiviertem Venv) installiert werden:

$ pip install git+git://git.graph-it.com/graphit/controlpi-wsserver.git

Außer dem Websocket-Server, der Debug-Oberfläche enthält dieses Beispiel nur ein State-Plugin Light, das den kontrollierten Zustand – ein Licht, das nach dem Ausschalten eine Weile weiterleuchten soll – darstellt, und ein Overshoot-Plugin Light-Overshoot, das eben ein Beispiel für das hier implementierte Plugin ist.

Es ist in diesen Minimal-Beispielen oft sinnvoll Dinge, die in der tatsächlichen Anwendung wahrscheinlich geschaltete elektrische Ausgänge – z.B. durch das Plugin controlpi-pinio – wären durch einfache State-Plugins darzustellen, da diese ohne externe Abhängigkeiten funktionieren und in der Debug-Oberfläche betrachtet werden können.

Das Beispiel kann (immer noch bei aktivierten Venv) ausgeführt werden durch:

$ python -m controlpi conf.json

Ein Zustand dieses Beispiels sieht zum Beispiel so aus: Debug-Oberfläche

Das Overshoot-Plugin wurde bereits ausgeschaltet, das State-Plugin ist aber noch eingeschaltet und wird genau 10 Sekunden (da der Default in der Beispiel-Konfiguration nicht überschrieben wurde) später ausgehen.