Die drahtlosen kleinen Tapo-Bewegungsmelder T100 leisten gute Dienste in unserem mietshauseigenen Waschsalon (siehe Januar-Snapshot, [2]). Praktisch, um zu sehen, ob dort gerade Betrieb ist, aber auch Besucherströme durch die Haustür, auf den Gängen und in der Garage lassen sich mit weiteren Meldern quantitativ erfassen. Das Skript der vorangegangenen Ausgabe ([2]) zieht die Bewegungsdaten der Sensoren vom Tapo-Hub in regelmäßigen Abständen ab und speichert sie in einer Datei im CSV-Format. Jede Zeile in Listing 1 beginnt mit einem Zeitstempel und enthält als zweites Feld den Namen des Sensors, der das Signal geschickt hat. So zeigt zum Beispiel die erste Zeile in Listing 1 mit "1782490946,Door", an, dass jemand zur Unix-Zeit 1782490946, also am Freitag, den 26. Juni 2026 um 9:22 Uhr in der in San Francisco herrschenden pazifischen Zeitzone durch die Tür mit dem Sensor marschiert ist.
|
| Abbildung 1: Der unauffällige drahtlose Bewegungsmelder rechts neben der Leuchte sendet Signale zum Hub. |
Aus den Einzelmeldungen lassen sich nun Bewegungsverläufe ableiten. Zum Beispiel zeigt Zeile 13 in Listing 1 an, dass jemand erst am Sensor Hallway-2 vorbeigehuscht ist (Unix-Zeit 1782493696) und nur 9 Sekunden später der Garagensensor angeschlagen hat (Zeile 14). Das legt den Verdacht nahe, dass es sich um dieselbe Person handelt, die an beiden Sensoren vorbeigeschlichen ist. Die Terminal-App in Abbildung 3 illustriert mit einer Pipeline von Hallway-2 zur Garage mitsamt einem sich fortbewegenden Pfeil-Array die (vermutete) Personenbewegung. So werden aus trockenen CSV-Daten kleine Retro-Videospiele, die man gerne mal guckt!
|
| Abbildung 2: Auch im Gang funkt ein Bewegungsmelder bei Personenverkehr. |
01 1782490946,Door
02 1782490966,Garage
03 1782491008,Door
04 1782491069,Door
05 1782491758,Hallway-2
06 1782491762,Hallway-1
07 1782491811,Garage
08 1782492753,Door
09 1782493662,Garage
10 1782493702,Hallway-1
11 1782493724,Hallway-2
12 1782493684,Hallway-1
13 1782493696,Hallway-2
14 1782493705,Garage
|
| Abbildung 3: Ereignisse an den Bewegungsmeldern im Pacman-Format |
Unten im Terminalfenster in Abbildung 3 laufen Logzeilen durch eine Box, die in Rot anzeigt, welche Zeile aus der Ereignisdatei gerade eingelesen wurde und nun oben im Pacman-Stil mittels Ascii-Art animiert wird. Wie funktioniert nun diese Animation, und wie stellt das Go-Programm sicher, dass die App das Unix-Terminal in den Grafikmodus umschaltet und nach Ctrl-C zum Programmabbruch auch wieder zum Shellprompt zurückkehrt, ohne dass Buchstabenfetzen herumliegen wie nach einem Wirbelsturm?
Wie schon in einem anderen Snapshot, der Mai-Ausgabe ([3]), kommt für die diesmal vorgestellte Terminal-App pacmotion das Go-Framework bubbletea zum Einsatz. Es lässt sich zur Compilezeit von Github herunterladen und hilft dabei, den Code zu strukturieren. Die tragenden Säulen der Architektur sind die vorgeschriebenen Funktionen Init() (Zeile 21 in Listing 1) zur anfänglichen Initialisierung, Update() zum Auffrischen der App-Daten bei neu eintreffenden Ereignissen, und View(), um das geänderte Datenmodell in einem Rutsch ans Terminal zu schicken.
|
| Abbildung 4: Der Bewegungsmelder Tapo T100 |
|
| Abbildung 5: Der Hub, der die Sensorsignale aufschnappt und weiterleitet. |
Mit NewProgram() in Zeile 15 setzt das Bubbletea-Framework eine neue Applikation auf, die normalerweise Tastatureingaben des Users von stdin entgegennimmt. Im vorliegenden Fall schiebt aber die aufrufende Shell CSV-Daten nach stdin. Der Bubbletea-Kern merkt, dass stdin nicht an einem Terminal sondern an einer Pipe hängt, und schaltet für Tastatureingaben auf das Terminal /dev/tty um. Da Bubbletea das Terminal dazu in den Raw-Modus schaltet, verweigert die aufrufende Shell die Verarbeitung von Ctrl-C des aufrufenden Users. Normalerweise fängt die Shell diese Kombination ab, und schickt gemäß Unix-Konvention ein SIGINT-Signal an den aufgerufenen Prozess, aber in diesem Fall leitet sie ein Ctrl-C des Users einfach per /dev/tty an den Prozess weiter, der es dann selbst verarbeiten müsste. Stattdessen weist Zeile 15 Bubbletea mit tea.WithInput(nil) an, das Terminal in Ruhe zu lassen, sodass die aufrufende Shell ein Ctrl-C des Users weiterhin abfängt und ein SIGINT schickt, damit der Prozess sich beendet.
01 package main
02 import (
03 "encoding/csv"
04 "fmt"
05 "os"
06 tea "github.com/charmbracelet/bubbletea"
07 )
08 func main() {
09 labels, err := loadLabels("pacmotion.yaml")
10 if err != nil {
11 fmt.Fprintln(os.Stderr, err)
12 os.Exit(1)
13 }
14 m := model{reader: csv.NewReader(os.Stdin), labels: labels}
15 program := tea.NewProgram(m, tea.WithInput(nil))
16 if _, err := program.Run(); err != nil {
17 fmt.Fprintln(os.Stderr, err)
18 os.Exit(1)
19 }
20 }
21 func (m model) Init() tea.Cmd { return m.readEvent() }
22 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
23 switch msg := msg.(type) {
24 case eventMsg:
25 ev := event(msg)
26 m.logs = append(m.logs, ev.line)
27 m.active = ev.box
28 if m.previous != nil && ev.ts-m.previous.ts < 60 && ev.box != m.previous.box {
29 m.from, m.to = m.previous.box, ev.box
30 m.position, m.pipe = 0, true
31 m.previous = &ev
32 return m, nextFrame()
33 }
34 m.pipe = false
35 m.previous = &ev
36 return m, m.readEvent()
37 case frameMsg:
38 if m.position < animFrames-1 {
39 m.position++
40 return m, nextFrame()
41 }
42 return m, m.readEvent()
43 }
44 return m, nil
45 }
46 func (m model) View() string {
47 scene := renderScene(m.from, m.to, m.arrowPosition(), m.active, m.pipe, m.labels)
48 logs := renderLog(m.logs)
49 return scene + "\n" + logs
50 }
Die Funktion Run() wirft den Bubbletea-Motor an, der wiederum konventionsgemäß als erstes die Funktion Init() ab Zeile 21 aufruft. Diese wiederum delegiert an readEvent() aus Listing 3, die sich die nächste CSV-Zeile schnappt und ein Kommando für den nächsten Aufruf von Update() zurückgibt. Der Wert ist tatsächlich eine Funktion, und je nachdem, ob noch CSV-Zeilen übrig sind oder nicht, besteht deren Nachricht vom Typ tea.Msg entweder aus einem Bewegungsmelder-Event oder dem Marschbefehl zum Programmabbruch.
Zeile 28 prüft bei jeder neu eingelesenen CSV-Zeile, ob es sich um einen registrierten Bewegungs-Event handelt, der einem vorhergehenden unmittelbar nachgefolgt ist, also ob es angebracht ist, die Animation zwischen den beiden betroffenen Sensorboxen zu starten.
Das bubbletea-Framework implementiert nun die in Terminal-UIs übliche Endlos-Eventschleife eigenwillig über die Update()-Funktion. Diese erhält eine Nachricht, wie zum Beispiel eine Meldung über eine neu verarbeitete Datenzeile oder die Bestätigung, dass nun der nächste Frame in der Animation zu zeichnen ist. Als Rückgabewerte reicht Update() erstens das Datenmodell und zweitens ein Kommando zurück, wie zum Beispiel "nun lies die nächste Zeile" oder "nun male den nächsten Frame in der Animation". So schlängelt sich die Applikation durch die flüssig laufende Animation, eine Aufgabe, die sonst eine explizite Eventschleife erledigen würde.
Wichtig ist, dass Update() niemals selbst blockierende Funktionen aufruft. Dies unterbräche die Haupteventschleife und hätte eine ruckelndes oder gar totes User Interface zur Folge. Das Bubbletea-Framework schreibt explizit vor, dass Update() zügig durchläuft und ein Kommando zurückgibt, das der Bubbletea-Kern anschließend nebenläufig ausführt. Das Kommando produziert wiederum eine Nachricht, die der Kern sofort nach deren Eintreffen an Update() weiterleitet. Dort wird geprüft um was für einen Event es sich handelt. Hat der User eine Taste gedrückt, muss der Pipe-Renderer den nächsten Frame zeichnen oder liegt die nächste CSV-Zeile vor?
Listing 3 definiert hierzu die Helfer-Funktionen readEvent() und nextFrame(). Beide geben ein Kommando vom Typ tea.Cmd zurück. Das Kommando ist festgelegt als beliebige Funktion, die aber eine Nachricht vom Typ tea.Msg zurückgibt. Letzterer wiederum ist ein leeres Go-Interface, kann also einen beliebigen Typ darstellen. Das erklärt auch, warum die von readEvent() retournierte Funktion eine Variable vom Typ event zurückgibt, während nextFrame() über den Ticker tea.Tick() später eine Funktion liefert die einen time.Time-Typ zurückgibt -- alles ist erlaubt!
Um diese vage typisierte Variable wieder in eine Variable mit statischem Typ umzuwandeln, bedient sich Listing 2 der magischen Zeile
switch msg := msg.(type)
Die neue Variable unter dem Namen msg der alten überschattet die alte und ab der Zuweisung oben (also in allen Bäuchen der case-Bereiche) liegt in msg eine Variable ihres ursprünglichen Typs. Das erklärt auch, warum der Compiler nicht mault, wenn die Zeile
ev := event(msg)
aus der generischen Nachricht wieder eine Struktur vom Typ event mit drei Feldern macht, auf die der Code später mit ev.ts, ev.box und ev.line zugreifen darf. Programmierung wie im Wilden Westen!
Wie viele Frames pro Sekunde die Animation zum Bildschirm schickt, definiert die Variable fps in Listing 3. Die Pfeile in der Pipeline zwischen zwei Sensorboxen laufen relativ zügig Richtung Ziel, aber das ist wie im Kino eine Illusion, die durch aneinandergereihte Frames entsteht. Die drei Pfeile einer Boxenpipeline laufen genau 5 Sekunden lang vom Start zum Ziel, und bei 30 Frames pro Sekunde ist die Animation genau 150 Frames lang.
01 package main
02 import (
03 "encoding/csv"
04 "time"
05 tea "github.com/charmbracelet/bubbletea"
06 "github.com/charmbracelet/lipgloss"
07 )
08 const (
09 boxHeight, pad, logHeight = 1, 1, 8
10 pipeRun, fps, animFrames = 12, 30, fps * 5
11 )
12 var (
13 boxStyle = fg("63")
14 activeBoxStyle = fg("196")
15 activeText = activeBoxStyle.Bold(true)
16 trackStyle = fg("240")
17 arrowStyle = fg("205").Bold(true)
18 logBoxStyle = lipgloss.NewStyle().
19 Border(lipgloss.RoundedBorder()).
20 BorderForeground(lipgloss.Color("240"))
21 )
22 func fg(color string) lipgloss.Style {
23 return lipgloss.NewStyle().Foreground(lipgloss.Color(color))
24 }
25 type event struct {
26 ts int64
27 box int
28 line string
29 }
30 type point struct{ x, y int }
31 type eventMsg event
32 type frameMsg time.Time
33 type model struct {
34 reader *csv.Reader
35 labels, logs []string
36 previous *event
37 from, to, position, active int
38 pipe bool
39 }
40 func (m model) readEvent() tea.Cmd {
41 return func() tea.Msg {
42 ev, ok := nextCSV(m.reader, m.labels)
43 if !ok {
44 return tea.Quit()
45 }
46 return eventMsg(ev)
47 }
48 }
49 func (m model) arrowPosition() int {
50 if !m.pipe {
51 return 0
52 }
53 return m.position * (len(pipeRoute(m.from, m.to)) - 1) / (animFrames - 1)
54 }
55 func nextFrame() tea.Cmd {
56 return tea.Tick(time.Second/fps, func(t time.Time) tea.Msg { return frameMsg(t) })
57 }
Listing 3 definiert Datenstrukturen, mit denen das Programm den TUI-Ablauf steuert, wie zum Beispiel das in bubbletea verwendete Datenmodell ab Zeile 33. Es beherbergt den CSV-Reader, die angezeigten Boxen, und den aktuell sowie den vorherigen verarbeiteten Sensor-Event. Weiter merkt sie sich, ob eine Pipeline schon zwei Boxen verbindet, und ob die Animation der wandernden Pfeile entlang der Pipeline immer noch weitertickt.
Die Funktionen readEvent() und nextFrame() in Listing 3 bauen das nächste Kommando für die bubbletea-Schleife zusammen, die Update() in Listing 1 an die Maschinerie des Frameworks zurückreicht, damit das Eventgetriebe um einen Zacken weiterdreht.
01 package main
02 import (
03 "strings"
04 "github.com/charmbracelet/lipgloss"
05 )
06 func renderScene(from, to, pos, active int, pipe bool, labels []string) string {
07 left := renderBoxes(labels, active)
08 for i, line := range makePipe(from, to, pos, pipe, len(left)) {
09 left[i] += line
10 }
11 return strings.Join(left, "\n")
12 }
13 const boxWidth = 40
14 func renderBoxes(labels []string, active int) []string {
15 var lines []string
16 for i, label := range labels {
17 style, text := boxStyle, label
18 if i == active {
19 style, text = activeBoxStyle, activeText.Render(label)
20 }
21 box := style.Width(boxWidth).Height(boxHeight).Align(lipgloss.Center).Border(lipgloss.RoundedBorder()).Render(text)
22 lines = append(lines, strings.Split(box, "\n")...)
23 if i < len(labels)-1 {
24 lines = append(lines, strings.Repeat(" ", boxWidth+2))
25 }
26 }
27 return lines
28 }
29 func renderLog(lines []string) string {
30 start := max(len(lines)-(logHeight-2), 0)
31 var out []string
32 for i, line := range lines[start:] {
33 if start+i == len(lines)-1 {
34 line = activeText.Render(line)
35 }
36 out = append(out, line)
37 }
38 return logBoxStyle.Width(boxWidth).Height(logHeight - 2).Render(strings.Join(out, "\n"))
39 }
Die gesamte Szenerie der Terminal-App zeichnet die Funktion renderScene() in Zeile 6 von Listing 4. Sie besteht aus zwei Teilen, dem oberen mit den Sensorboxen und dem unteren mit der Logbox. Erstere zeichnet renderBoxes() ab Zeile 14 und letztere renderLog() ab Zeile 29. Farben und Strichstärke kontrollieren die Stilelemente des Pakets lipgloss aus dem Fundus des bubbletea-Projekts.
Listing 5 erledigt die statische Konfiguration des Programms und liest die Sensornamen aus der Yaml-Datei pacmotion.yaml ein (Listing #4). Die nächste Zeile der dynamischen Sensordaten in der CSV-Datei greift sich das Standardpaket encoding/csv aus dem Go Standardfundus in der Funktion nextCSV() ab Zeile 18 in Listing 5. Da Go auf strengen Typen besteht, werden die Unix-Zeitstempel zunächst als Strings ausgelesen, dann mit ParseInt() in Integerwerte umgewandelt, um anschließend mit time.Unix() aus Gos Zeitpaket in Go-Zeitstempel konvertiert zu werden. Das ginge in einer Skriptsprache definitiv einfacher und kürzer, aber hat man sich erst einmal an den etwas weitschweifigeren Coden einer typsicheren Sprache gewohnt, weiß man zu schätzen, dass ganze Kategorien von Fehlern in Sekundenschnelle beim Compilieren erkannt werden, statt dass sich mit Debuggern bewaffnete Programmierer minutenlang die Haare raufen, nur weil sich ein Tippfehler eingeschlichen hat.
01 package main
02 import (
03 "encoding/csv"
04 "os"
05 "strconv"
06 "strings"
07 "time"
08 "gopkg.in/yaml.v3"
09 )
10 func loadLabels(path string) ([]string, error) {
11 var cfg map[string][]string
12 data, err := os.ReadFile(path)
13 if err == nil {
14 err = yaml.Unmarshal(data, &cfg)
15 }
16 return cfg["sensors"], err
17 }
18 func nextCSV(reader *csv.Reader, labels []string) (event, bool) {
19 rec, err := reader.Read()
20 if err != nil || len(rec) < 2 {
21 return event{}, false
22 }
23 ts, err := strconv.ParseInt(strings.TrimSpace(rec[0]), 10, 64)
24 if err != nil {
25 return event{}, false
26 }
27 name := strings.TrimSpace(rec[1])
28 for i, sensor := range labels {
29 if strings.EqualFold(name, sensor) {
30 line := time.Unix(ts, 0).Format("2006-01-02 15:04:05 ") + name
31 return event{ts, i, line}, true
32 }
33 }
34 return event{}, false
35 }
Das Zeichnen der Ascii-Art der animierten Pipelines erledigt Listing 6. Erst findet pipeRoute() ab Zeile 3 heraus, zwischen welchem Start- und Endpunkt die Pipe überhaupt liegt. Dann stellt sie im Array route die Bildschirmkoordinaten zusammen, an denen sich die Pipe entlangangelt. Wer genau hinschaut, sieht, dass die Pipe aus verschiedenen geformten Teilstücken besteht. Erst geht sie waagrecht von einer Sensorbox ab, dann biegt sie entweder rechts oder links ab, und anschließend geht es senkrecht nach oben oder unten. Für alle vier Fälle definiert pipeChar() ab Zeile 43 ein entsprechendes Legoteil.
Ähnliches gilt für die wandernden Pfeile innerhalb der Pipeline: Sie zeigen mit "<" nach links, falls es rückwärts geht, ^ nach oben, v nach unten, und mit ">" nach rechts. Die Funktion drawArrow() ab Zeile 60 legt für jeden Pipeline-Abschnitt die korrekte Pfeilform ab.
01 package main
02 import "strings"
03 func pipeRoute(from, to int) []point {
04 height := boxHeight + 2
05 y, end := from*(height+pad)+height/2, to*(height+pad)+height/2
06 var route []point
07 for x := 0; x <= pipeRun; x++ {
08 route = append(route, point{x, y})
09 }
10 step := 1
11 if y > end {
12 step = -1
13 }
14 for y += step; y != end; y += step {
15 route = append(route, point{pipeRun, y})
16 }
17 for x := pipeRun; x >= 0; x-- {
18 route = append(route, point{x, end})
19 }
20 return route
21 }
22 func makePipe(from, to, pos int, enabled bool, height int) []string {
23 canvas := make([][]string, height)
24 for y := range canvas {
25 canvas[y] = make([]string, pipeRun+1)
26 for x := range canvas[y] {
27 canvas[y][x] = " "
28 }
29 }
30 if enabled {
31 route := pipeRoute(from, to)
32 for i, p := range route {
33 canvas[p.y][p.x] = trackStyle.Render(pipeChar(route, i))
34 }
35 drawArrow(canvas, route, pos)
36 }
37 lines := make([]string, height)
38 for y := range canvas {
39 lines[y] = strings.Join(canvas[y], "")
40 }
41 return lines
42 }
43 func pipeChar(route []point, i int) string {
44 if i == 0 || i == len(route)-1 {
45 return "─"
46 }
47 a, p, b := route[i-1], route[i], route[i+1]
48 up, down := a.y < p.y || b.y < p.y, a.y > p.y || b.y > p.y
49 switch {
50 case up && down:
51 return "│"
52 case down:
53 return "┐"
54 case up:
55 return "┘"
56 default:
57 return "─"
58 }
59 }
60 func drawArrow(canvas [][]string, route []point, pos int) {
61 next := route[min(pos+1, len(route)-1)]
62 p := route[pos]
63 direction := point{next.x - p.x, next.y - p.y}
64 arrow := map[point]string{{1, 0}: ">", {-1, 0}: "<", {0, 1}: "v", {0, -1}: "^"}[direction]
65 for _, p := range route[pos:min(pos+3, len(route))] {
66 canvas[p.y][p.x] = arrowStyle.Render(arrow)
67 }
68 }
Der übliche Dreisprung go mod init pacmotion; go mod tidy; go build linkt alles zu einem ausführbaren Binary zusammen. Wie ein Skript die Bewegungsdaten vom Tapo-Hub einholt, zeigt die Snapshot-Ausgabe vom Januar dieses Jahres ([2]). Weiter erwartet das Programm eine Liste von Sensornamen in der Datei pacmotion.yaml, damit es gleich zu Beginn alle Sensorboxen in der vordefinierten Reihenfolge auf den Bildschirm bringen kann (Listing 7).
1 sensors:
2 - Hallway-2
3 - Hallway-1
4 - Door
5 - Garage
Dann noch die Daten im CSV-Format nach Listing 1 extrahiert, und auf die Standardeingabe des compilierten Programms pacmotion gepumpt! Nach dem Aufruf von cat data.csv | ./pacmotion schnackelt dann das Terminal in den Grafikmodus und zeigt die Animation. Genüsslich lehnt sich der Hausmeister zurück, schenkt sich eine Halbe ein, und verfolgt den Bewegungsreigen.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2026/09/snapshot/
"Spion im Waschsalon", Michael Schilli, https://www.linux-magazin.de/ausgaben/2026/01/snapshot-2/
"Daumen im Wind", Michael Schilli, https://www.linux-magazin.de/ausgaben/2026/05/snapshot/