From: Benjamin Braatz Date: Mon, 4 Sep 2023 17:33:12 +0000 (+0200) Subject: Completed tutorial X-Git-Url: http://git.graph-it.com/?a=commitdiff_plain;h=9dae4c07b6fd2a31f4f4671ac30b38cdf0cc32d1;p=tutorials%2Fcontrolpi-plugin.git Completed tutorial --- diff --git a/doc/DebugView.png b/doc/DebugView.png new file mode 100644 index 0000000..0933855 Binary files /dev/null and b/doc/DebugView.png differ diff --git a/doc/index.md b/doc/index.md index a40b26c..d5891b4 100644 --- a/doc/index.md +++ b/doc/index.md @@ -23,12 +23,12 @@ controlpi-template controlpi-` (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 das Template-Repository, sondern (falls +.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 ```shell -$ cp -R controlpi-template controlpi-overshoot +$ cp -r controlpi-template controlpi-overshoot $ cd controlpi-overshoot $ rm -rf .git/ ``` @@ -78,6 +78,49 @@ Beide sollten für unser Projekt angepasst werden. In `setup.py` können auch Abhängigkeiten zu anderen Python-Bibliotheken deklariert werden. +`README.md`: +```markdown +# 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`: +```python +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 @@ -139,11 +182,259 @@ 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: +```python +"""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. +```python +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: +```python + 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: +```python + 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): +```python + 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: +```python + 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: +```python + 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: +```python + 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: +```python + 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: +```python + 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: +```python + 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: +* Starten eines Web-Servers, der auf eingehende Verbindungen wartet +* Starten eines parallelen Threads, der Peripherie-Ereignisse in +Bus-Nachrichten übersetzt +* Starten eines parallelen Threads, der eine Schnittstelle überwacht + +### Beispiel-Konfiguration +Die Beispiel-Konfiguration sieht so aus: +```json +{ + "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: +```shell +$ 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: +```shell +$ python -m controlpi conf.json +``` +Ein Zustand dieses Beispiels sieht zum Beispiel so aus: +![Debug-Oberfläche](tutorials/controlpi-plugin/DebugView.png) + +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.