Aus Linux-Magazin 01/2026

Go-Programm prüft Belegung im Waschsalon

© wirestock / 123RF.com

Bevor er Wäsche wäscht, prüft Mike Schilli die Aktivität im Waschsalon anhand eines Go-Programms, das einen Tapo-Bewegungsmelder abfragt.

Wer wie ich in einem amerikanischen Mietshaus wohnt, erlebt täglich tausend Abenteuer. Eines davon spielt sich im hauseigenen Miniwaschsalon ab, in dem sich fast 30 Parteien zwei Waschmaschinen teilen. Oft schon bin ich mit einem prall gefüllten Waschkorb im Schlepptau mit dem Aufzug zwei Stockwerke tiefer gefahren, nur um festzustellen, dass beide Maschinen belegt sind. “Hier muss der deutsche Ingenieur ran!”, rief ich aus (Abbildung 1) und baute ein System, das fortan sämtliche Aktivitäten in der Waschküche für alle sichtbar im Web anzeigt.

Abbildung 1: Der Autor in der mietshauseigenen Waschküche.

Abbildung 1: Der Autor in der mietshauseigenen Waschküche.

Dazu klebte ich zunächst zwei batteriebetriebene Bewegungsmelder des Typs Tapo T100 an die Wände des Waschsalons (Abbildung 2), die auf vorbeilaufende menschliche Körper anspringen, da deren Infrarot-Abstrahlung den von einem Passive Infrared Sensor (PIR) gemessenen Wärmehintergrund verändert. Die Sensoren senden daraufhin ein kurzes Signal an einen Hub vom Typ Tapo H100, der die Zeitstempel der Ereignisse in einer SQLite-Datenbank speichert.

Abbildung 2: In der Waschküche installierter Bewegungsmelder T100.

Rührt sich nun etwas im Waschsalon, heißt das normalerweise, dass jemand dort unten entweder die Waschmaschinen oder die Trockner befüllt oder leert. Häufig empfiehlt es sich, eine Stunde zu warten, bis dort wieder Ruhe eingekehrt ist. Abbildung 3 zeigt den Waschküchenmonitor als laufend aufgefrischte Webseite. Sie zeigt die Bewegungsaktivitäten während der vergangenen 24 Stunden unten durch kleine schwarze Dreiecke an. Im oberen Bereich findet sich mittels einer Heuristik abgeschätzt, während welcher Zeiten die Maschinen besetzt waren (orange) und wann ich selbst hätte waschen können (grün).

Abbildung 3: Internet-Darstellung der Aktivität im häuslichen Waschsalon.

Abbildung 3: Internet-Darstellung der Aktivität im häuslichen Waschsalon.

Events im zeitlichen Abstand von weniger als anderthalb Stunden bewertet das System als Anzeichen dafür, dass der Waschsalon besetzt war. Das Verfahren ist freilich nicht perfekt, denn manchmal steckt auch nur jemand den Kopf zur Tür des Waschsalons herein, um zu sehen, ob die Maschinen frei sind, oder irgendein Hallodri lässt seine Wäsche stundenlang in der bereits fertigen Waschmaschine modern. Aber so genau geht es ja nicht zu.

Cloud oder handgestrickt?

Nun bietet die Tapo-App fürs Mobiltelefon schon allerhand Firlefanz zur Überwachung der Sensoren, und so auch für einen dort angemeldeten Bewegungsmelder (Abbildung 4). Dort steht genau, wann der Sensor angeschlagen hat, und sogar Push-Nachrichten beherrscht die App. Doch alle Mietparteien im Haus sollten auf einfache Weise und ohne Push-Schwemme auf Wunsch Zugriff auf die Daten erhalten. Und so blieb nur der Hack, per WLAN über die in der App angezeigte IP des steuernden Hubs (Abbildung 5) auf das Gerät zuzugreifen und die Daten händisch abzuholen, zu verarbeiten und ein öffentlich zugängliches Diagramm online anzuzeigen (Abbildung 3).

Abbildung 4: Die Tapo-App zeigt Signale des Bewegungsmelders an.

Abbildung 4: Die Tapo-App zeigt Signale des Bewegungsmelders an.

Abbildung 5: Der für die Verwaltung zuständige Hub lauscht auf der vergebenen IP-Adresse.

Abbildung 5: Der für die Verwaltung zuständige Hub lauscht auf der vergebenen IP-Adresse.

