Aus Linux-Magazin 02/2024

LED-Anzeige flashen und programmieren

© Linux-Magazin

Mike Schilli hat eine preiswerte LED-Anzeige geordert und macht sich daran, sie mit Custom-Firmware und selbstgebauten Skripts auszurüsten.

Externe Displays, die ohne richtigen Bildschirm laufend Daten anzeigen, selbst wenn der Computer gerade ein Nickerchen macht, geben dem Arbeitszimmer einen besonderen Kick. Sie lassen sich nicht nur zum Anzeigen der Zeit oder des Wetters einspannen, sondern erledigen ungewöhnliche, an den privaten Bedarf angepasste Aufgaben. Die preisgünstige Ulanzi TC001 [1] landete für rund 60 Euro binnen einer Woche an meiner Haustür, nach dem weiten Weg von China in die USA. Meine ursprüngliche Idee war es, damit eine “Wealth Clock” zu bauen, die den aktuellen Goldpegel in allen meinen Geldspeichern anzeigt, sodass ich jederzeit weiß, wie vermögend ich gerade bin.

Flash zum Tausendsassa

Von der LED-Anzeige geht ein Retro-Feeling aus. Klar gibt es heute höher auflösende Displays, aber zum Anzeigen kurzer Zeichenketten taugt das LED-Display allemal und strahlt eine heimelige Tetris-Atmosphäre aus. Die installierte Firmware kann zwar kaum etwas, aber das Projekt Awtrix [2] bietet eine offene Software samt Browser-basiertem Blitz-Flasher an, mit der das Teil ruckzuck zum Tausendsassa avanciert. Abbildung 1 zeigt, wie die neue Firmware bootet.

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

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

Das Gerät protzt nicht gerade mit RAM, und als Prozessor dient nur ein ESP32. Dieser Mikrocontroller kann zwar mit WLAN und Bluetooth umgehen, seine Leistung lässt sich aber nicht mit der einer modernen CPU vergleichen. Deswegen laufen anspruchsvollere Applikationen nicht direkt in der Firmware auf dem Ulanzi. Stattdessen lagern sie auf einem externen Rechner mit mehr Power, der Awtrix in periodischen Abständen per API-Kommando anweist, was es auf dem Display anzeigen soll. In ihrer Hauptschleife rotiert die Firmware im Betrieb durch alle konfigurierten “Apps”, von denen nach dem Re-Flash Uhrzeit/Datum, Temperatur, Luftfeuchte der internen Fühler und aktuelle Batteriestärke definiert sind. Aber darum soll es hier nicht gehen. Vielmehr gilt es, die Standard-Apps der Reihe nach auszuschalten, um selbstdefinierte Apps hochzuladen.

Ewiger Kreislauf

Dazu halten Sie am Ulanzi die mittlere Taste mit dem Kreis etwa zwei Sekunden lang gedrückt, was Awtrix in die Admin-Konsole wirft. Eines der dortigen Untermenüs heißt Apps.

Ein weiterer kurzer Druck auf die Kreistaste zeigt den Status der ersten App an, beispielsweise die verbleibende Kapazität der eingebauten Batterie. Das Display lässt sich ohne Netzkabel etwa fünf Stunden mit der eingebauten Batterie betreiben – aber das dürfte wohl kaum jemanden interessieren, da es für den Dauerbetrieb eine Steckdose braucht.

Ein Druck auf die Pfeiltasten nach links oder rechts fördert nun weitere Apps wie die Temperatur- oder Luftfeuchteanzeige beziehungsweise Zeit und Datum zutage. Ein kurzes Drücken der 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. Haben Sie alle mitgelieferten Apps deaktiviert, blicken Sie nun auf ein dunkles Display.

Sinnloses Passwort

Die Web-UI und die API der Awtrix-Firmware lassen sich in der Admin-Konsole (Abbildung 2) mit Benutzername 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 nicht zeitgemäß: Jeder, der auf dem WLAN mithört, bekommt so das Passwort mitgeliefert.

Abbildung 2: Das Awtrix-Admin-Interface im Webbrowser.

Abbildung 2: Das Awtrix-Admin-Interface im Webbrowser.

