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.
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.
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.
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.
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.
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.
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:
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:
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.