ControlPi-Systeme sind Raspberry Pis, die Steuerungs-Aufgaben erledigen. Sie sind nach dem Prinzip der übergeordneten Steuerung in einem Baum organisiert, wobei jeweils höhere ControlPis die jeweils niedrigeren organisieren, ohne dass benachbarte Systeme oder weiter oben oder unten in der Hierarchie befindliche voneinander wissen müssen.
Dieses Paket enthält die grundlegende Infrastruktur für ein ControlPi-System, die Plugins verwaltet und ihnen ermöglicht über einen Bus Nachrichten auszutauschen.
Die ControlPi-Infrastruktur hat zwei Haupt-Bestandteile:
controlpi_plugins.util
, controlpi_plugins.state
und
controlpi_plugins.wait
befinden sich eine Reihe nützlicher
Standard-Plugins, an denen die generelle Struktur nachvollzogen werden
kann.Die generierte Dokumentation des API der grundlegenden Infrastruktur ist unter controlpi/ zu finden. Die Kollektion an grundlegenden Plugins ist unter controlpi_plugins/ dokumentiert.
Um Nachrichten zu senden und/oder zu empfangen muss ein Klient am Bus unter einem Namen registriert werden. Eine Plugin-Instanz kann entweder genau einen Klienten registrieren (der dann sinnvollerweise den gleichen Namen wie die Plugin-Instanz trägt) oder aber beliebig viele Klienten z.B. für mehrere mit einem Hardware-Bus verbundene Geräte oder die gerade offenen Verbindungen einer Netzwerk-Schnittstelle.
Die Infrastruktur definiert nur die Anforderung, dass jede gesendete
Nachricht einen speziellen Schlüssel "sender"
mit dem Namen des sendenden
Klienten als Wert enthält.
Der Bus überprüft, ob diese Art von Nachricht für diesen Sender registriert
wurde, und liefert sie nur dann aus.
Darüber hinaus gibt es keine harten Einschränkungen an den Aufbau von Nachrichten. Sie müssen nicht unbedingt bestimmte Empfänger haben, sie gehören nicht unbedingt zu einem bestimmten Typen etc.
Einige Konventionen, die z.B. von den Plugins in diesem Repository eingehalten werden, sind aber:
"event"
mit einem Wert, der die
Art des Ereignisses passend benennt und eventuell weitere Schlüssel, die
dieses näher beschreiben.
Auch das Registrieren und Deregistrieren von Klienten am Bus selbst wird
als Ereignis mit einem "event"
-Schlüssel signalisiert."target"
mit dem Namen dieses
Ziel-Klienten als Wert und einen Schlüssel "command"
mit einem
festgelegten Namen für das auszuführende Kommando und eventuell weiteren
Schlüsseln, die dann so etwas wie Parameter des Kommandos sind.
(Es können aber auch andere Klienten, beispielsweise Logger oder
Debug-Oberflächen, diese Nachrichten empfangen und verarbeiten, da
"target"
-Schlüssel von der Infrastruktur bzw. dem Bus nicht speziell
behandelt werden.)State
-Plugin ist ein Beispiel, welche Nachrichten (Boolesche)
Zustände senden und empfangen sollten:
Bei Änderung des Zustandes wird ein Ereignis mit "event"
-Wert
"changed"
und dem neuen Zustand als "state"
-Wert geschickt.
Das Plugin reagiert auf ein "get state"
-Kommando, indem es eine
Nachricht mit dem Schlüssel "state"
und dem aktuellen Zustand als Wert
(aber ohne "event"
-Schlüssel schickt, da kein wirkliches Ereignis
eingetreten ist) schickt.
Da in diesem Plugin der Zustand auch von außen setzbar ist, reagiert es
auf ein "set state"
-Kommando, das einen "new state"
-Schlüssel mit
einem neuen, zu setzenden Wert enthält, indem es diesen Zustand setzt
(und eine Ereignis-Nachricht sendet, wenn dies eine Änderung war).State
-Plugin senden als Reaktion auf
"get …"
-Nachrichten Antworten, die genau so aufgebaut sind wie die
Ereignis-Nachrichten, die sie auch aktiv senden, wenn eine Änderung
eingetreten ist.
Hierdurch können Klienten diese Nachrichten gleich behandeln, wenn das
Änderungs-Ereignis für sie nicht relevant ist, sondern nur die weiteren
Informationen in der Nachricht (der jetzt aktuelle Zustand der
State
-Instanz bzw. das Vorhandensein und das Interface eines Klienten
am Bus).Hinsichtlich der Reihenfolge in der verschiedene Klienten, die alle für die gleiche Nachricht registriert sind, diese erhalten, werden von der Infrastruktur keine Garantien gegeben und Plugin-Implementierungen sollten sich nicht darauf verlassen, die einzigen zu sein, die auf eine Nachricht reagieren.
Die ControlPi-Infrastruktur ist auf nebenläufiger Programmierung mit
asyncio
aufgebaut.
Diese garantiert, dass Code solange ohne Unterbrechung läuft, bis er selbst
die Kontrolle abgibt, weil er auf eine Ein- oder Ausgabe-Operation oder
ähnliches wartet.
In welcher Reihenfolge andere wartende Aufgaben dann ausgeführt werden,
sollte aber nicht vorausgesetzt werden, selbst wenn es als
Implementierungs-Detail eventuell recht sicher vorhergesehen werden kann.
Ein Tutorial, wie ein neues Plugin für das ControlPi-System geschrieben werden kann, findet sich unter: Wie schreibe ich ein ControlPi-Plugin?
Eine Beispiel-Konfiguration, die nur die mitgelieferten Werkzeug-Plugins verwendet, ist im Repository enthalten. Das System kann mit dieser Konfiguration gestartet werden:
(venv)$ python -m controlpi <Pfad zum Code-Repository>/conf.json
(Das System läuft in normalerweise in einer Endlos-Schleife und kann mit Ctrl-C oder durch anderes Beenden seines Prozesses von außen beendet werden.)
Die Beispiel-Konfiguration sieht folgendermaßen aus:
{
"Example State": {
"plugin": "State"
},
Ein State
-Plugin speichert intern einen Booleschen Wert (True
oder
False
) und stellt Kommandos "get state"
und "set state"
zur
Verfügung, um diesen Wert abzufragen oder zu ändern und reagiert darauf
durch das Senden von "state"
-Ereignissen.
"Example GenericWait": {
"plugin": "GenericWait"
},
Ein GenericWait
-Plugin reagiert auf "wait"
-Kommandos, die Schlüssel
"seconds"
und "id"
enthalten, indem es die angegebene Anzahl Sekunden
wartet und dann ein "finished"
-Ereignis mit dem angegebenen "id"
-String
sendet.
"Trigger Wait Check": {
"plugin": "Alias",
"from": {
"sender": { "const": "Example GenericWait" },
"id": { "const": "Check" }, "event": { "const": "finished" }
},
"to": {
"target": "Example GenericWait", "seconds": 1.0,
"id": "Check", "command": "wait"
}
},
In der Beispiel-Konfiguration verwenden wir Alias
-Instanzen, um bei
Ablauf verschiedener Wartezeiten Aktionen auszulösen.
Hier wird bei Ablauf einer Wartezeit mit "id": "Check"
sofort eine neue
Wartezeit von einer Sekunde mit der gleichen "id"
gestartet.
"Trigger State Check": {
"plugin": "Alias",
"from": {
"sender": { "const": "Example GenericWait" },
"id": { "const": "Check" }, "event": { "const": "finished" }
},
"to": {
"target": "Example State", "command": "get state"
}
},
Außerdem wird bei Ablauf von "id": "Check"
einmal der momentane Zustand
von "Example State"
mit "get state"
abgefragt.
Es wird also im Endeffekt jede Sekunde einmal der Zustand des
State
-Plugins abgefragt.
Statt Alias
-Instanzen zu verwenden, um Ereignisse direkt in Kommandos
anderer (oder desselben) Plugins zu übersetzen, werden im Paket
controlpi-statemachine
Zustands-Maschinen zur Verfügung gestellt, mit denen sich komplexere
Verschaltungen eleganter konfigurieren lassen.
"Trigger Wait On Off": {
"plugin": "Alias",
"from": {
"sender": { "const": "Example GenericWait" },
"id": { "const": "On" }, "event": { "const": "finished" }
},
"to": {
"target": "Example GenericWait", "seconds": 1.5,
"id": "Off", "command": "wait"
}
},
"Trigger Wait Off On": {
"plugin": "Alias",
"from": {
"sender": { "const": "Example GenericWait" },
"id": { "const": "Off" }, "event": { "const": "finished" }
},
"to": {
"target": "Example GenericWait", "seconds": 1.5,
"id": "On", "command": "wait"
}
},
Mit diesen beiden Alias
-Instanzen wird dafür gesorgt, dass bei Ablauf
einer "On"
-Wartezeit eine "Off"
-Wartezeit von anderthalb Sekunden
gestartet wird und umgekehrt.
"Trigger State On Off": {
"plugin": "Alias",
"from": {
"sender": { "const": "Example GenericWait" },
"id": { "const": "On" }, "event": { "const": "finished" }
},
"to": {
"target": "Example State", "command": "set state",
"new state": false
}
},
"Trigger State Off On": {
"plugin": "Alias",
"from": {
"sender": { "const": "Example GenericWait" },
"id": { "const": "Off" }, "event": { "const": "finished" }
},
"to": {
"target": "Example State", "command": "set state",
"new state": true
}
},
Bei Ablauf einer "On"
-Wartezeit wird "Example State"
mit "set state"
ausgeschaltet, bei Ablauf von "Off"
eingeschaltet.
Zusammen sorgt dies dafür, dass "Example State"
alle anderthalb Sekunden
den Zustand wechelt.
"Test Procedure": {
"plugin": "Init",
"messages": [
{ "target": "Example GenericWait", "seconds": 1.5,
"id": "Off", "command": "wait" },
{ "target": "Example GenericWait", "seconds": 1.0,
"id": "Check", "command": "wait" }
]
},
Init
-Instanzen senden einfach am Beginn der Ausführung des
ControlPi-Systems eine konfigurierte Liste von Nachrichten.
Hier werden eine erste "Off"
- und eine erste "Check"
-Wartezeit
gestartet, die sich durch die oben beschriebenen Alias
-Instanzen dann
endlos selbst auslösen.
"Debug Logger": {
"plugin": "Log", "filter": [{}]
},
"State Change Logger": {
"plugin": "Log",
"filter": [
{
"sender": { "const": "Example State" },
"event": { "const": "changed" }
}
]
}
}
Log
-Instanzen geben einfach durch einen konfigurierten Filter bestimmte
Nachrichten auf der Konsole (oder, falls das System als systemd-Unit läuft,
im System-Journal) aus.
Hier wird eine Instanz "Debug Logger"
, die alle Nachrichten ausgibt, und
eine Instanz "State Change Logger"
, die nur "changed"
-Ereignisse der
State
-Instanz ausgibt, konfiguriert.
Die Ausgabe eines Laufs dieses Beispiel-Systems sieht folgendermaßen aus:
$ python -m controlpi conf.json
Debug Logger: {'sender': '', 'event': 'registered',
'client': 'Example State', 'plugin': 'State',
'sends': [{'event': {'const': 'changed'},
'state': {'type': 'boolean'}},
{'state': {'type': 'boolean'}}],
'receives': [{'target': {'const': 'Example State'},
'command': {'const': 'get state'}},
{'target': {'const': 'Example State'},
'command': {'const': 'set state'},
'new state': {'type': 'boolean'}}]}
...
Debug Logger: {'sender': '', 'event': 'registered',
'client': 'State Change Logger', 'plugin': 'Log',
'sends': [],
'receives': [{'sender': {'const': 'Example State'},
'event': {'const': 'changed'}}]}
Die Instanz Debug Logger
zeigt uns alle im System verschickten
Nachrichten an.
Zunächst meldet der Nachrichten-Bus selbst alle Registrierungen der
konfigurierten Plugins als Klienten des Busses mit den jeweils für sie
registrierten gesendeten und empfangenen Nachrichten-Vorlagen.
In unserem Fall gibt es für jedes Plugin genau einen Klienten.
Für komplexere Plugins kann es aber durchaus mehrere (oder keine) Klienten
für ein Plugin geben, beispielsweise für jede gerade offfene Verbindung zu
einer Netzwerkschnittstelle.
Debug Logger: {'sender': 'Test Procedure', 'target': 'Example GenericWait',
'seconds': 1.5, 'id': 'Off', 'command': 'wait'}
Debug Logger: {'sender': 'Test Procedure', 'target': 'Example GenericWait',
'seconds': 1.0, 'id': 'Check', 'command': 'wait'}
Die Init
-Instanz "Test Procedure"
sendet ihre beiden konfigurierten
Nachrichten und startet damit sowohl die erste "Off"
- als auch die erste
"Check"
-Wartezeit.
Debug Logger: {'sender': 'Example GenericWait',
'event': 'finished', 'id': 'Check'}
Debug Logger: {'sender': 'Trigger Wait Check',
'target': 'Example GenericWait', 'seconds': 1.0,
'id': 'Check', 'command': 'wait'}
Debug Logger: {'sender': 'Trigger State Check',
'target': 'Example State', 'command': 'get state'}
Debug Logger: {'sender': 'Example State', 'state': False}
Nach einer Sekunde ist die "Check"
-Wartezeit das erste Mal abgelaufen.
Sie wird durch die erste ihrer Alias
-Instanzen sofort neu gestartet.
Durch die zweite Alias
-Instanz wird der momentane Zustand von
"Example State"
abgefragt, der dann antwortet.
Debug Logger: {'sender': 'Example GenericWait',
'event': 'finished', 'id': 'Off'}
Debug Logger: {'sender': 'Trigger Wait Off On',
'target': 'Example GenericWait', 'seconds': 1.5,
'id': 'On', 'command': 'wait'}
Debug Logger: {'sender': 'Trigger State Off On',
'target': 'Example State', 'command': 'set state',
'new state': True}
Debug Logger: {'sender': 'Example State',
'event': 'changed', 'state': True}
State Change Logger: {'sender': 'Example State',
'event': 'changed', 'state': True}
Eine halbe Sekunde später sorgt der Ablauf der "Off"
-Wartezeit dafür,
dass "Example State"
auf True
gesetzt wird.
Hier reagiert jetzt auch der "State Change Logger"
.
Debug Logger: {'sender': 'Example GenericWait',
'event': 'finished', 'id': 'Check'}
Debug Logger: {'sender': 'Trigger Wait Check',
'target': 'Example GenericWait', 'seconds': 1.0,
'id': 'Check', 'command': 'wait'}
Debug Logger: {'sender': 'Trigger State Check',
'target': 'Example State', 'command': 'get state'}
Debug Logger: {'sender': 'Example State', 'state': True}
Wiederum eine halbe Sekunde später wird durch Ablauf von "Check"
wieder
eine Abfrage des Zustands ausgelöst.
Debug Logger: {'sender': 'Example GenericWait',
'event': 'finished', 'id': 'On'}
Debug Logger: {'sender': 'Trigger Wait On Off',
'target': 'Example GenericWait', 'seconds': 1.5,
'id': 'Off', 'command': 'wait'}
Debug Logger: {'sender': 'Trigger State On Off',
'target': 'Example State', 'command': 'set state',
'new state': False}
Debug Logger: {'sender': 'Example State',
'event': 'changed', 'state': False}
State Change Logger: {'sender': 'Example State',
'event': 'changed', 'state': False}
Nachdem "On"
das erste Mal abgelaufen ist, wird "Example State"
wieder
auf False
gesetzt.
Dies wiederholt sich jetzt solange weiter, bis das Programm abgebrochen wird.
Ein reales System wird weniger von sich immer wieder selbst triggernden Abläufen geprägt sein, sondern eher auf Einflüsse von außen reagieren. Beispielsweise kann ein System durch controlpi-wsserver Nachrichten über Websockets austauschen und damit auf Web-Oberflächen und andere externe Systeme reagieren oder durch controlpi-pinio mit an die GPIO-Pins eines Raspberry Pi angeschlossener Hardware interagieren.
Auf dem Raspberry Pi wollen wir den Code in ein virtuelles Environment
installieren, wobei die weitere Vorgehensweise davon ausgeht, dass dieses
direkt unter /home/pi
liegt, also insgesamt den Pfad
/home/pi/controlpi-venv
hat:
$ sudo apt install python3-venv git
$ python3 -m venv controlpi-venv
$ source controlpi-venv/bin/activate
(venv)$ pip install --upgrade pip setuptools wheel
(venv)$ pip install git+git://git.graph-it.com/graphit/controlpi.git
Die Datei conf.json
und die Datei controlpi.service
aus dem
Haupt-Verzeichnis des Repositories (oder jede anders, speziell für den
Einsatzzweck konfigurierte conf.json
) sollten ebenfalls nach /home/pi
übertragen werden.
Die controlpi.service
ist vorbereitet, das ControlPi-System als
systemd-Service direkt beim Hochfahren zu starten und am Laufen zu halten.
Sie sieht folgendermaßen aus:
[Unit]
Description=ControlPi Service
Wants=network-online.target
After=network-online.target
[Service]
WorkingDirectory=/home/pi
Environment=PYTHONUNBUFFERED=1
ExecStart=/home/pi/controlpi-venv/bin/python -m controlpi conf.json
Restart=Always
[Install]
WantedBy=multi-user.target
Sie wird durch folgende Kommandos aktiviert und gestartet:
$ sudo cp /home/pi/controlpi.service /etc/systemd/system/
$ sudo systemctl enable controlpi.service
$ sudo systemctl start controlpi.service
Die Ausgaben können dann im Journal nachvollzogen werden:
$ journalctl -u controlpi.service
Es wird mindestens Python 3.7 benötigt.
$ sudo apt install python3 python3-venv
Das Repository mit dem Code dieses Pakets kann vom öffentlichen git-Server bezogen werden:
$ git clone git://git.graph-it.com/graphit/controlpi.git
(Falls Zugang zu diesem Server per SSH besteht und Änderungen gepusht werden sollen, sollte stattdessen die SSH-URL benutzt werden.)
Damit Python-Module in diesem Paket gefunden und eventuelle Abhängigkeiten
installiert werden, dies aber von anderen Teilen des Systems, auf dem
entwickelt wird, isoliert gehalten wird, sollte ein virtuelles Environment
verwendet werden. Wo dieses Verzeichnis liegt, ist dabei relativ egal.
Übliche Orte sind innerhalb des Code-Repository-Verzeichnisses (hierfür
wird venv/
von git ignoriert), parallel zum Code-Repository oder an einer
zentralen Stelle des Home-Verzeichnisses gesammelt.
Sobald das virtuelle Environment eingerichtet und aktiviert ist, beziehen
sich Befehle wie python
und pip
auf die in diesem Environment
konfigurierten und müssen nicht mehr mit genauer Version aufgerufen werden:
$ python3 -m venv <Pfad zum venv>
$ source <Pfad zum venv>/bin/activate
(venv)$ pip install --upgrade pip setuptools wheel
Damit Änderungen am Code sofort wirksam werden und getestet werden können, sollte das Paket, an dem gerade entwickelt wird, im virtuellen Environment editierbar installiert werden:
(venv)$ pip install --editable <Pfad zum Code-Repository>
Die Formatierung des Codes und das Vorhandensein und der Stil der
Kommentare werden durch
pycodestyle
und
pydocstyle
überprüft.
Die an Funktionen und Variablen annotierten Typ-Informationen werden durch
mypy
gecheckt.
Alle drei Tools können rekursiv ein gesamtes Python-Paket überprüfen:
(venv)$ pycodestyle <Pfad zum Code-Repository>/controlpi
(venv)$ pydocstyle <Pfad zum Code-Repository>/controlpi
(venv)$ mypy <Pfad zum Code-Repository>/controlpi
Sie sind als Extra-Requirements in der Datei setup.py
definiert, sodass
sie mit einem einzigen pip
-Aufruf installiert werden können:
(venv)$ pip install --editable <Pfad zum Code-Repository>[dev]
Der Code wird durch in die Dokumentation eingebettete „doctests“ getestet.
Diese können für jede Code-Datei einzeln mit dem in der
Python-Standard-Distribution enthaltenen Modul
doctest
ausgeführt
werden:
(venv)$ python -m doctest <Pfad zur Code-Datei>
(Für die Datei __main__.py
, die das kleine Hauptprogramm zum Ausführen
des Systems enthält, funtioniert dies leider nicht.)
Komplette Informationen über alle durchgeführten Tests und die Abdeckung
des Codes mit Tests erhält man mit der zusätzlichen Option -v
:
(venv)$ python -m doctest -v <Pfad zur Code-Datei>
Außerdem wird durch die [dev]
-Extras in setup.py
auch das Tool
pdoc
zur automatischen Generierung von
API-Dokumentation in HTML installiert:
(venv)$ pdoc --html --config sort_identifiers=False --force \
--output-dir doc/ controlpi/ controlpi_plugins/
Mit diesem wurden auch die oben verlinkten API-Dokumentationen für
controlpi
und
controlpi_plugins
generiert.