Dabei hängt der Bewegungsmelder T100 nicht etwa selbst im WLAN, sondern tuschelt batterieschonend mit dem Tapo-Hub H100, der sich seinerseits im WLAN befindet. Um die Daten abzuholen, muss ein steuernder Raspberry Pi also beim Hub anfragen, die Event-Daten des Bewegungsmelders abholen, eine Webseite daraus zimmern und ins Internet hochladen. Abbildung 6 zeigt den Datenfluss zwischen den beteiligten Geräten.

Abbildung 6: Eine Webseite zeigt Signale der Bewegungsmelder in der Waschküche an.

Abbildung 6: Eine Webseite zeigt Signale der Bewegungsmelder in der Waschküche an.

Wartungsmangel

Noch in der Snapshot-Ausgabe vor zwei Monaten [1] habe ich das Go-Paket »tapo-api« auf Github dazu verwendet, tadellos einen Tapo-Stromzähler auszulesen. Doch wie lässt sich der Hub ansteuern, um die Trigger-Logs des Bewegungsmelders abzufragen? Unglücklicherweise leidet »tapo-api« auf Github unter Wartungsmangel und bietet zwar die Funktion »get_trigger_logs()« an, läuft dabei allerdings in einen Fehler. Ich habe einen Pull-Request für einen verwandten Fehler eingebracht, der aber noch nicht eingespielt ist, deshalb sattelte ich für diese Ausgabe kurzerhand zumindest für das Abholskript auf Python um. Das Paket »tapo« im Python-Universum beherrscht die API des Hubs besser und Listing 1 veranschaulicht, wie der Code die Zeitstempel der letzten Bewegungsereignisse einholt.

Listing 1

h100.py

import asyncio
import os
import yaml
from tapo import ApiClient
from tapo.responses import T100Result
def murmur_get(key):
  path = os.path.expanduser("~/.murmur")
  with open(path, "r") as f:
    data = yaml.safe_load(f)
  return data.get(key)
async def main():
    tapo_username = murmur_get("tapo_user")
    tapo_password = murmur_get("tapo_pass")
    ip_address = "192.168.87.40"
    client = ApiClient(tapo_username, tapo_password)
    hub = await client.h100(ip_address)
    child_device_list = await hub.get_child_device_list()
    for child in child_device_list:
        if isinstance(child, T100Result):
            t100 = await hub.t100(device_id=child.device_id)
            trigger_logs = await t100.get_trigger_logs(20, 0)
            if "Laundry" in child.nickname:
                 for log in trigger_logs.logs:
                    print(log.timestamp)
if __name__ == "__main__":
    asyncio.run(main())

Damit der Hub das Skript an seine Daten lässt, benötigt die Library die Kombination von Username und Passwort des Accounts in der TP-Link-Cloud. Daraus formt die Library einen Hash, den sie mit einem während der Anmeldung des Bewegungsmelders in der TP-Link-Cloud gespeicherten Wert vergleicht. Damit diese sensitiven Daten nicht im Code stehen, lagern sie in einer »~/.murmur-Datei« im Yaml-Format, von wo sie Listing 1 in der Funktion »murmur_get()« ab Zeile 6 ausliest.

Mit den Credentials erzeugt dann Zeile 15 einen API-Client, der sich in Zeile 16 mit dem Hub verbindet, dessen Webserver auf der in Zeile 14 eingestellten IP-Adresse lauscht. Zeile 17 holt die vom Hub verwalteten Geräte, und Zeile 22 filtert die Sensoren heraus, deren Nickname das Wort “Laundry” enthält. Mit diesen setzt sich Zeile 20 jeweils über den Hub in Verbindung und bekommt in Zeile 21 eine Liste mit Trigger-Logs zurück. Jeder Eintrag dort ist mit einem Zeitstempel versehen, und Zeile 24 gibt selbigen aus. Auf der Standardausgabe des Python-Skripts purzeln dann wie in Listing 2 abgebildet nach sachgerechter Installation zeilenweise die aktuell ausgelesenen Zeitstempel im Unix-Format heraus, also als Integer in Sekunden seit 1970.

Listing 2

pyrun.sh

$ python3 -m venv venv
$ venv/bin/pip install pyyaml tapo
$ venv/bin/python h100.py
1761670177
1761670116
1761668223
1761668159
1761668098
1761668037
...

