Projekt Blinkenlight (Linux-Magazin, Februar 2024)

Externe Displays, die auch ohne richtigen Bildschirm laufend Daten anzeigen, auch wenn der Computer gerade ein Nickerchen macht, geben dem Arbeitszimmer schon einen besonderen Kick. Sie lassen sich nicht nur zum Anzeigen der Zeit oder des Wetters einspannen, sondern auch für ungewöhnliche an den privaten Bedarf angepasste Aufgaben. Das preisgünstige Ulanzi TC001 war schnell bestellt (unter 60 Euro bei AliExpress), und schlug binnen einer Woche an der Haustür auf, nach dem weiten Weg aus direkt China. Meine ursprüngliche Idee war es, damit eine "Wealth Clock" zu bauen, die den aktuellen Goldpegel in allen meinen Geldspeichern anzeigt, und ich so jederzeit weiß, wie reich ich gerade bin.

Flash zum Tausendsassa

Von der LED-Anzeige geht ein Retro-Feel aus. Klar gibt es heute hochauflösendere Displays, aber zum Anzeigen kurzer Strings ist das LED-Display gut genug und verstrahlt eine heimelige Tetris-Atmosphäre. Die installierte Firmware kann zwar kaum etwas, aber das Projekt "Awtrix" ([3]) bietet eine offene Software samt browserbasiertem Blitzflasher an, mit der das Teil ruckzuck zum Tausendsassa avanciert. Abbildung 2 zeigt, wie die neue Firmware bootet.

Abbildung 1: Der Ulanzi TC001 kommt über AliExpress direkt aus China.

Das Gerät protzt nicht gerade mit RAM und als Prozessor dient nur ein ESP32. Der kann zwar mit Wifi und Bluetooth umgehen, ist aber in der CPU-Leistung nicht mit einer modernen CPU zu vergleichen. Deswegen können anspruchsvollere Applikationen nicht direkt in der Firmware auf dem Ulanzi laufen. Statt dessen werden sie auf einen externen Rechner mit mehr Muckies ausgelagert, der Awtrix in periodischen Abständen per API-Kommando anweist, was gerade auf dem Display anzuzeigen ist. In ihrer Hauptschleife rotiert die Firmware im Betrieb durch alle konfigurierten "Apps", von denen nach dem Re-Flash Uhrzeit/Datum, Temperatur Luftfeuchtigkeit der internen Fühler und aktuelle Batteriestärke definiert sind. Aber darum soll es hier nicht gehen, vielmehr gilt es, diese Standard-Apps der Reihe nach auszuschalten, um selbstdefinierte Apps hochzuladen.

Abbildung 2: Nach dem Flashen der Awtrix-Firmware bootet das Ulanzi

Ewiger Kreislauf

Dazu hält man die mittlere Taste auf der Oberseite (die mit dem Kreis) etwa zwei Sekunden gedrückt, was Awtrix in die Admin-Konsole wirft. Aus zehn verschiedenen Untermenüs heißt eines "Apps", und ein weiterer (kurzer) Druck auf die Kreistaste zeigt den Status der ersten App an, etwa die verbleibende Kapazität der eingebauten Batterie. Das Display lässt sich ohne Netzkabel etwa 5 Stunden mit der eingebauten Batterie betreiben, aber das dürfte wohl kaum jemand interessieren, da es für den Dauerbetrieb eh eine Steckdose braucht. Weitere Apps bringen nun die Pfeiltasten nach links oder rechts zutage, wie die Temperaturanzeige, den Feuchtigkeitsfühler oder Zeit und Datum und ein kurzer Druck auf die Kreistaste schaltet die jeweils angezeigte App aus beziehungsweise wieder ein, was die Firmware mit "off" oder "on" quittiert. Ein langer Druck auf die Kreistaste lässt die Konsole wieder auf die nächste Ebene hochspringen und zuletzt wieder den ewigen App-Kreislauf starten. Wer alle mitgelieferten Apps deaktiviert hat, blickt nun auf ein dunkles Display.

Abbildung 3: Awtrix-Admin-Interface im Webbrowser

Sinnloses Passwort

