Saubere Wäsche (Linux-Magazin, Januar 2026)

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

Wer wie ich in einem amerikanischen Mietshaus wohnt, erlebt täglich tausend Abenteuer. Eines davon ist die Benutzung des hauseigenen Miniwaschsalons, 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 gerade keine Maschine frei war. "Hier muss der deutsche Ingenieur ran!" rief ich aus und baute ein System, das nun alle Aktivitäten in der Waschküche für alle sichtbar auf dem Internet anzeigt.

Abbildung 1: Internet-Darstellung der Aktivität im Waschsalon

Hierzu pappte ich insgesamt zwei batteriebetriebene Bewegungsmelder des Typs Tapo T100 an die Wände des Waschsalons (Abbildung 3), die auf vorbeilaufende menschliche Körper anspringen, da deren Infrarot-Abstrahlung den von einem PIR-(Passive-Infrared)-Sensor 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: Der Autor in der mietshauseigenen Waschküche

Abbildung 3: In der Waschküche installierter Bewegungsmelder T100

Rührt sich nun etwas im Waschsalon, heißt dies normalerweise, dass jemand dort unten entweder die Waschmaschinen oder die Trockner befüllt oder entleert, und oft empfielt es sich, eine Stunde zu warten, bis dort wieder Ruhe eingekehrt ist. Abbildung 1 zeigt den Waschküchenmonitor als laufend aufgefrischte Webseite, die die Bewegungsaktivitäten während der vergangenen 24 Stunden unten durch kleine schwarze Dreiecke anzeigt, sowie im oberen Bereich mittels einer Heuristik abschätzt, während welcher Zeiten die Maschinen besetzt waren (orange) und wann ich selber hätte waschen können (grün).

Abbildung 4: Bewegungsmelder Tapo T100

Abbildung 5: Der Melder benötigt den Hub Tapo H100

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 ganz 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's nicht.

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 6). 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 7) auf das Gerät zuzugreifen und die Daten händisch abzuholen, zu verarbeiten und ein öffentlich zugängliches Diagramm auf dem Web anzuzeigen (Abbildung 1).

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

Abbildung 7: 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 seinerseits im WLAN hängt. 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 8 zeigt den Datenfluss zwischen den beteiligten Geräten.

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

Wartungsmangel

Noch in der Snapshot-Ausgabe vor zwei Monaten habe ich das Go-Paket tapo-api auf Github ([2]) dazu verwendet, tadelfrei einen Tapo-Stromzähler auszulesen, aber wie lässt sich der Hub ansteuern, um die Trigger-Logs des Bewegungsmelders abzufragen? Leider leidet tapo-api auf Github unter Wartungsmangel und bietet zwar die Funktion get_trigger_logs() an, läuft dabei aber auf einen Fehler. Ich habe einen Pull-Request für einen verwandten Fehler eingebracht, der aber noch nicht eingespielt wurde, also 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 zeigt, wie der Code die Zeitstempel der letzten Bewegungsereignisse einholt.

Listing 1: h100.py

    01 import asyncio
    02 import os
    03 import yaml
    04 from tapo import ApiClient
    05 from tapo.responses import T100Result
    06 def murmur_get(key):
    07   path = os.path.expanduser("~/.murmur")
    08   with open(path, "r") as f:
    09     data = yaml.safe_load(f)
    10   return data.get(key)
    11 async def main():
    12     tapo_username = murmur_get("tapo_user")
    13     tapo_password = murmur_get("tapo_pass")
    14     ip_address = "192.168.87.40"
    15     client = ApiClient(tapo_username, tapo_password)
    16     hub = await client.h100(ip_address)
    17     child_device_list = await hub.get_child_device_list()
    18     for child in child_device_list:
    19         if isinstance(child, T100Result):
    20             t100 = await hub.t100(device_id=child.device_id)
    21             trigger_logs = await t100.get_trigger_logs(20, 0)
    22             if "Laundry" in child.nickname:
    23                  for log in trigger_logs.logs:
    24                     print(log.timestamp)
    25 if __name__ == "__main__":
    26     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 dann 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 gezeigt nach sachgerechter Installation zeilenweise die aktuell ausgelesenen Zeitstempel im Unix-Format heraus, also als Integer in Sekunden seit 1970.

Listing 2: pyrun.sh

    01 $ python3 -m venv venv
    02 $ venv/bin/pip install pyyaml tapo
    03 $ venv/bin/python h100.py
    04 1761670177
    05 1761670116
    06 1761668223
    07 1761668159
    08 1761668098
    09 1761668037
    10 ...

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

Für immer gespeichert

