Graph-IT

Control-Pi-Infrastruktur

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.

Überblick

Die ControlPi-Infrastruktur hat zwei Haupt-Bestandteile:

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:

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?

Beispiel-Konfiguration

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.

Installation auf Raspberry Pi

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

Installation zum Entwickeln

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>

Code-Stil, Typ-Checks und Tests

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.