Die Web-UI und die API der Awtrix-Firmware lassen sich in der Admin-Konsole (Abbildung 3) mit Username und Passwort schützen. Allerdings erwartet der Mini-Webserver anschließend bei jedem Request die Anmeldedaten per Basic Auth über ungeschütztes HTTP. Das ist leider nicht zeitgemäß, denn jeder, der auf dem WLAN mithört, bekommt so auch gleich das Passwort mitgeliefert.

Um nun neue Custom-Apps in die Display-Schleife der Firmware einzugliedern, nutzen Clients entweder die besonders bei Home-Automation-Systemen beliebte MQTT-Schnittstelle, oder schicken Befehle über die Web-API. Die ist auf [3] dürftig dokumentiert, letztlich genügt ein POST-Request and die IP des Ulanzi auf dem WLAN. Nach dem Flashen mit der neuen Firmware startet das Gerät nämlich im AP-Modus und wer auf einem Laptop oder Smartphone das neue Wifi "awtrix_xxx" auswählt, kann dem Ulanzi im aufpoppenden Browser die Wifi-Zugangsdaten für das Hausnetz übermitteln. Nach einem Reboot wählt sich das Ulanzi dann dort ein und schnappt sich eine IP, die es beim Hochfahren auf dem Display anzeigt. API-Calls zum Setzen einer neuen App gehen unter /api/custom an diese IP, und benötigen außerdem einen Namen für die App, der frei wählbar ist, sowie einen JSON-Blob mit dem gewünschten Display-Inhalt.

Geburtstags-Countdown

Abbildung 4: Die Ulanzi-Anzeige zählt die Tage, Stunden und Minuten bis zum Geburtstag

Um zum Beispiel eine neue App in das Display einzuschleusen, die die Tage, Stunden und Minuten bis zu einem vorgegebenen Termin (zum Beispiel dem Geburtstag) herunterzählt, berechnet Listing 1 in der Funktion DHMUntil() die Zeitspanne zwischen der aktuellen Uhrzeit und dem vorgegebenen Termin, und teilt die ermittelten Stunden durch 24, um daraus die Tage zu errechnen. Eine mod24-Operation gewinnt daraus die Reststunden, und ein mod 60 auf die Minuten die Restminuten. Zurück kommt ein String im Format TT:HH:MM, den der API-Aufruf in Listing 2 auf Display bringt.

Es liegt also am Steuerungsrechner, wie oft der Countdown aufgefrischt wird. Springt zum Beispiel alle 10 Minuten ein Cronjob an, kann der Zähler schlimmstenfalls 10 Minuten hinterherhängen.

Listing 1: countdown.go

    01 package main
    02 import (
    03   "fmt"
    04   "time"
    05 )
    06 func DHMUntil(until time.Time) string {
    07   dur := time.Until(until)
    08   days := int(dur.Hours() / 24)
    09   hours := int(dur.Hours()) % 24
    10   mins := int(dur.Minutes()) % 60
    11   return fmt.Sprintf("%02d:%02d:%02d", days, hours, mins)
    12 }

Die Kommunikation mit der Webserver-API der Awtrix-Firmware auf dem Display erledigt Listing 2 mittels der Struktur vom Typ apiPayload ab Zeile 9. Diese wandelt der Packer json.Marshal() in Zeile 17 entsprechend der Hinweise in der Struktur ins Json-Format um. So steht das der Inhalt des Struktur-Feldes Text mit dem anzuzeigenden Textstring in JSON standardgemäß unter text (also kleingeschrieben), da JSON-Felder traditionell mit Klein-, Go-Strukturfelder aber mit Großbuchstaben beginnen.

Listing 2: api.go

    01 package main
    02 import (
    03   "bytes"
    04   "encoding/json"
    05   "fmt"
    06   "net/http"
    07 )
    08 const baseURL = "http://192.168.87.22/api/custom"
    09 type apiPayload struct {
    10   Text     string `json:"text"`
    11   Rainbow  bool   `json:"rainbow"`
    12   Duration int    `json:"duration"`
    13   Icon     int    `json:"icon"`
    14 }
    15 func postToAPI(name string, p apiPayload) error {
    16   url := baseURL + "?name=" + name
    17   jsonBytes, err := json.Marshal(p)
    18   if err != nil {
    19     return err
    20   }
    21   resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonBytes))
    22   if err != nil {
    23     return err
    24   }
    25   defer resp.Body.Close()
    26   if resp.StatusCode != http.StatusOK {
    27     return fmt.Errorf("%v", resp.StatusCode)
    28   }
    29   return nil
    30 }

