Completed tutorial
authorBenjamin Braatz <bb@bbraatz.eu>
Mon, 4 Sep 2023 17:33:12 +0000 (19:33 +0200)
committerBenjamin Braatz <bb@bbraatz.eu>
Mon, 4 Sep 2023 17:33:12 +0000 (19:33 +0200)
doc/DebugView.png [new file with mode: 0644]
doc/index.md

diff --git a/doc/DebugView.png b/doc/DebugView.png
new file mode 100644 (file)
index 0000000..0933855
Binary files /dev/null and b/doc/DebugView.png differ
index a40b26cca87eaf0334c36e6fdf1c36296a514f71..d5891b4cd0619410b39c7ba6b5c055f41f0ed469 100644 (file)
@@ -23,12 +23,12 @@ 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 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.