Pacman des Hausmeisters (Linux-Magazin, September 2026)

Strategisch positionierte Bewegungsmelder liefern Signale, die Mike Schilli mit einer Go-Terminal-App im Pacman-Look-And-Feel aufpoliert, um zu sehen, wer wo entlanggelaufen ist.

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.

Listing 1: data.csv

    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

Bewegte Ascii-Art

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.

Terminal in Ruhe lassen

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.

Listing 2: pacmotion.go

    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?

Wilder Westen in Go

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!

Daumenkino

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.

Listing 3: anim.go

    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.

Listing 4: render.go

    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.

Listing 5: input.go

    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.

Listing 6: pipe.go

    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 }

Bevor es schnackelt

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).

Listing 7: pacmotion.yaml

    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.

Infos

[1]

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

[2]

"Spion im Waschsalon", Michael Schilli, https://www.linux-magazin.de/ausgaben/2026/01/snapshot-2/

[3]

"Daumen im Wind", Michael Schilli, https://www.linux-magazin.de/ausgaben/2026/05/snapshot/

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.