Die Funktion postToAPI() ab Zeile 15 erwartet vom Aufrufer dann zwei Parameter, den Namen der Applikation und eine Struktur vom Typ apiPayload mit den Werten für den anzuzeigenden Text (in Text), das Flag Rainbow (gesetzt auf einen wahren Wert für bunte Darstellung), die Dauer der Anzeige in Sekunden in Duration und optional ein Icon, damit der Betrachter den angezeigten Wert optisch einer App zuordnen kann.

Die Funktion Post() aus dem Go-Standardpaket net/http schickt den Json-Blob dann unter Angabe des MIME-Types application/json an den Webserver. Letzteres ist obligatorisch, da der Webserver den Aufruf sonst nicht richtig zuordnet. Nach einer Prüfung der HTTP-Anwort auf Fehler kehrt die Funktion schließlich zurück.

Like und Subscribe!

Abbildung 5: Follower und Uploads auf dem Youtube-Channel

In einer weiteren App soll das Ulanzi die Anzahl der Subscriber auf meinen Youtube-Kanal anzeigen, sowie die Anzahl der bis dato hochgeladenen Videos (Abbildung 5). Listing 3 illustriert, wie der Kontrollrechner die gewünschten Zahlenwerte von Youtube abholt. Für den Zugriff auf die Daten verlangt Google einen gültigen API-Key (Zeile 11), den Entwickler wie im letzten Snapshot gezeigt ([5]) auf der Cloud-Console abholen können.

Listing 3: youtube.go

    01 package main
    02 import (
    03 	"context"
    04 	"google.golang.org/api/option"
    05 	"google.golang.org/api/youtube/v3"
    06 	"log"
    07 )
    08 const ChannelID = "UC4UlBOISsNy4HcQFWSrnV5Q"
    09 const ApiKey = "AIzaSyZmOrarSDWqrnAwIKkWGzj0vaVQtyvPokB"
    10 func youtubeStats() (uint64, uint64, uint64, error) {
    11 	ctx := context.Background()
    12 	service, err := youtube.NewService(ctx, option.WithAPIKey(ApiKey))
    13 	resp, err := service.Channels.List([]string{"statistics", "contentDetails"}).Id(ChannelID).Do()
    14 	if err != nil {
    15 		log.Fatalf("%v", err)
    16 	}
    17 	if len(resp.Items) == 0 {
    18 		log.Fatal("Channel not found")
    19 	}
    20 	stat := resp.Items[0].Statistics
    21 	plid := resp.Items[0].ContentDetails.RelatedPlaylists.Uploads
    22 	plResp, err := service.PlaylistItems.List([]string{"snippet"}).
    23 		PlaylistId(plid).
    24 		MaxResults(1).
    25 		Do()
    26 	if err != nil {
    27 		log.Fatalf("%v", err)
    28 	}
    29 	if len(plResp.Items) == 0 {
    30 		log.Fatalf("Nothing found")
    31 	}
    32 	videoID := plResp.Items[0].Snippet.ResourceId.VideoId
    33 	log.Printf("Video ID: %s\n", videoID)
    34 	videoResp, err := service.Videos.List([]string{"statistics"}).
    35 		Id(videoID).
    36 		Do()
    37 	if err != nil {
    38 		log.Fatalf("%v", err)
    39 	}
    40 	video := videoResp.Items[0]
    41 	viewCount := video.Statistics.ViewCount
    42 	return stat.SubscriberCount, stat.VideoCount, viewCount, nil
    43 }

Der in Listing 3 genutzte offizielle API-Client der Youtube-API macht es einfach, Statistiken zu einem Channel einzuholen, und erspart dem Entwickler auch noch das Herausfieseln der interessierenden Werte aus dem verschachtelten Json-Salat der Server-Antwort. Die Channel-ID zum Identifizieren des gewünschten Kanals ist in Zeile 8 hartkodiert, der API-Key in Zeile 9.

Listing 3 erzeugt wie schon im letzten Snapshot ein Service-Objekt mit NewService() in Zeile 12 und ruft dann die API-Funktion List() mit dem Parameter statistics auf, um die Statistikdaten des Channels zu erfahren. Zurück kommt eine Trefferliste mit einem Element, dessen Daten Zeile 20 herausholt und Zeile 21 mit Subscribercount und VideoCount die hier interessierenden Werte extrahiert.