Um neue Custom-Apps in die Display-Schleife der Firmware einzugliedern, nutzen Clients entweder die besonders für Home-Automation-Systeme beliebte MQTT-Schnittstelle oder schicken Befehle über die Web-API. Letztere ist auf Github dürftig dokumentiert; letztlich genügt ein POST-Request an die IP des Ulanzi im WLAN. Nach dem Flashen mit der neuen Firmware startet das Gerät nämlich im AP-Modus. Wenn Sie auf einem Laptop oder Smartphone das neue WLAN »awtrix_XXX« auswählen, können Sie dem Ulanzi im aufpoppenden Browser die WLAN-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 (frei wählbaren) Namen für die App sowie einen JSON-Blob mit dem gewünschten Display-Inhalt.

Geburtstags-Countdown

Als Erstes wollen wir eine neue App in das Display einschleusen, die die Tage, Stunden und Minuten bis zu einem vorgegebenen Termin herunterzählt, zum Beispiel zu einem Geburtstag (Abbildung 3). Dazu berechnet Listing 1 in der Funktion »DHMUntil()« die Zeitspanne zwischen der aktuellen Uhrzeit und dem entsprechenden Termin. Dann teilt es die ermittelten Stunden durch 24, um daraus die Tage zu errechnen. Eine Mod-24-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 aufs Display bringt. Es liegt dabei am Steuerungsrechner, wie oft der Countdown aufgefrischt wird. Springt zum Beispiel nur alle 15 Minuten ein Cronjob an, hängt der Zähler schlimmstenfalls eine Viertelstunde hinterher.

Abbildung 3: Die Anzeige zählt Tage, Stunden und Minuten bis zum Geburtstag.

Abbildung 3: Die Anzeige zählt Tage, Stunden und Minuten bis zum Geburtstag.

Listing 1

countdown.go

package main
import (
  "fmt"
  "time"
)
func DHMUntil(until time.Time) string {
  dur := time.Until(until)
  days := int(dur.Hours() / 24)
  hours := int(dur.Hours()) % 24
  mins := int(dur.Minutes()) % 60
  return fmt.Sprintf("%02d:%02d:%02d", days, hours, mins)
}

Die Kommunikation mit der Webserver-API der Awtrix-Firmware auf dem Display erledigt Listing 2 mittels der Struktur vom Typ »apiPayload« ab Zeile 9. Die wandelt der Packer »json.Marshal()« in Zeile 17 entsprechend der Hinweise in der Struktur ins JSON-Format um. Dabei steht der Inhalt des Go-Strukturfelds »Text« mit der anzuzeigenden Zeichenkette in JSON standardgemäß unter »text« (also kleingeschrieben), da JSON-Felder traditionell mit Kleinbuchstaben beginnen, Go-Strukturfelder aber mit Majuskeln.

Listing 2

api.go

package main
import (
  "bytes"
  "encoding/json"
  "fmt"
  "net/http"
)
const baseURL = "http://192.168.87.22/api/custom"
type apiPayload struct {
  Text     string `json:"text"`
  Rainbow  bool   `json:"rainbow"`
  Duration int    `json:"duration"`
  Icon     int    `json:"icon"`
}
func postToAPI(name string, p apiPayload) error {
  url := baseURL + "?name=" + name
  jsonBytes, err := json.Marshal(p)
  if err != nil {
    return err
  }
  resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonBytes))
  if err != nil {
    return err
  }
  defer resp.Body.Close()
  if resp.StatusCode != http.StatusOK {
    return fmt.Errorf("%v", resp.StatusCode)
  }
  return nil
}

Die Funktion »postToAPI()« ab Zeile 15 erwartet vom Aufrufer zwei Parameter, den Namen der Applikation und eine Struktur vom Typ »apiPayload«. Letztere enthält den anzuzeigenden Text (in »Text«), das Flag »Rainbow« (wahrer Wert für bunte Darstellung) und die Dauer der Anzeige in Sekunden in »Duration«. Optional kommt ein Icon hinzu, 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 Server den Aufruf sonst nicht richtig zuordnet. Nach einer Prüfung der HTTP-Antwort auf Fehler kehrt die Funktion schließlich zurück.

Like und Subscribe

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 4). 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, den man wie im letzten Snapshot gezeigt [3] auf der Cloud-Console abholen kann.

Abbildung 4: Follower und Uploads auf dem Youtube-Channel.

Abbildung 4: Follower und Uploads auf dem Youtube-Channel.

Listing 3

youtube.go

package main
import (
  "context"
  "google.golang.org/api/option"
  "google.golang.org/api/youtube/v3"
  "log"
)
const ChannelID = "UC4UlBOISsNy4HcQFWSrnV5Q"
const ApiKey = "AIzaSyZmOrarSDWqrnAwIKkWGzj0vaVQtyvPokB"
func youtubeStats() (uint64, uint64, error) {
  ctx := context.Background()
  service, err := youtube.NewService(ctx, option.WithAPIKey(ApiKey))
  resp, err := service.Channels.List([]string{"statistics"}).Id(ChannelID).Do()
  if err != nil {
    log.Fatalf("%v", err)
  }
  if len(resp.Items) == 0 {
    log.Fatal("Channel not found")
  }
  stat := resp.Items[0].Statistics
  return stat.SubscriberCount, stat.VideoCount, nil
}

Der im Listing genutzte offizielle Client der Youtube-API macht es einfach, Statistiken zu einem Channel einzuholen. Obendrein erspart er dem Entwickler das Herausfieseln der interessanten 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.

Der Code erzeugt wie schon im vorangegangenen Snapshot mit »NewService()« ein Service-Objekt (Zeile 12). Dann ruft er 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 die Zeile 20 herausholt. Zeile 21 extrahiert mit »Subscribercount« und »VideoCount« die hier interessierenden Werte.

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. Es ist aber gar nicht so einfach, auf einer Mini-Matrix von 8 mal 8 Pixeln in einem Feld des Displays eine aussagekräftige Grafik zu erzeugen.

Interessanterweise nutzt das Ulanzi TC001 mit Awtrix deshalb einfach vordefinierte Icons (Abbildung 5) von der Developer-Seite des teureren Konkurrenzprodukts LaMetric [4]. Dort können Sie mit Stichworten nach passenden Icons suchen (Abbildung 6) und sich deren Nummer merken. Auf der Awtrix-Admin-Seite lassen sich dann im Reiter Icons die kleinen Pixelkunstwerke per Zahlenwert referenzieren (Abbildung 7). Auf Knopfdruck lädt Awtrix das jeweilige Icon in die Firmware herunter und zeigt es im ersten Feld einer App an, sobald die an das Display übersandten JSON-Daten einer App die entsprechende numerische Icon-ID im Feld »icon« referenzieren.

Abbildung 5: Geldsack als Symbol für Geldspeicherinventur.

Abbildung 5: Geldsack als Symbol für Geldspeicherinventur.

Abbildung 6: Auf der LaMetric-Developer-Seite stehen Icons zum Download bereit …

Abbildung 6: Auf der LaMetric-Developer-Seite stehen Icons zum Download bereit …

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

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

Nach dem API-Call des fertigen Programms zeigt das Display später wie in Abbildung 4 gezeigt einen Youtube-typischen roten Play-Button als Icon an. 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 keine Details veröffentlichen, deswegen zeigt Abbildung 8 lediglich einen symbolischen Geldspeicherstand. In Wahrheit läuft auf dem Kontrollrechner täglich ein Go-Programm, das alle Geldeinlagen und Anlagewerte bewertet, addiert und als Zahlenwert ans Ende einer Log-Datei anhängt. Die Funktion »mon()« in Listing 4 muss also lediglich ans Ende der Log-Datei navigieren, den ersten dort stehenden Zahlenwert extrahieren und ihn an den Aufrufer zurückgeben.

Abbildung 8: Symbolische Anzeige des Geldspeicherstands des Autors.

Abbildung 8: Symbolische Anzeige des Geldspeicherstands des Autors.

Da die Bytes einer Datei sequenziell auf der Festplatte stehen und eine Zeile unter Unix so implementiert ist, dass an deren Ende jeweils ein Newline-Zeichen steht, gestaltet sich das Auslesen der letzten Zeile einer Datei gar nicht so trivial. Die einfachste Methode: Das Programm liest die Bytes der Datei zeilenweise jeweils bis zum nächsten Newline-Zeichen aus, bis es 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. Für mehr Effizienz weist man das Betriebssystem mit der Unix-Funktion »fseek()« an, 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 bearbeitete Log-Datei jedoch sehr kurz ausfällt, nutzt es das erste, simplere Verfahren.

Listing 4

dago.go

package main
import (
  "bufio"
  "golang.org/x/text/language"
  "golang.org/x/text/message"
  "os"
  "os/user"
  "path"
  "regexp"
  "strconv"
)
func mon() string {
  usr, err := user.Current()
  if err != nil {
    panic(err)
  }
  logf := path.Join(usr.HomeDir, "data/monlog.txt")
  file, err := os.Open(logf)
  if err != nil {
    panic(err)
  }
  defer file.Close()
  scanner := bufio.NewScanner(file)
  var lastLine string
  for scanner.Scan() {
    lastLine = scanner.Text()
  }
  if err := scanner.Err(); err != nil {
    panic(err)
  }
  re := regexp.MustCompile(`\d+`)
  match := re.FindString(lastLine)
  n, err := strconv.ParseInt(match, 10, 64)
  if err != nil {
    panic(err)
  }
  n = n / 1000
  p := message.NewPrinter(language.English)
  return p.Sprintf("%d", n)
}

Zur besseren Lesbarkeit langer Zahlen trennen Angelsachsen Zifferngruppen mit Kommas (“10,000”) voneinander, hierzulande verwendet man stattdessen Punkte (“10.000”). Darum kümmert sich in Listing 4 die Standard-Go-Library »text/message«, die die »import«-Anweisung in Zeile 5 hereinzieht und in Zeile 38 für den englischen Sprachraum initialisiert. So liefert die Funktion »mon()« den bereits formatierten String zum Geldspeicherstand ans Hauptprogramm.

Startschuss

Das Hauptprogramm in Listing 5 ruft schließlich die Hilfsfunktionen der definierten Apps der Reihe nach auf und schickt entsprechende JSON-Daten an das Display. Der übliche Dreisprung aus Listing 6 baut die fünf Quelldateien zum Binary »ulanzi« zusammen. Damit die Anzeige stets aktuell bleibt, sollte ein Cronjob auf dem Steuerrechner das Binary in regelmäßigen Abständen (zum Beispiel stündlich) aufrufen. Das setzt eine funktionierende WLAN-Verbindung zum Display voraus.

Listing 5

ulanzi.go

package main
import (
  "fmt"
  "time"
)
func main() {
  // Youtube
  f, v, err := youtubeStats()
  if err != nil {
    panic(err)
  }
  p := apiPayload{Text: fmt.Sprintf("%d/%d", f, v), Icon: 974, Duration: 4, Rainbow: true}
  err = postToAPI("youtube", p)
  if err != nil {
    panic(err)
  }
  // Countdown
  loc, err := time.LoadLocation("America/Los_Angeles")
  if err != nil {
    panic(err)
  }
  timerVal := DHMUntil(time.Date(2024, time.August, 1, 0, 0, 0, 0, loc))
  p = apiPayload{Text: timerVal, Duration: 4, Rainbow: true}
  err = postToAPI("countdown", p)
  if err != nil {
    panic(err)
  }
  // Dago
  p = apiPayload{Text: mon(), Icon: 23003, Duration: 4, Rainbow: true}
  err = postToAPI("dago", p)
  if err != nil {
    panic(err)
  }
}

Listing 6

build.sh

$ go mod init ulanzi
$ go mod tidy
$ go build ulanzi.go api.go countdown.go youtube.go dago.go

Startet Awtrix neu, zum Beispiel nach dem Leeren der Batterie, einem längeren Stromausfall oder einem manuellen Neustart wegen einer Konfigurationsänderung, dann vergisst das Ulanzi den handgedengelten Code und spielt nur die vorkonfigurierten Apps ab (sofern Sie die nicht vorab abgestellt haben). Das bleibt so, bis wieder ein API-Befehl vom Steuerrechner kommt, der den neuesten Wert für eine Custom App setzt. Damit startet der Kreislauf von Neuem. (uba)

Der Autor

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 mailto:mschilli@perlmeister.com beantwortet er gern Ihre Fragen.

Infos

  1. Ulanzi TC001 auf AliExpress: https://www.aliexpress.us/item/3256804848125097.html
  2. Custom-Firmware Awtrix für den Ulanzi TC001: https://blueforcer.github.io/awtrix-light/#/
  3. Snapshot: Mike Schilli, “Kanalarbeiter”, LM 01/2024, S. 78, https://www.lm-online.de/48789
  4. Icons von LaMetric: https://developer.lametric.com/icons
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
1 Kommentar
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
JSchroe
2 Jahre her

Hab es gerade ausprobiert und bekomme zwar ein HTTP 200, aber es passiert nichts….

Nach oben