Nun wird der Hub irgendwann die älteren Triggerdaten verwerfen, um Platz zu sparen, aber es wäre durchaus interessant, nach einiger Zeit die Nutzung 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 ab, die es bei Bedarf auch gleich neu anlegt. Abbildung 9 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 9. 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

    01 package main
    02 import (
    03   "bufio"
    04   "database/sql"
    05   "os"
    06   "strconv"
    07   _ "modernc.org/sqlite"
    08 )
    09 func main() {
    10   const dbFile = "laundry.db"
    11   db, err := sql.Open("sqlite", dbFile)
    12   if err != nil {
    13     panic(err)
    14   }
    15   defer db.Close()
    16   _, err = db.Exec(`CREATE TABLE IF NOT EXISTS events (
    17     ts INTEGER PRIMARY KEY
    18   );`)
    19   if err != nil {
    20     panic(err)
    21   }
    22   scanner := bufio.NewScanner(os.Stdin)
    23   for scanner.Scan() {
    24     line := scanner.Text()
    25     ts, err := strconv.ParseInt(line, 10, 64)
    26     if err != nil {
    27       panic(err)
    28     }
    29     _, err = db.Exec(`INSERT OR IGNORE INTO events (ts) VALUES (?)`, ts)
    30     if err != nil {
    31       panic(err)
    32     }
    33   }
    34   if err := scanner.Err(); err != nil {
    35     panic(err)
    36   }
    37 }

Abbildung 9: Messdaten wandern in die SQLite-Datenbank

Schön bunt

Aber natürlich will kein Hausbewohner auf Zahlenkolonnen starren, und deswegen stellt Listing 4 die Bewegungs-Logs der vorherigen 24 Stunden wie in Abbildung 1 bunt als Web-Grafik dar. Die Wahl des Plotters fiel diesmal auf Google Charts, einer Javascript-Bibliothek, die ansprechende Graphen ins Browserfenster malt.

Dass nun Google Zugriff auf die Bewegungsdaten hat ist im vorliegenden Fall eher nicht relevant, falls es sich jedoch um Zugangsdaten zu meinem Geldspeicher ginge, wäre ich vorsichtiger.

Die Web-Grafik in Abbildung 1 besteht dabei aus zwei Teilen. Unten steht ein Graph, auf dessen X-Achse die Tageszeit während der vergangenen 24 Stunden verzeichnet ist. Als Y-Werte malt 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 malt es grün.

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

Diese für einen X/Y-Grafen etwas ungewöhnliche Darstellung setzt sich aus zwei überlappenden Einzelgraphen zusammen, wie Abbildung 10 zeigt.

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

    01 package main
    02 import (
    03   "bufio"
    04   "html/template"
    05   "os"
    06   "strconv"
    07 )
    08 type Point struct {
    09   X int64
    10   Y int
    11 }
    12 type tmplData struct {
    13   Events   []int64
    14   InUse    []Point
    15   NotInUse []Point
    16 }
    17 func main() {
    18   data := tmplData{
    19     Events:   []int64{},
    20     InUse:    []Point{},
    21     NotInUse: []Point{},
    22   }
    23   sc := bufio.NewScanner(os.Stdin)
    24   for sc.Scan() {
    25     txt := sc.Text()
    26     if sec, err := strconv.ParseInt(txt, 10, 64); err == nil {
    27       data.Events = append(data.Events, sec)
    28     }
    29   }
    30   data.NotInUse, data.InUse = windows(data.Events)
    31   t, err := template.ParseFiles("chart.html")
    32   if err != nil {
    33     panic(err)
    34   }
    35   err = t.Execute(os.Stdout, data)
    36   if err != nil {
    37     panic(err)
    38   }
    39 }

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

    01 package main
    02 import (
    03 	"time"
    04 )
    05 func windows(events []int64) ([]Point, []Point) {
    06 	finish := time.Now()
    07 	start := finish.Add(-24 * time.Hour)
    08 	busy := []Point{}
    09 	idle := []Point{}
    10 	base := start
    11 	events = append(events, finish.Unix())
    12 	for _, e := range events {
    13 		eTime := time.Unix(e, 0).In(time.Local)
    14 		block := mkBlock(base.Unix(), e)
    15 		if eTime.Sub(base) > 90*time.Minute {
    16 			idle = append(idle, block...)
    17 		} else {
    18 			busy = append(busy, block...)
    19 		}
    20 		base = eTime.Add(1 * time.Second)
    21 	}
    22 	return idle, busy
    23 }
    24 func mkBlock(from, to int64) []Point {
    25 	block := []Point{}
    26 	block = append(block,
    27 		Point{from, 0}, Point{from, 1},
    28 		Point{to, 1}, Point{to, 0})
    29 	return block
    30 }

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 hingegen hoch her im Waschsalon, wandert der neue, orange zu zeichnende Block ans Ende des Arrays busy.

Listing 6: chart.html

    01 <!DOCTYPE html>
    02 <html>
    03   <head>
    04     <title>Laundromat Availability</title>
    05     <script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
    06     <script type="text/javascript">
    07       google.charts.load('current', { packages: ['corechart'] });
    08       google.charts.setOnLoadCallback(drawChart);
    09       function drawChart() {
    10         const now = new Date();
    11         const past24 = new Date(now.getTime() - 24 * 60 * 60 * 1000);
    12         const idleData = new google.visualization.DataTable();
    13         idleData.addColumn('datetime', 'Time');
    14         idleData.addColumn('number', 'Idle');
    15         idleData.addRows([
    16         {{range .NotInUse}}
    17           [new Date({{.X}} * 1000), {{.Y}}],
    18         {{end}}
    19         ]);
    20         const busyData = new google.visualization.DataTable();
    21         busyData.addColumn('datetime', 'Time');
    22         busyData.addColumn('number', 'Busy');
    23         busyData.addRows([
    24         {{range .InUse}}
    25           [new Date({{.X}} * 1000), {{.Y}}],
    26         {{end}}
    27         ]);
    28         const eventData = new google.visualization.DataTable();
    29         eventData.addColumn('datetime', 'Time');
    30         eventData.addColumn('number', 'Event');
    31         {{range .Events}}
    32 	  eventData.addRows([[new Date({{.}} * 1000), 1]]);
    33 	{{end}}
    34         // --- Chart options ---
    35         const barOptions = {
    36           title: 'Laundromat Availability',
    37           backgroundColor: 'white',
    38           chartArea: { left: 60, top: 40, width: '80%', height: '70%' },
    39           hAxis: {
    40             title: 'Time (last 24 hours)',
    41             format: 'HH:mm',
    42             viewWindow: { min: past24, max: now },
    43             gridlines: { color: '#ddd' }
    44           },
    45           vAxis: {
    46 	    title: 'Availability',
    47             minValue: 0,
    48             maxValue: 1,
    49             gridlines: { color: 'transparent' }
    50           },
    51           series: {
    52             0: { type: 'area', color: 'orange', areaOpacity: 1.0, lineWidth: 0 },
    53             1: { type: 'area', color: 'lightgreen', areaOpacity: 1.0, lineWidth: 0 },
    54           }
    55         };
    56         const joined = google.visualization.data.join(
    57           busyData,
    58           idleData,
    59           'full',
    60           [[0, 0]],
    61           [1],
    62           [1]
    63         );
    64         const dotOptions = {
    65           title:'Events',
    66           legend:'none',
    67           chartArea:{left:60,top:20,width:'80%',height:'60%'},
    68           backgroundColor:'white',
    69           hAxis:{
    70             viewWindow:{min:past24,max:now},
    71           },
    72           series:{0:{color:'black',pointShape:'triangle',pointSize:8}},
    73         };
    74         const topChartDiv = document.getElementById('top_chart_div');
    75         const bottomChartDiv = document.getElementById('bottom_chart_div');
    76         const topChart = new google.visualization.ComboChart(topChartDiv);
    77         const bottomChart = new google.visualization.ComboChart(bottomChartDiv);
    78         topChart.draw(eventData, dotOptions);
    79         bottomChart.draw(joined, barOptions);
    80       }
    81     </script>
    82     <style>
    83       body { background: #fff; }
    84       #chart_div {
    85         border: 3px solid black;
    86         width: 800px;
    87         height: 600px;
    88         margin: 40px auto;
    89       }
    90     </style>
    91   </head>
    92   <body>
    93     <div id="graph_box">
    94       <div id="bottom_chart_div"></div>
    95       <div id="top_chart_div"></div>
    96     </div>
    97   </body>
    98 </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, wie Abbildung 10 zeigt: (x1, 0), (x1, 1), (x2, 1) und (x2, 0). Dabei stehen 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 diese 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 und CY> 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, beidesmal auf den Indexpositionen 1 in den respektiven Datensätzen. Puh, etwas umständlich, aber die die Google-Charts-Library gibt sich hier eigenwillig.

Der JavaScript-Code erhält die darzustellenden Messdaten dynamisch über Gos Template-Mechanismus. Listing 4 nutzt hierzu 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 den URL und haben ihn in ihre Bookmarksammlung aufgenommen. So muss hoffentlich niemand mehr unnötige Trips runter zum Waschsalon unternehmen.

Infos

[1]

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

[2]

"Stromzähler", Michael Schilli, Linux-Magazin 10/2025, https://www.linux-magazin.de/ausgaben/2025/10/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.