Pixelige Icons

Gerade bei mehreren installierten Apps, durch die das Display laufend rotiert, stellen Icons schön heraus, zu welcher App der gerade angezeigte Text gehört. Auf einer Minimatrix von 8x8 Pixeln in einem Feld des Displays ist es aber gar nicht so einfach, eine aussagekräftige Grafik zu erzeugen. Interessanterweise nutzt das Ulanzi TC001 mit Awtrix deshalb einfach vordefinierte Icons von der Developer-Seite des teureren Konkurrenzprodukts LaMetric ([4]). Dort kann der User mit Stichworten nach passenden Icons suchen und sich deren Nummer merken.

Abbildung 6: Geldsack als Symbol für Geldspeicherinventur

Auf der Awtrix-Adminseite lassen sich dann unter dem Reiter "Icons" diese kleinen Pixelkunstwerke per Zahlenwert referenzieren. Auf Knopfdruck lädt Atrix dann das jeweilige Icon in die Firmware herunter, und zeigt es im ersten Feld einer App an, falls die an das Display übersandten Json-Daten einer App die entsprechende numerische Icon-ID im Feld icon referenzieren.

Abbildung 7: Auf der LaMetric-Developerseite stehen Icons zum Download ...

Abbildung 8: ... die Awtrix per numerischer ID herunterlädt und einbindet.

Nach dem API-Call des fertigen Programms wird das Display später wie in Abbildung 5 gezeigt einen Youtube-typischen roten Play-Button als Icon anzeigen. Außerdem teilt es mit, dass mein persönlicher Channel auf der Plattform mittlerweile 290 Subscriber hat, und insgesamt 85 Videos zu meinen Koch- und Autoreparaturkünsten hochgeladen wurden.

Dagobert: Million nie verkehrt

Was meine persönliche Wohlstandsuhr betrifft, kann ich leider keine Details veröffentlichen, deswegen zeigt Abbildung 9 lediglich einen symbolischen Geldspeicherstand. In Wahrheit läuft auf dem Kontrollrechner täglich ein Go-Programm, das alle Geldeinlagen und Anlagewerte bewertet, aufaddiert, und als Zahlenwert ans Ende einer Logdatei anhängt. Die Funktion mon() in Listing 4 muss also ledigleich ans Ende der Logdatei navigieren, den ersten dort stehenden Zahlenwert extrahieren und diesen an den Aufrufer zurückgeben.

Abbildung 9: Anzeige des Geldspeicherstandes des Autors

Da die Bytes einer Datei sequentiell auf der Festplatte stehen und das Konzept einer Zeile auf Unix nur so implementiert ist, dass an deren Ende jeweils ein Newline-Zeichen steht, ist das Auslesen der letzten Zeile einer Datei gar nicht so trivial. Die einfachste Methode ist, die Bytes der Datei zeilenweise jeweils bis zum nächsten Newline-Zeichen auszulesen, bis das Programm am Dateiende anlangt, wobei es sich den Inhalt der letzten bearbeiteten Zeile gemerkt hat. Das ist allerdings besonders bei längeren Dateien sehr ineffizient, da das Auslesen eigentlich unnützer Daten sich gewaltig in die Länge ziehen kann. Effizienter ist es, das Betriebssystem mit der Unix-Funktion fseek() anzuweisen, sich praktisch verzögerungsfrei bis zum Dateiende vorzuarbeiten, und von dort rückwärts nach dem Anfang der letzten Zeile zu suchen. Da die von Listing 4 ausgelesene Logdatei sehr kurz ist, nutzt es das erste, simplere Verfahren.

Listing 4: dago.go

    01 package main
    02 import (
    03   "bufio"
    04   "golang.org/x/text/language"
    05   "golang.org/x/text/message"
    06   "os"
    07   "os/user"
    08   "path"
    09   "regexp"
    10   "strconv"
    11 )
    12 func mon() string {
    13   usr, err := user.Current()
    14   if err != nil {
    15     panic(err)
    16   }
    17   logf := path.Join(usr.HomeDir, "data/monlog.txt")
    18   file, err := os.Open(logf)
    19   if err != nil {
    20     panic(err)
    21   }
    22   defer file.Close()
    23   scanner := bufio.NewScanner(file)
    24   var lastLine string
    25   for scanner.Scan() {
    26     lastLine = scanner.Text()
    27   }
    28   if err := scanner.Err(); err != nil {
    29     panic(err)
    30   }
    31   re := regexp.MustCompile(`\d+`)
    32   match := re.FindString(lastLine)
    33   n, err := strconv.ParseInt(match, 10, 64)
    34   if err != nil {
    35     panic(err)
    36   }
    37   n = n / 1000
    38   p := message.NewPrinter(language.English)
    39   return p.Sprintf("%d", n)
    40 }

Zur besseren Lesbarkeit langer Zahlen kommen im Englischen Kommas ("1,000") und im Deutschen Punkte ("1.000") zum Einsatz, um Zifferngruppen voneinander zu trennen. Dies erledigt in Listing 4 die Standard-Go-Library text/message, die die import-Anweisung in Zeile 5 hereinzieht und Zeile 38 für den englischen Sprachraum initialisiert. So liefert die Funktion mon() den bereits formatierten String zum Geldspeicherstand ans Hauptprogramm.

Auf geht's

Das Hauptprogramm in Listing 5 ruft schließlich die Hilfsfunktionen der definierten Apps der Reihe nach auf und schickt damit gefüllte Json-Daten an das Display.

Listing 5: ulanzi.go

    01 package main
    02 import (
    03 	"fmt"
    04 	"time"
    05 )
    06 func main() {
    07 	// Youtube
    08 	f, _, latestV, err := youtubeStats()
    09 	if err != nil {
    10 		panic(err)
    11 	}
    12 	p := apiPayload{Text: fmt.Sprintf("%d/%d", f, latestV), Icon: 974, Duration: 4, Rainbow: true}
    13 	err = postToAPI("youtube", p)
    14 	if err != nil {
    15 		panic(err)
    16 	}
    17 	// Countdown
    18 	loc, err := time.LoadLocation("America/Los_Angeles")
    19 	if err != nil {
    20 		panic(err)
    21 	}
    22 	timerVal := DHMUntil(time.Date(2024, time.January, 1, 0, 0, 0, 0, loc))
    23 	p = apiPayload{Text: timerVal, Duration: 4, Rainbow: true}
    24 	err = postToAPI("countdown", p)
    25 	if err != nil {
    26 		panic(err)
    27 	}
    28 	// Dago
    29 	p = apiPayload{Text: mon(), Icon: 23003, Duration: 4, Rainbow: true}
    30 	err = postToAPI("dago", p)
    31 	if err != nil {
    32 		panic(err)
    33 	}
    34 }

Der übliche Dreisprung aus Listing 6 baut die fünf Source-Dateien zu einem Binary ulanzi zusammen. Damit die Anzeige stets aktuell bleibt, sollte ein Cronjob das Binary auf dem Steuerrechner in regelmäßigen Abständen (zum Beispiel stündlich) aufrufen. Eine funktionierende Wifi-Verbindung zum Display ist erforderlich.

Listing 6: build.sh

    1 go mod init ulanzi
    2 go mod tidy
    3 go build ulanzi.go api.go countdown.go youtube.go dago.go

Startet Awtrix übrigens neu, zum Beispiel nach einem längeren Stromausfall, nach dem die Batterie aufgegeben hat, oder einem per Admin-Konsole eingeleiteten Neustart wegen einer Konfigurationsänderung, vergisst der Kasten alle Custom Apps und spielt nur die vorkonfigurierten Apps ab, so weit diese nicht vorab abgestellt wurden. Dies bleibt so, bis wieder ein API-Befehl vom steuernden Rechner kommt, der den neuesten Wert für eine Custom App setzt und der Kreislauf wieder startet und sich dann endlos fortsetzt.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2024/01/snapshot/

[2]

Ulanzi TC001 auf Aliexpress: https://www.aliexpress.us/item/3256804848125097.html

[3]

Custom-Firmware "awtrix" für den Ulanzi TC001: https://blueforcer.github.io/awtrix-light/#/

[4]

Icons von LaMetric passen für Ulanzi, https://developer.lametric.com/icons

[5]

"Kanalarbeiter", Michael Schilli, Linux-Magazin 2024-01

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc