Software-Details (Version 2.0)

Die Plattform an sich

Als Controller kommt der Raspberry Pi Pico W zum Einsatz (wie er offiziell heißt; ich sage gerne Pico 1 W, denn inzwischen gibt es auch den Pico 2 W mit mehr von allem). Um genau zu sein ist es sogar der Pico WH den ich verwende, das H steht für Header und bezeichnet die Variante, die bereits auf einer Stiftleiste montiert ist. Mein Gedanke war, dass jeder das energy-display auf Lochraster nachbauen können sollte, ohne ein spezifisches PCB zu benötigen. Die Idee habe ich allerdings schon wieder zu bereuen begonnen, weil "nur mal schnell" auf Lochraster aufbauen am Ende doch immer eine ganze Weile dauert, da man ständig aufpassen muss, keine Verdrahtungsfehler zu machen.

Programmiert habe ich in MicroPython. Das ist eine Python-Variante für Mikrocontroller-Umgebungen, die auf Python 3.4 basiert, aber auch zahlreiche Features späterer Python-Versionen beinhaltet (zum Beispiel f-strings). Als Entwicklungsumgebung verwende ich Visual Studio Code mit der Extension MicroPico. Weiterhin kommt gelegentlich das Kommandozeilen-Werkzeug mpremote zum Einsatz, um einzelne Abläufe zu automatisieren (Dateien runterladen, Packages installieren, etc.).

Ein wesentlicher Erfolgsfaktor von Python besteht in den zahlreichen Packages für so ziemlich jeden Zweck. Dieses Konzept gibt es auch bei MicroPython, wobei dort mit mip ("mip installs packages") gearbeitet wird. Die Pakete stammen entweder aus der micropython-lib, oder aus anderen GitHub- oder GitLab-Repositories, die direkt per URL angegeben werden können. In Version 2.0 benutze ich z.B. diese zusätzlichen Packages: umqtt.simple, pimoroni/phew, ntptime, pathlib, time, logging. Darüber hinaus sind zahlreiche Pakete bereits fest eingebaut (z.B. math, os, json, asyncio).

Jeder will seine eigene Plattform bauen

Als Software-Architekt neigt man irgendwie dazu, Plattformen zu bauen. Ich wollte diesmal "nur" eine Anwendung bauen, aber irgendwie waren doch immer wieder Aspekte dabei, die dort raus wollten. Nach mehreren Umbenennungen und Refactorings kam ich bei dieser Hauptstruktur heraus (aufs Wesentliche gekürzt):

Alles was applikationsspezifisch ist liegt unter app/ und wird in eine Klasse EnergyDisplay aggregiert, die von runtime.Application abgeleitet ist. Mit dem System kommuniziert es über das Interface runtime.Runtime, das einzelne Runtime-Komponenten exponiert, wie z.B. einen MqttProvider oder eine EndpointRegistry (wobei dies auch nur schlanke Interfaces der tatsächlichen Komponenten sind).

Unter runtime/ folgen ca. 10 weitere Packages mit Komponenten, die spezifische Aufgaben erfüllen, wie z.B. clock, das die Systemzeit via NTP aktuell hält und registrierte Clients über Anpassungen informiert. Die Runtime selbst wird durch die Klasse AsyncioRuntime gebildet, die wiederum alle Runtime-Komponenten aggregiert und u.a. die asyncio-Hauptschleife beinhaltet.

In config.json steht die Konfiguration aller Komponenten, ebenfalls unterteilt in die beiden Hauptzweige app und runtime, und darunter thematisch weiter untergliedert. Um genau zu sein gibt es im Dateisystem anfangs nur eine defaults.json, die dann verwendet wird, wenn es keine config.json gibt; das folgt einem Copy-On-Write-Ansatz, bei dem config.json automatisch dadurch entsteht, dass von den Defaults abweichende Werte hinterlegt werden. (Allerdings auf Datei-Ebene, es wird nicht zusammengeführt.)

main.py hält schlußendlich alles zusammen, indem es die Instanzen anlegt und die Applikation mit der konkreten Runtime bekannt macht. Normalerweise wäre das auch der Ort, an dem die ganzen dreckigen Details sichtbar werden, allerdings stecken diese weitgehend in der Konfigurationsdatei, die hier ebenfalls geöffnet und als ConfigView-Instanz in die Komponenten rein gereicht wird.

Abhängigkeiten auflösen

Eine der Hauptaufgaben eines Software-Entwicklers besteht darin, die Abhängigkeiten einzelner Komponenten, Module, Arbeitsschritte, etc. zu identifizieren und richtig aufzulösen. Die Applikation will sich per MQTT auf ein Topic registrieren, der MQTT-Proxy muss zuvor aber mit dem MQTT-Broker verbunden sein, um die Subscribe-Nachricht verschicken zu können, und das alles funktioniert nur, wenn das WLAN verbunden ist. Dabei begegnen sich sowohl inhaltliche Abhängigkeiten (die Applikation muss den MQTT-Provider kennen, aber umgekehrt sollte der MQTT-Provider nicht von der Applikation abhängig sein) als auch zeitliche Abhängigkeiten (die bereits skizzierte Reihenfolgen-Thematik). Die inhaltlichen Abhängigkeiten bekommt man für gewöhnlich mit Interfaces in den Griff, die zeitlichen Abhängigkeiten mit Callbacks.

Ein Interface sollte möglichst schmal und inhaltlich zusammenhängend sein (in gehobeneren Informatiker-Kreisen spricht man auch von Kohäsion und Interface Segregation, aber viel davon ist gesunder Menschenverstand hochtrabend verpackt). Dazu habe ich jeder Komponente, die nach außen in Erscheinung tritt, ein eigenes Interface verpasst, das nur das Notwendigste beinhaltet. Der bereits erwähnte MqttProvider hat beispielsweise die Methoden publish, subscribe und unsubscribe. Tatsächlich implementiert wird dieses Interface von der Klasse MqttProxy, die selbst mit einem "echten" Broker in Verbindung steht; ein Detail, das den Benutzer des MqttProvider-Interfaces gar nicht interessiert. Außerdem merkt sich der MqttProxy die Topic-Wünsche seiner Clients in einer eigenen Liste, die er abarbeitet, wenn die Verbindung zum Broker später / erneut hergestellt wird.

Für die Veranschaulichung der Callbacks kommen mehrere Beispiele in Frage: die Verbindung zum WLAN wurde hergestellt, die Uhr wurde (erstmals) synchronisiert, die Konfiguration wurde geändert, oder auch: eine MQTT-Nachricht ist eingetroffen. Alle haben gemeinsam, dass ein "Vertrag" zwischen Anbieter und Nutzer geschlossen wird: Du rufst diese meine Funktion auf, wenn das und das geschieht. Damit lassen sich erstaunlich komplexe Abhängigkeiten abbilden, man muss nur etwas aufpassen, dass keine Rekursionen entstehen, und dass scheinbar harmlose Aufrufe lange dauern können, wenn eine ganze Abhängigkeitskette losgetreten wird. (Berühmtes Beispiel: Donald E. Knuth wollte seine Buchreihe The Art of Computer Programming schreiben und hat dafür zunächst das Textsatzsystems TeX entwickelt...)

Konfigurationsänderungen zur Laufzeit

Der Haupt-Treiber der Callback-Architektur war tatsächlich das Zulassen von Konfigurationsänderungen zur Laufzeit. Nachdem ich einen API-Endpunkt zum Editieren der Konfiguration bereitgestellt hatte, erschien es enorm unelegant, das Gerät neustarten zu müssen, damit z.B. eine geänderte zeitliche Auflösung der Einspeisehistorie Wirkung zeigt. Viele Refactorings später kam ich bei einem Interface ConfigView heraus, das eine Ansicht auf einen bestimmten Teilbaum des JSON-Objekts aus config.json darstellt. Dabei kann jede Komponente eine solche Baum-Ansicht weiter einschränken, z.B. kann Runtime den gesamten Zweig runtime bearbeiten, aber um die Komponente MqttProxy zu konfigurieren einfach nur den Teilbaum runtime.mqtt weiter reichen. Als Benutzer eines ConfigView-Objekts kann man sich für Änderungen registrieren und wird fortan immer dann aufgerufen, wenn sich das JSON-Objekt geändert hat. (Allerdings bislang stumpf immer, egal ob die Änderungen unterhalb dieses Teilbaums waren oder nicht.)

Die Komponenten selbst müssen sich ihre aktuellen Betriebswerte merken und bei einem Aufruf des config_changed-Callbacks prüfen, ob sie etwas zu tun haben. Wird z.B. der minimale Anzeigewert der Batterieanzeige geändert (also wann die Batterie als leer gilt), dann muss die Anzeige mit dem bestehenden, zuletzt empfangenen Wert und der neuen Randbedingung neu gezeichnet werden. Wird aber das Feld oder Topic geändert, wäre es nur seriös den zuletzt empfangenen Wert komplett zu verwerfen und auf einen neuen zu warten, bis wieder etwas dargestellt werden kann, weil es ja sonst gelogen wäre. Viel einfacher hingegen ist der Fall bei der Display-Helligkeit: hier wird nur ein Faktor aktualisiert und im gleichen Kontext passiert nichts weiter; die Änderung zeigt ihre Wirkung erst bei der nächsten, zyklisch erfolgenden Display-Aktualisierung.

Auf den Konfigurations-Mechanismus bin ich ziemlich stolz. War ein zähes Biest, aber bislang gibt es keine Eigenschaft, für deren Änderung ein Neustart notwendig wäre (von den bislang nicht online editierbaren WLAN-Zugangsdaten abgesehen).

API-Endpunkte und Webserver

Das energy-display ist mein erstes Projekt, bei dem ich einen Webserver integriert habe. Ich verwende dazu das Package phew von Pimoroni, basierend auf einem Tipp aus der Make. Zwar liefert der Webserver auch statische Seiten aus, aber seine Hauptaufgabe in meinem Projekt besteht in der Bereitstellung von API-Endpunkten zur Interaktion mit dem Gerät. Um ganz genau zu sein ist es derzeit nur der Endpunkt /config der tatsächlich Änderungen verursachen kann, die anderen Endpunkte sind eher informativer Natur:

Route Methode Wirkung
/config GET Aktuelle Konfiguration darstellen
PUT Konfiguration durch neuen Inhalt ersetzen
DELETE Default-Werte wiederherstellen
/syslog GET System-Log darstellen
/status GET Status-Auskunft erteilen
/version GET Versions-Informationen darstellen

Die Informationen werden immer als JSON-Objekte bereitgestellt bzw. entgegengenommen. Für eine etwas benutzerfreundlichere Darstellung habe ich ein paar Webseiten gebaut, die diese Daten per JavaScript abfragen (Fetch API) und darstellen. Ich bin kein Experte auf diesem Gebiet, aber es erschien mir einfach und ressourcenschonend (die meiste Arbeit entsteht beim Client). Der Nachteil ist die relativ enge Kopplung (die Status-Seite kennt z.B. den genauen Aufbau des JSON-Objekts; kommt ein neues Feld hinzu, muss die Seite ebenfalls angepasst werden), aber andererseits wäre diese bei der serverseitigen Erzeugung auch vorhanden, nur anderswo. Naja, und der DOM-Baum lässt sich schlechter untersuchen, wenn die Elemente dynamisch generiert werden; dafür sind "lokale" Themen wie die zu verwendende Zeitzone deutlich sauberer lösbar.

Bei der Latenz habe ich große Unterschiede gesehen. Wenn es schnell geht, dann kommt eine Antwort innerhalb von rund 40 ms, manchmal kann es aber auch bis zu 300 ms dauern (Abfrage eines API-Endpunkts; die komplette Darstellung einer Seite, die nach dem eigenen Laden noch den Inhalt nachlädt, kann auch mal 800 ms dauern). Im Durchschnitt liegt die Antwortzeit im Bereich von 100 bis 200 Millisekunden, wobei der Aufwand für die Beschaffung des Inhalts (Status-Provider abklappern, Syslog lesen) kaum ins Gewicht zu fallen scheint. Ich vermute die Latenz ist in der Ein- und Ausgabe zu suchen, und der Art wie hierbei das Scheduling der asyncio-Coroutinen erfolgt.

System-Log und weitere Statusmeldungen

Logging ist in einem eingebetteten System immer so eine Sache: einerseits will man es, andererseits läuft einem der begrenzte Speicher voll. Ich habe auf dem bewährten logging.Logger-Ansatz von Python aufgesetzt und einen RotatingFileHandler gebaut, der die Log-Ausgaben in einem Set von .ndjson-Dateien sammelt. Dabei kann eine Maximalgröße konfiguriert werden, ab der eine neue Logdatei begonnen wird, und eine Maximalzahl alter Logdateien, die aufgehoben werden sollen. Über den API-Endpunkt /syslog werden die Meldungen als ein zusammenhängendes Array aus JSON-Objekten gestreamt (mit einem Generator; abgefahrene Sache), sodass diese Stückelung komplett verborgen bleibt. Durch den logging.Logger-Ansatz unterstützt das ganze auch die verschiedenen Level (Fehler, Warnung, Info, etc.) und die darauf basierende Filterung (während der Entwicklung lasse ich z.B. Meldungen der Stufe Debug mitprotokollieren, im produktiven Betrieb nur ab Info aufwärts). Dank zweier strategisch klug platzierter Exception-Handler werden auch unvorhergesehene Fehlerszenarien "gefangen" und inklusive Stacktrace gespeichert (sofern das System noch dazu in der Lage ist). Eine Webseite zeigt die JSON-Daten auch in hübsch an und fürbt die Meldungen entsprechend ein (Debug-Meldungen grau, Fehler rot, etc.).

Parallel zum Logging existiert eine Status-Schnittstelle, die Auskunft über den Zustand der Runtime und der Applikation gibt. Jede Komponente kann das Interface StatusProvider implementieren, das die Bereitstellung einer Eigenschaft status fordert, die ein Dictionary zurückliefert. Ein solcher Status Provider registriert sich bei einem Status Collector. Dieser wiederum kann über zwei Wege in Erscheinung treten: per API-Endpunkt /status kann der Status asynchron zu einem beliebigen Zeitpunkt erfragt werden. Weiterhin kann der StatusPublisher angewiesen werden, den Status zyklisch abzufragen und als MQTT-Nachricht zu versenden. Letzteres eignet sich dann z.B. hervorragend, um via Telegraf / InfluxDB / Grafana gesammelt und ausgewertet zu werden. Für die einfache Darstellung im Webbrowser gibt es eine Webseite, die den API-Endpunkt befragt und die Antwort in aufbereiteter Form darstellt (optional mit regelmäßiger Aktualisierung).

Eine Teilmenge der Status-Informationen sind die Geräte-Informationen, bereitgestellt durch den DeviceMonitor. Dieser gibt Auskunft über aktuelle Systemzeit, Uptime, Temperatur sowie Auslastung von Arbeitsspeicher und Dateisystem. Der Device-Monitor war auch der erste Kunde der ClockSyncer-Komponente, die Datum und Uhrzeit per NTP abfragt und nachführt, denn mangels Pufferbatterie startet das energy-display immer am 1. Januar 2021 um 0 Uhr. Bis jetzt ist die Uhrzeit zwar nur für die Logmeldungen wirklich relevant, wird aber auch zur Berechnung der Uptime genutzt, indem die Uhrzeit des Systemstarts gemerkt wird; dieser wird dann rückwirkend und einmalig nach der ersten Synchronisation angepasst, als Reaktion auf den clock_synced-Callback, der das Delta mitbekommt, um das die Uhr gestellt wurde. Es sind diese Kleinigkeiten, die nach und nach zu einem runden Gesamtbild führen, aber auch den verfügbaren Speicher des Mikrocontrollers irgendwann zum Platzen bringen werden; derzeit sind ca. 60% belegt, aber ich habe noch Pläne... (zur Not folgt der Umstieg auf den Pico 2 W, der doppelt so viel Flash-Speicher hat).

Die Anwendung

So spannend eine Plattform an sich auch ist, eigentlich soll sie ja nur ein Hilfsmittel sein, damit die Anwendung glänzen kann. Die Anwendung habe ich nicht in Packages untergliedert, sondern "nur" in einzelne Module: averager.py, bargraph.py, battery.py, brightness.py, curve.py, display.py, hwdef.py, power.py und __init__.py. Diese lassen sich in die Themengebiete Display-Ansteuerung, Darstellung und Logik unterteilen.

Die Display-Ansteuerung (display.py) erfolgt über neopixel.NeoPixel, wobei ich nicht direkt auf die Pixel schreibe, sondern einen internen Puffer mit den "echten" RGB-Werten unterhalte, die in _flush mit dem aktuellen Helligkeits-Faktor verrechnet werden. Dieser wird aus der vom Fotowiderstand gemessenen Umgebungshelligkeit anhand einer Kennlinie (curve.py) mittels linearer Interpolation bestimmt. Der Grund dafür liegt in der Helligkeitswahrnehmung, die einem logarithmischen Zusammenhang folgt (Stichwort Weber-Fechner-Gesetz); das einfache Multiplizieren mit einem direkt aus dem Helligkeitswert berechneten Faktor führt nicht zu einem brauchbaren Ergebnis. Der Aufruf von _flush erfolgt zyklisch mit der konfigurierten Aktualisierungsrate (Default: 5 Sekunden), damit das Display nicht zu hektisch zappelt, wenn viele Aktualisierungen in kurzer Zeit stattfinden.

Das Display kennt nur Pixel, und zwar auf Grund der Zusammenschaltung der beiden Module nur eine Kette aus 76 Pixeln, die sich "irgendwie" auf den Ring und auf die Matrix aufteilen. Dieses Wissen, also welcher logische Index zu welchem physischen Pixel gehört, steckt in hwdef.py in Form der Liste CIRCLE_PIXELS sowie der Liste aus Listen MATRIX_PIXELS. Durch diese Indirektion ist eine beliebige Zuordnung möglich, was insbesondere bei der Matrix sehr hilfreich ist, um die Zählweise der Spalten ("alt" und "neu") und Zeilen ("viel" und "wenig") an den Anwendungsfall anzupassen, um dort die Logik zu vereinfachen.

Die Darstellung steckt in bargraph.py, wobei die Klasse BarGraph in ihrem Konstruktor eine Pixel-Liste erhält (praktischerweise genau in dem Format, das in hwdef.py steckt...), sowie eine Color Strategy, eine Callback-Funktion die aus einem Füllstandwert (z.B. 0.55) einen Farbwert (z.B. Color.YELLOW) ableitet. Diese BarGraph-Klasse wird sowohl für den Ring als auch für die Matrix-Spalten genutzt (die Matrix ist dann eine Liste aus BarGraph-Instanzen). Ein BarGraph selbst hat jeweils ein Minimum und ein Maximum sowie einen aktuellen Wert. Immer wenn dieser Wert (oder der Wertebereich) aktualisiert wird, wird anhand von Minimum, Maximum sowie der Anzahl der zur Verfügung stehenden Pixel ein Anzeigewert errechnet (z.B. 3.55 von 12), der dann mit Hilfe der Color Strategy in eine Pixel-Folge wie grün, grün, grün, gelb umgesetzt wird.

Die eigentliche Applikations-Logik steckt in battery.py, power.py und averager.py. Letzteres umfasst die beiden Hilfsklassen SimpleAverager (addiert Werte auf und liefert das arithmethische Mittel) und BucketAverager (bildet einen gleitenden Mittelwert, wobei die Werte nach und nach verschiedene "Eimer" durchlaufen, bis sie am Ende raus fallen). Der BatteryMonitor registiert sich auf die MQTT-Botschaft für den aktuellen Batteriefüllstand und bildet dessen nutzbaren Bereich auf den LED-Ring ab (wobei streng genommen die Hauptarbeit von BarGraph erledigt wird und BatteryMonitor die Werte für soc_min, soc_max und den jeweils aktuellen Füllstandwert nur weitergibt). Im PowerMonitor steckt ungleich mehr Intelligenz: neben des MQTT-Handlings besitzt er einen SimpleAverager um die sekundenweise auftretenden Einzelwerte in größere Einheiten (je nach Konfiguration z.B. 30 Sekunden oder 5 Minuten) zu aggregieren, die dann wiederum in einen BucketAverager eingespeist werden, der einen Bucket für jede Matrix-Spalte beinhaltet. Über die Parameter seconds_per_unit, units_per_column, live_column_enabled sowie display_threshold lässt sich das Verhalten individuell anpassen, letzteres beispielsweise unterdrückt die ständig auftretende Über- und Unterkorrektur der Batteriesteuerung die dafür sorgt, dass immer ein paar Watt eingespeist oder aus dem Netz bezogen werden, auch wenn der Wert eigentlich bei 0 liegen sollte.

Deploy- und Release-Prozess

Ich hatte mir bei diesem Projekt zur Angewohnheit gemacht, nach jeder Bastel-Session eine lauffähige Version der Software auf mein jeweils aktuelles Testgerät zu spielen und mindestens bis zur nächsten Session laufen zu lassen. Mit abnehmender Äderungsfrequenz entstanden immer ausgedehntere Langzeittests, aber mitunter auch die Unsicherheit, welche Version genau jetzt getestet wird. Im Prinzip müsste es dem letzten Stand im Git-Repository entsprechen, aber manchmal liegen doch ein paar Commits dazwischen, weil sich (vermeintlich) nur Details geändert haben und ich dann einen Langzeittest nicht unterbrechen wollte (z.B. um die Drift der Systemuhr genauer einschätzen zu können). Da nun mit der HTTP-API ein schöner Weg existiert, Informationen abfragen zu können, ohne das System anhalten und inspizieren zu müssen, lag es nahe auch einen Endpunkt /version anzulegen. Der Endpunkt an sich war schnell angelegt, aber um sicherzustellen, dass er immer die passende Information ausliefert, waren noch ein paar weitere Schritte notwendig.

Zum einen muss immer die richtige Information erhoben werden, idealerweise direkt aus Git. Dazu habe ich ein Skript geschrieben, das mit GitPython das letzte Release-Tag aufsucht (z.B. v2.0) und daraus die Major- und Minor-Version ermittelt (hier also 2 und 0). Weiterhin wird betrachtet, wie viele Commits zwischen dem Release-Tag und dem aktuellen Commit liegen; diese Zahl wird als dritte Stelle der Versionsnummer benutzt. Eine Version 2.0.24 gibt also an, dass 24 Commits seit dem Release 2.0 vergangen sind. Weitere Informationen umfassen den Commit-Hash, das Datum, den aktiven Branch sowie ob die Arbeitskopie "dirty" war (also Änderungen vorlagen, die nicht in besagtem Commit enthalten sind).

Die nächste Hürde besteht darin, dass tatsächlich aufgespielte Software und angegebene Software-Version garantiert konsistent sein müssen. Das habe ich dadurch erreicht, dass der Download auf das Target durch ein Skript (pico-deploy) gesteuert wird, das diese Versions-Information aktualisiert. Jetzt muss man sich nur noch daran halten, nicht hinterrücks am Target rumzufummeln, und schon passt's. Um dies zu begünstigen habe ich dem Skript noch ein paar mehr Funktionen spendiert, wie z.B. das alte Syslog zu entfernen, die notwendigen Packages zu installieren, etc. sodass es am Ende zu einem "mach, dass alles gut ist"-Button geworden ist, den man gerne nutzt. (Ein Button ist es tatsächlich noch nicht, aber es lässt sich bestimmt auch in die IDE integrieren.)

Nachdem ich nun eh schon dabei war, alles zu automatisieren, habe ich auch noch ein Skript make-release gebaut, das ein Release-Paket erzeugt, indem es das aktuelle Git-Repository in eine Arbeitskopie klont, auf ein bestimmtes Commit bringt, die Versions-Information aktualisiert, dann das .git-Verzeichnis wegwirft und alles in ein korrekt benanntes Tarball-Archiv verpackt. So erstmals geschehen für Version 2.0.0, die natürlich noch ein paar Macken enthält, die erst mit Version 2.1 gefixt sein werden (siehe Known Issues). Je dichter ich an das Ziel "Version 2.0" kam, desto deutlicher wurde mir auch, wieso das scheinbar immer so ist: man muss einfach irgendwo einen Schnitt machen und zu einem Feature oder komplexeren Bugfix sagen können "Du kommst erst in der nächsten Version"; andernfalls wird man nie veröffentlichen und hat ein (hoffentlich) super-tolles, fast-fertiges Produkt rumliegen, von dem nie jemand erfährt, weil es bei 99% stagniert (dem geschuldet, dass sich das letzte Prozent während seiner Abarbeitung immer weiter ausdehnt).


Zurück zur Hauptseite