Zur Installation: Das Skript benötigt außer dem Python-Paket »pyyaml« noch die Bibliothek »tapo«, die der Python-Package-Installer »pip« einholt – am besten in einer virtuellen Python-Umgebung »venv« (Listing 2). Ein Cronjob ruft später das Skript mit dem vollen Python-Pfad in die »venv«-Umgebung auf, so bleibt die Root des verwendeten Systems sauber, im vorliegenden Fall ein Raspberry Pi 4.

Für immer gespeichert

Nun dürfte der Hub irgendwann die älteren Triggerdaten verwerfen, um Platz zu sparen. Es wäre jedoch durchaus interessant, nach einiger Zeit die Belegung des Waschsalons statistisch auszuwerten. An welchen Wochentagen geht es am tollsten zu? Welche Tageszeit ist die ruhigste? Darum speichert Listing 3 die zeilenweise auf Stdin hereinkommenden Zeitstempel in einer SQLite-Datenbank »laundry.db«, die es bei Bedarf auch gleich neu anlegt. Abbildung 7 zeigt das Schema, das lediglich aus einer Spalte für den numerischen Zeitstempel besteht. Die Pipeline zum Einlesen neuer Daten steht in Zeile 4 von Abbildung 7. Der Insert-Befehl in Zeile 29 von Listing 3 unterbindet doppelte Zeitstempel, also darf die Pipeline beliebig oft anspringen, ohne dass die Datenbank mit Duplikaten zugemüllt wird.

Listing 3

db.go

package main
import (
  "bufio"
  "database/sql"
  "os"
  "strconv"
  _ "modernc.org/sqlite"
)
func main() {
  const dbFile = "laundry.db"
  db, err := sql.Open("sqlite", dbFile)
  if err != nil {
    panic(err)
  }
  defer db.Close()
  _, err = db.Exec(`CREATE TABLE IF NOT EXISTS events (
    ts INTEGER PRIMARY KEY
  );`)
  if err != nil {
    panic(err)
  }
  scanner := bufio.NewScanner(os.Stdin)
  for scanner.Scan() {
    line := scanner.Text()
    ts, err := strconv.ParseInt(line, 10, 64)
    if err != nil {
      panic(err)
    }
    _, err = db.Exec(`INSERT OR IGNORE INTO events (ts) VALUES (?)`, ts)
    if err != nil {
      panic(err)
    }
  }
  if err := scanner.Err(); err != nil {
    panic(err)
  }
}
Abbildung 7: Messdaten wandern in die SQLite-Datenbank.

Abbildung 7: Messdaten wandern in die SQLite-Datenbank.

Schön bunt

Selbstredend will kein Hausbewohner auf Zahlenkolonnen starren, und deswegen stellt Listing 4 die Bewegungslogs der vorherigen 24 Stunden wie in Abbildung 3 bunt als Webgrafik dar. Die Wahl des Plotters fiel diesmal auf Google Charts, eine JavaScript-Bibliothek, die ansprechende Graphen ins Browserfenster malt. Dass damit Google Zugriff auf die Bewegungsdaten bekommt, ist im vorliegenden Fall eher nicht relevant.

Die Webgrafik in Abbildung 3 besteht aus zwei Teilen. Unten steht ein Graph, auf dessen X-Achse die Tageszeit während der vergangenen 24 Stunden verzeichnet ist. Als Y-Werte setzt der Code schwarze Dreiecke, eines für jeden vorliegenden Zeitstempel mit Waschküchenbewegung. Der obere Teil des Diagramms illustriert, ob der Waschsalon gerade frei oder besetzt ist. Zeitfenster mit Bewegung mindestens alle 90 Minuten erscheinen in Orange, längere bewegungsfreie Zeiträume markiert es grün.

Abbildung 8: Die Aktivitätsanzeige besteht aus zwei überlappenden Graphen.

Abbildung 8: Die Aktivitätsanzeige besteht aus zwei überlappenden Graphen.

Diese für einen X/Y-Grafen etwas ungewöhnliche Darstellung besteht aus zwei überlappenden Einzelgraphen (Abbildung 8). Das Go-Programm in Listing 4 nimmt Zeitstempel für Bewegungen innerhalb der letzten 24 Stunden zeilenweise auf Stdin entgegen, entweder direkt vom abfragenden Python-Programm oder aus der Datenbank mit einem SQL-Query.

select * from events where ts >= strftime('%s', 'now') - 24 * 3600 order by ts;

Listing 4

mkgraph.go

package main
import (
  "bufio"
  "html/template"
  "os"
  "strconv"
)
type Point struct {
  X int64
  Y int
}
type tmplData struct {
  Events   []int64
  InUse    []Point
  NotInUse []Point
}
func main() {
  data := tmplData{
    Events:   []int64{},
    InUse:    []Point{},
    NotInUse: []Point{},
  }
  sc := bufio.NewScanner(os.Stdin)
  for sc.Scan() {
    txt := sc.Text()
    if sec, err := strconv.ParseInt(txt, 10, 64); err == nil {
      data.Events = append(data.Events, sec)
    }
  }
  data.NotInUse, data.InUse = windows(data.Events)
  t, err := template.ParseFiles("chart.html")
  if err != nil {
    panic(err)
  }
  err = t.Execute(os.Stdout, data)
  if err != nil {
    panic(err)
  }
}

Zum Befüllen der Webgrafik hält der Datentyp »tmplData« aufbereitete Messdaten vor, die Zeile 31 mit Gos Template-Mechanismus dynamisch in die HTML-Datei »chart.html« aus Listing 6 einpflanzt. Das Feld »events« führt dabei die Zeitstempel der Trigger-Events, während »InUse« die mittels der Heuristik errechneten Zeitfenster aktiver Waschsalonbenutzung vorhält. »NotInUse« gibt andererseits die später grün dargestellten Zeiträume ohne Trigger-Events an.

Listing 5

windows.go

package main
import (
  "time"
)
func windows(events []int64) ([]Point, []Point) {
  finish := time.Now()
  start := finish.Add(-24 * time.Hour)
  busy := []Point{}
  idle := []Point{}
  base := start
  events = append(events, finish.Unix())
  for _, e := range events {
    eTime := time.Unix(e, 0).In(time.Local)
    block := mkBlock(base.Unix(), e)
    if eTime.Sub(base) > 90*time.Minute {
      idle = append(idle, block...)
    } else {
      busy = append(busy, block...)
    }
    base = eTime.Add(1 * time.Second)
  }
  return idle, busy
}
func mkBlock(from, to int64) []Point {
  block := []Point{}
  block = append(block,
    Point{from, 0}, Point{from, 1},
    Point{to, 1}, Point{to, 0})
  return block
}

Für diese per abenteuerlicher Heuristik ermittelten Fensterwerte zeichnet Listing 5 verantwortlich. Stellt Zeile 15 dort fest, dass vor einem Trigger-Event mehr als 90 Minuten lang nichts passiert ist, schiebt es einen grün zu zeichnenden Block ans Ende des Arrays »idle«. Ging es dagegen hoch her im Waschsalon, wandert der neue, orange zu zeichnende Block ans Ende des Arrays »busy«.

Listing 6

chart.html

<!DOCTYPE html>
<html>
  <head>
    <title>Laundromat Availability</title>
    <script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
    <script type="text/javascript">
      google.charts.load('current', { packages: ['corechart'] });
      google.charts.setOnLoadCallback(drawChart);
      function drawChart() {
        const now = new Date();
        const past24 = new Date(now.getTime() - 24 * 60 * 60 * 1000);
        const idleData = new google.visualization.DataTable();
        idleData.addColumn('datetime', 'Time');
        idleData.addColumn('number', 'Idle');
        idleData.addRows([
        {{range .NotInUse}}
          [new Date({{.X}} * 1000), {{.Y}}],
        {{end}}
        ]);
        const busyData = new google.visualization.DataTable();
        busyData.addColumn('datetime', 'Time');
        busyData.addColumn('number', 'Busy');
        busyData.addRows([
        {{range .InUse}}
          [new Date({{.X}} * 1000), {{.Y}}],
        {{end}}
        ]);
        const eventData = new google.visualization.DataTable();
        eventData.addColumn('datetime', 'Time');
        eventData.addColumn('number', 'Event');
        {{range .Events}}
          eventData.addRows([[new Date({{.}} * 1000), 1]]);
        {{end}}
        // --- Chart options ---
        const barOptions = {
          title: 'Laundromat Availability',
          backgroundColor: 'white',
          chartArea: { left: 60, top: 40, width: '80%', height: '70%' },
          hAxis: {
            title: 'Time (last 24 hours)',
            format: 'HH:mm',
            viewWindow: { min: past24, max: now },
            gridlines: { color: '#ddd' }
          },
          vAxis: {
            title: 'Availability',
            minValue: 0,
            maxValue: 1,
            gridlines: { color: 'transparent' }
          },
          series: {
            0: { type: 'area', color: 'orange', areaOpacity: 1.0, lineWidth: 0 },
            1: { type: 'area', color: 'lightgreen', areaOpacity: 1.0, lineWidth: 0 },
          }
        };
        const joined = google.visualization.data.join(
          busyData,
          idleData,
          'full',
          [[0, 0]],
          [1],
          [1]
        );
        const dotOptions = {
          title:'Events',
          legend:'none',
          chartArea:{left:60,top:20,width:'80%',height:'60%'},
          backgroundColor:'white',
          hAxis:{
            viewWindow:{min:past24,max:now},
          },
          series:{0:{color:'black',pointShape:'triangle',pointSize:8}},
        };
        const topChartDiv = document.getElementById('top_chart_div');
        const bottomChartDiv = document.getElementById('bottom_chart_div');
        const topChart = new google.visualization.ComboChart(topChartDiv);
        const bottomChart = new google.visualization.ComboChart(bottomChartDiv);
        topChart.draw(eventData, dotOptions);
        bottomChart.draw(joined, barOptions);
      }
    </script>
    <style>
      body { background: #fff; }
      #chart_div {
        border: 3px solid black;
        width: 800px;
        height: 600px;
        margin: 40px auto;
      }
    </style>
  </head>
  <body>
    <div id="graph_box">
      <div id="bottom_chart_div"></div>
      <div id="top_chart_div"></div>
    </div>
  </body>
</html>

Da die Blöcke im Diagramm später nicht einfache Rechtecke sind sondern X/Y-Graphen, deren Unterbauch eingefärbt ist, besteht jeder Block aus vier X/Y-Werten (Abbildung 8): (x1, 0), (x1, 1), (x2, 1) und (x2, 0). Dabei treffen zwei sich überlappende Graphen im oberen Teil des Diagramms, deren Bäuche grün und rot ausgemalt werden.

Eigenwillige Library

Hierzu ruft der JavaScript-Code in Listing 6 in Zeile 56 die Funktion »join()« aus dem Paket »google.visualization« auf. Als Parameter nimmt sie die beiden Arrays »busyData« und »idleData« mit den Zeitfensterdaten entgegen. Es folgen einige Kontrollparameter für den Join. Beide Arrays enthalten nämlich jeweils Wertepaare von X/Y-Koordinaten, »X« auf Indexplatz 0 und »Y«> auf 1. Zum Mischen der Daten gibt nun »[[0,0]]« in Zeile 60 an, dass die X-Werte (jeweils auf Indexposition 0) zu einem neuen Graphen zusammenzufassen sind. Welche weiteren Parameter in den gemischten Datensatz wandern sollen, steht in den Parametern 5 und 6, nämlich die Y-Werte, beides Mal auf den Indexpositionen 1 in den jeweiligen Datensätzen. Etwas umständlich, aber die Google-Charts-Library gibt sich hier eigenwillig.

Der JavaScript-Code erhält die darzustellenden Messdaten dynamisch über Gos Template-Mechanismus. Listing 4 nutzt dazu die Funktionen des Pakets »html/template« aus dem Standardfundus. Sobald Zeile 35 dort »Execute()« aufruft, ersetzt das Paket Parameter im Template »chart.html«, die zwischen doppelten geschweiften Klammern stehen. Auch For-Schleifen beherrscht Gos Template-Paket, so iteriert »{{range .InUse}}« ab Zeile 24 über alle Elemente des als »InUse« unter »tmplData« hereingereichten Arrays. Da diese Felder wiederum »structs« vom Typ »Point« sind, kann der Code innerhalb der Schleife auf deren Attribute »X« und »Y« mit »{{.X}}« und »{{.Y}}« zugreifen.

Das so expandierte Template gibt Listing 4 auf Stdout aus. Von wo es ein Cronjob auf einen öffentlich zugänglichen Webserver schiebt. Handverlesene Hausbewohner kennen die URL und haben die Adresse in ihrer Bookmarksammlung gespeichert. So muss aus diesem Kreis hoffentlich niemand mehr unnötige Wanderungen zum Waschsalon unternehmen. (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 mschilli@perlmeister.com beantwortet er gern Ihre Fragen.

Infos

  1. “Stromzähler”, Michael Schilli, Linux-Magazin 10/2025: https://www.linux-magazin.de/ausgaben/2025/10/snapshot/
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 7 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:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben