Von Beginn an standen die beiden Optionen Raspberry Pi Zero / Raspberry Pi Pico im Raum. Der Pi Zero war für mich eher bekanntes Terrain, während der Pico weitgehend Neuland bedeutete. Nachdem die InfluxDB bereits da war lag es nahe, eine Anzeigebox zu bauen, die ihre Daten von der Datenbank bezieht und "nur" anders darstellt. Dementsprechend habe ich mich als erstes mit den Möglichkeiten beschäftigt, die InfluxDB via Python abzufragen, und wollte mich an deren Komplexität orientieren. Schnell fand ich heraus, dass es das offizielle InfluxDB-Paket nur für CPython (also das nicht-Micro-Python) gibt und man die HTTP-Abfragen für die Datenbank auf dem Pico selbst nachbauen müsste. Das war ein wesentlicher Punkt, der mich zum Zero greifen ließ.
Vom Hardware-Aufbau her wollte ich den Zero zunächst nicht verbasteln, sondern habe einen steckbrett-orientierten Ansatz gewählt. Das führte mich auch zu der ersten Version des Gehäuses, das ein bisschen an einen frühen Heimcomputer erinnert: eine CPU-Steckkarte bestehend aus dem Pi Zero in einem Montagerahmen, eine Steckkarte auf Lochraster zur Display-Ansteuerung und für weitere Glue-Logic, sowie ein freier Steckplatz für künftige Erweiterungen. Spoiler: die künftigen Erweiterungen kamen nie.
Ein weiterer Plan bestand darin, endlich mal ein sauberes Pi-System zu haben, in dem die applikationsspezifische Software nur als Paket installiert wird, anstatt das gesamte System zu infiltrieren. Realisieren wollte ich das durch Containerisierung: der Pi Zero sollte nur die jeweils frischeste Version der Software als Container-Image runterladen, aufstarten und einsatzbereit sein. Der Zusammenbau des Containers sollte anderswo erfolgen, d.h. endlich mal keine Entwicklungstools auf dem Zielsystem...
Als erstes Problem ergab sich die Tatsache, dass man (derzeit; mit meinem Wissensstand) ein weiteres Raspi-System benötigt, um die Container-Images zu bauen. Die Versuche, das per Crossbuild vom x86-PC aus hinzubekommen, verliefen relativ kläglich. Ein zweiter Dämpfer kam in Form von Adafruit-Blinka, einer Abhängigkeit von Adafruit CircuitPython NeoPixel zur Ansteuerung der NeoPixel-LEDs. Blinka hat sich mit Händen und Füßen dagegen gewehrt, in einem Docker-Container verwendet zu werden (vermutlich dieses Problem). Als Workaround habe ich mich dazu entschieden, die LED-Ansteuerung in ein eigenes Programm zu stecken, das außerhalb von Docker läuft und per HTTP-API erreichbar ist. Das habe ich via FastAPI realisiert, was ich mir auch schon seit einer ganze Weile ansehen wollte. Als positive Nebenwirkung entstand damit die Möglichkeit, den aktuellen Display-Inhalt auch als Webseite darstellen zu können, was einerseits für Testzwecke ganz praktisch war, andererseits aber auch einen neuen Anwendungsfall ermöglichte: das Ablesen des Displays von anderswo (mit dem Smartphone, das die Box überflüssig machen sollte... ich sehe die Ironie ebenfalls).
Ein erklärtes Feature der Anzeigebox war die Live-Anzeige: die rechteste Spalte der Matrix soll auf Wunsch die jeweils gerade aktuelle Einspeiseleistung anzeigen können, anstatt eines über Minuten gemittelten Durchschnitts. Nun kam es mir aber etwas falsch vor, die InfluxDB alle 5 Sekunden zu fragen "was ist letzter Wert?", zumal diese bei mir ja auch nur auf einem Raspberry Pi 3 läuft und gerne mal auf 300 MB Speicherverbrauch anschwillt, wenn sie gepiesackt wird. Vor allem wirkt es unsinnig, wenn man sich das Gesamt-Setup bei mir anschaut und weiß, dass der letzte Wert der Einspeiseleistung ohnehin in Sekundenauflösung auf dem MQTT-Broker vorliegt.
Als Reaktion darauf habe ich eine Zwischenschicht in die Datenbeschaffung eingezogen, die mehere mögliche Datenquellen kennt: den MQTT-Broker und die InfluxDB. Wird nach einem Wert gefragt, dann klappert diese Zwischenschicht alle ihre Quellen ab, bis sie fündig wird. Die Reihenfolge ist so gewählt, dass zuerst der MQTT-Broker und dann die InfluxDB befragt wird. (Um genau zu sein registriert sich der MQTT-Connector für die Topics und speichert sich den jeweils letzten Wert, der eingetrudelt ist.) Kurzlebige Werte wie Einspeiseleistung (wird jede Sekunde gesendet) und Batteriestand (wird alle 60 Sekunden gesendet) liegen in der Regel via MQTT vor; der Blick in die Vergangenheit zur Befüllung der Matrix wird immer von der InfluxDB beantwortet, aber auch nur ein mal pro Minute aktualisiert, sodass sich die dadurch entstehende Last in Grenzen hält.
Im weiteren Verlauf habe ich mir immer wieder die Frage gestellt, ob sich der Aufwand lohnt, zwei unterschiedliche Datenquellen bzw. Abfragewege zu unterhalten. Die Datenbank bietet mit ihrem quasi unendlich tief greifendem Gedächtnis einen großen Vorteil: wird das Gerät frisch eingeschaltet, kann es direkt alles anzeigen. Würde es hingegen nur via MQTT arbeiten, dann wüsste es nur so viel von der Vergangenheit, wie es selbst miterlebt hat, sprich: die Einspeisehistorie würde sich erst stückweise aufbauen. Je länger ich darüber nachgedacht habe, desto unschädlicher erschien mir diese Komforteinbuße im Einschalt-Szenario, weshalb ich auch für die Version mit dem Pi Pico nur noch MQTT als Quelle nutze. Für potenzielle Nachbauer ist das natürlich auch viel angenehmer, weil so ein MQTT-Broker leichter aufzusetzen ist als eine InfluxDB, oder vielleicht sogar schon da ist. Unsere Wallbox z.B. hat selbst einen offenen MQTT-Broker, der die vom Home Manager erfragten Werte der Einspeiseleistung ebenfalls verbreitet.
Keine neue Erkenntnis, aber doch immer wieder verblüffend: so ein Linux-System ist ein recht komplexes Ding. Klar ist mit dem Raspberry Pi Imager schnell ein Image auf eine SD-Karte gezogen und diese in den Pi eingesteckt, aber danach kommen noch ein paar Konfigurationsschritte, die insbesondere schnell nerven, wenn man sie ein paar mal ausführen muss (z.B. um eine Schritt-für-Schritt-Anleitung zu erstellen und diese zu testen). Ich habe diesmal zu Ansible gegriffen, um den Vorgang reproduzierbar zu gestalten, aber auch das ist eine komplexe Sache. (Oh Wunder, Komplexität geht nicht weg.)
Aber auch im Betrieb ist so ein Linux-System nicht "kostenlos". Updates wollen eingespielt werden (merkt man dann irgendwann nach 200 Tagen Betriebszeit) und, gerade in Bezug auf eingebettete Systeme und Container: so ein Linux-System ist enorm geschwätzig in Form von Logdateien. Einerseits will man sie haben, andererseits schrubben sie einem die SD-Karte tot bzw. lassen diese irgendwann volllaufen.
Die Idee, die Anwendungssoftware als Container-Image bereitzustellen, und das System ansonsten unbehelligt zu
lassen, war schön und ist eigentlich auch eine Gute. Aber man merkt schon, dass sich hier zwei
unterschieliche Konzepte beißen: Docker möchte einen so gut es geht von der konkreten Hardware fern
halten, aber Anwendungssoftware für eingebettete Systeme möchte (bzw. muss) an konkreter Hardware
herumspielen. Wenn sich Adafruit-Blinka nicht so quer gestellt hätte, dann hätte es wahrscheinlich
sogar funktioniert; ich vermute ich war nur wenige Bind-Mounts von /dev
oder /proc
vom
Ziel entfernt und inzwischen scheint es im genannten Bug-Ticket auch einen vielversprechenden
Lösungsvorschlag zu geben. Ich habe diesen Punkt noch nicht völlig aufgegeben, auch hat mein
Workaround mit dem außerhalb von Docker lebendem Hardware-Service gut funktioniert und sollte in der Form
immer möglich sein; aber zu einem Preis.
Ich hatte vor ein paar Wochen ein kritisches Video zum Thema Microservices gesehen, das meine Beobachtung noch einmal bestätigt: APIs können hübsch und einladend aussehen, Entkopplung und Abstraktion machen Architekturen flexibel und "sauber", aber bilden doch einen (wenn auch versteckten) Posten auf der Gesamtrechnung. In meinem konkreten Fall hatte ich den Eindruck, dass mein Software-System aus zu viel Schnittstelle bei wenig Gesamtfunktion besteht. Außerdem war das spürbar träge: ein harmlos aussehender Funktionsaufruf tritt einen HTTP-Request los und verbummelt gerne mal 15 Millisekunden, in einer Schleife über z.B. 8 Display-Spalten dann auch locker 150 ms (die Zahl der Spalten kann man ja auch per API abfragen, um hübsch generisch zu bleiben...).
board.D10
können "sudo-less" betrieben werden
Ich habe die Dokumentation
nicht aufmerksam gelesen und bin auf die vielen Beispiele reingefallen, die board.D18
als
Default-Pin für den Anschluss von NeoPixel-Modulen an Raspberry Pis verwenden. Das funktioniert, aber nur,
wenn das Programm mit Root-Rechten läuft. Für einen Betrieb als nicht-privilegierter Benutzer muss
board.D10
verwendet werden, weil dieser als Teil der SPI-Hardware wohl auch über andere Wege
aus dem Userspace heraus nutzbar ist (passende Änderungen an /boot/config.txt
vorausgesetzt).
Ich vermute, dass board.D18
beliebter ist, weil so die SPI-Schnittstelle für andere Zwecke
frei bleibt.
Durch die Verwendung des Raspberry Pi Zeros in Steckbrett-Manier mit Pin-Headern lag die Idee nahe, auch die Spannungsversorgung über die allgegenwärtigen Steckboard-Verbindungsleitungen zu realisieren. An sich nicht dumm, der Steckverbinder ist wohl auch bis 3 Ampere tauglich, aber die Kabel in den typischen Verbindungsleitungen sind zu dünn. Ich hatte schon mal gehört, dass die Kabel ziemlich dünn sein sollen, aber nachdem ich eins zerschnitten hatte, um ein Ende fest einzulöten, hat sich "ziemlich" so ziemlich bewahrheitet. Ich kam nach dem Verzinnen auf 0,25 mm Durchmeser, das sind 0,05 mm2 Querschnitt und entspricht AWG 30. In den Tabellen, die ich so gefunden habe, wird dafür schon gar keine Strombelastbarkeit mehr angegeben, nach meiner neuen Lieblings-Faustformel (6 A/mm2 bei Litze, 10 A/mm2 bei Einzelader) kommt man auf 300 Milliampere. Bei voller Helligkeit auf allen drei Farbkanälen reicht das gerade mal für 5 NeoPixel-LEDs, und der Pi selbst braucht ja auch noch etwas.
Ich habe übrigens auch einen Verdacht, warum die Kabel trotz des dünnen Innenlebens so eine vergleichsweise dicke Ummantelung haben (neben des Versuchs wertiger zu wirken): meine eigenen Versuche, "Reichelt-Litze" (nenne ich immer so, weil ich sie dort kaufe und die Artikelnummer einfach nur LITZE lautet) mit 0,14 mm2 selbst zu crimpen, lassen vermuten, dass ein gewisser Außendurchmesser notwendig ist, damit sich die Zugentlastung richtig um das Kabel legt; bei zu dünnen Kabeln gibt es nur undefinierten Matsch.
Nach diversen Experimenten mit der Frontplattendicke meines Gehäuses und verschiedenen Einsatzorten im Wohnzimmer bin ich zu dem Ergebnis gekommen, dass ich die LEDs nie mit mehr als 10% ihrer Helligkeit betreiben muss, Tendenz eher hin zu 1% in den Abendstunden. Das ist gut für den Aspekt der Stromaufnahme: diese ist linear zur Helligkeit (nicht zum Helligkeitsempfinden!) und beträgt somit für eine Vollansteuerung aller 76 LEDs bei nur einem Farbkanal (der häufigste Anwendungsfall in meinem Szenario) rund 150 mA bei 10% und 15 mA bei 1% Helligkeit. Womit wir wieder gut in Schlagdistanz der Steckboard-Verbindungsleitungen sind. :-)
Der NeoPixel-Treiber von CircuitPython erlaubt die Dimmung über die Eigenschaft
NeoPixel.brightness
, die Werte zwischen 0 und 1.0 annehmen kann. Intern wird dabei ein Faktor
gesetzt, mit dem die RGB-Werte der einzelnen Pixel multipliziert werden. Aus (255, 255, 0) werden so z.B. bei
einer Helligkeit von 0.25 die RGB-Anteile (63, 63, 0). Wird der Vollausschlag jetzt aber von 256
Helligkeitsstufen auf nur 64 Helligkeitsstufen reduziert, dann verkleinert sich auch der Farbraum von
2563 auf 643. So werden aus 16,8 Millionen Farben bei 100% nur noch ca. 262.000 Farben bei
25% und ca. 15.000 bei 10%. Klingt noch nach viel, aber spätestens 27 Farben bei 1% machen das Problem
deutlich: ein behutsam ausgewählter Mischfarbton sieht bei geringer Helligkeit möglicherweise ganz
anders aus.
Ich bin dem Problem übrigens dadurch begegnet, dass ich die Frontplatte etwas dicker gemacht habe, sodass das Licht stärker gedämpft wird.
Die letzte Erkenntnis kommt für die meisten sicher auch nicht überraschend: Plastiknasen an Gehäusen brechen leicht ab. Ich wollte diesen Mechanismus nutzen, um die Display-Module in die Frontplatte einzuclipsen, damit sie nicht mit Heißkleber o.ä. fixiert werden müssen. Wer die große Version der Fotos angesehen hat wird wahrscheinlich den Tesafilm bemerkt haben; ungefähr die Hälfte der Nasen ist abgebrochen. Das hat zwei Gründe: zum einen ist PLA furchtbar spröde, zum anderen erfordern die Nasen so, wie sie platziert sind, eine Flexiblität in der Achse senkrecht zur Druckebene (Z-Achse). Durch das schichtweise Auftragen beim FDM-Verfahren ist die Z-Achse aber besonders bruchgefährdet (weil die Schichten hier nur aufeinander kleben, aber nicht miteinander verschmolzen sind). Beim Gehäuse für Version 2 mit dem Pi Pico habe ich einen anderen Mechanismus entwickelt der zumindest dieses Problem nicht mehr aufweist.