Strom ist teuer, selbst bei mir in Kalifornien kostet die Kilowattstunde um die 40 Cents. Verbraucht ein neues Gadget in Dauerlauf 20 Watt, summiert sich das auf 14 kWH im Monat, oder fast 6 Euro. Ein Mann wie ich muss mit dem Kreuzer rechnen!
Außerdem richtet sich der Strompreis bei meinem Energieversorger Pacific Gas & Electric nach der Uhrzeit (Abbildung 1), da eröffnet eine zeitliche Aufschlüsselung des Verbrauchs bestimmter Geräte womöglich Einsparpotential.
![]() |
Abbildung 1: Mail vom Stromversorger |
Ein Energiemonitor wie der TP-Link Tapo P110 (Abbildung 2) gibt Auskunft darüber, wieviel Strom gerade fließt und hält die Messergebnisse für Clients per API bereit. Das kleine Gerät gibt auf Anfrage die aktuell gezogene Leistung in Milli-Watt aus, unterhält aber auch zwei stetig wachsende Stromzähler, die den Verbrauch aller eingestöpselten Geräte messen. Aufkumuliert wird über den heutigen Tag oder den aktuellen Monat in Wattstunden.
![]() |
Abbildung 2: Der Tapo Energy-Monitor P110 |
![]() |
Abbildung 3: Tapo Energy-Monitor auf Amazon |
![]() |
Abbildung 4: Der Tapo-Energiemonitor P110 |
Zwar sind die TP-Link-Stöpsel hauptsächlich für die proprietäre TP-Cloud ausgelegt, aber mit etwas Handarbeit lassen sich die Energie-Messwerte auch lokal über die API des Geräts auf dem WLAN abgreifen. Dazu ist das Gerät zunächst wie gewohnt über die Tapo-App in der Cloud zu installieren (Abbildung 5). Ein Blick in die Sektion "Device Info" offenbart die IP-Addresse, die das Gerät bei der Installation vom DHCP-Server zugewiesen bekam (Abbildung 6).
Nun kann ein Client API-Requests an das Gerät schicken, die den Zählerstand des Energie-Monitors abfragen. Das verwendete Protokoll mit zieht einige Security-Register, wohl auf Antwort auf die ursprünglich schluderhafte Implementiertung der unter dem Label "Kasa" vertriebenen Vorgängerversion. So muss der Client dem auf dem Stecker laufenden Webserver zunächst einen Public Key schicken, mit dem der Server einen geheimen Session-Key verschlüsselt und zurückschickt. Mit diesem wiederum darf der Client die aktuellen Messwerte anfordern, die ebenfalls mit dem Session-Key verschlüsselt zurückkommen.
![]() |
Abbildung 5: Der Tapo-Stecker wurde mit der tplink-App aktiviert und eingebunden. |
![]() |
Abbildung 6: Die IP-Adresse des Steckers steht unter "Device Info" in der App. |
Listing 1 zeigt die Implementierung des Monitor-Clients tapo-emeter
, die ein Go-Paket von Github ([2]) zu Hilfe nimmt, um die Spezial-Kodierung für Anfragen sowie die Dekodierung der Anworten zu bewerkstelligen.
Abbildung 7 zeigt die Ausgabe des aus der Source kompilierten Binaries. Auf Anfrage an die IP-Adresse des Geräts kommt eine JSON-Antwort zurück, in der unter dem Schlüssel today_energy
die heute schon verbrauchte Energie in Wattstunden steht.
Für die Abfrage benötigt der Client auch den Usernamen und das Passwort des TP-Link-Accounts, unter dem der Stecker vorher registriert wurde. Damit diese nicht hartkodiert im Code stehen müssen, holt Listing 1 sie in der Datei .murmur
im Home-Verzeichnis ab, in der sie under den Schlüsseln tapo_user
und tapo_pass
im Yaml-Format stehen.
Zurück kommt vom verwendeten SDK eine Go-Map, die Listing 1 als formatiertes Json auf Stdout ausgibt.
![]() |
Abbildung 7: JSON-Antwort des Tapo-Steckers |
01 package main 02 import ( 03 "fmt" 04 "encoding/json" 05 "github.com/fabiankachlock/tapo-api" 06 "github.com/fabiankachlock/tapo-api/pkg/api/request" 07 "github.com/mschilli/go-murmur" 08 ) 09 func main() { 10 tapoIp := "192.168.8.205" 11 m := murmur.NewMurmur() 12 tapoEmail, _ := m.Lookup("tapo_user") 13 tapoPass, _ := m.Lookup("tapo_pass") 14 client := tapo.NewClient(tapoEmail, tapoPass) 15 device, err := client.P115(tapoIp) 16 if err != nil { 17 panic(err) 18 } 19 resp, err := device.GetEnergyUsage(request.GetEnergyDataParams{}) 20 if err != nil { 21 panic(err) 22 } 23 txt, err := json.MarshalIndent(resp, "", " ") 24 if err != nil { 25 panic(err) 26 } 27 fmt.Println(string(txt)) 28 }
Führt nun ein Client in kurzen Abständen regelmäßige Abfragen durch, zeigt sich eine zeitliche Verteilung der vom Stecker gezogenen Leistung nach Abbildung (#4).
![]() |
Abbildung 8: Gemessene Leistung durch den Stecker über die Zeit |
Multipliziert man gezogenen Strom mit der anliegenden Spannung, kommen Watt heraus. Zieht ein Fön zum Beispiel 5 Ampere bei 220V, verbraucht er knapp über 1000 Watt Leistung. Läuft er eine Stunde lang, zeigt der Zähler eine verbrauchte Kilowattstunde mehr an.
Allerdings bezieht sich dieser Wert auf die unmittelbar während der Messung gezogenen Leistung. Wer den Fön nach 20 Sekunden wieder abstellt, muss den E-Werken keine Kilowattstunde bezahlen. Statt des Momentanwerts ermittelt die Auswertung deshalb später einen über fünf Minuten gezogenen Mittelwert.
Zum Glück führt der Energiemonitor nicht nur Buch über die gegenwärtig gezogene Leistung, sondern unterhält auch einen Stromzähler ähnlich der der örtlichen Stromversorger. Dieser erhöht einen stetig wachsenden internen Wert, der die bislang verbrauchten Wattstunden anzeigt (Abbildung 9). Zieht jemand den Stecker, fällt der Strom aus, oder ein neuer Tag beginnt, springt der Tageszähler auf Null zurück. Dies gilt es später bei der Auswertung zu berücksichtigen. Hangelt sich der Code durch die Messwerte und will die in einem Zeitfenster verbrauchte Energie ermitteln, muss er prüfen, ob der aktuelle Zählerstand niedriger ist als der letzte gemessene. In diesem Spezialfall ergibt sich der Verbrauch nicht aus der Differenz beider Messwerte, sondern ist über den Daumen gepeilt etwa gleich dem aktuellen Zählerstand.
![]() |
Abbildung 9: Zählerstand über die Zeit, mit einem Reset in der Mitte |
In einem Graph über die Zeit wäre es nun allerdings sinnvoll, nicht verbrauchte Wattstunden, sondern Watt anzuzeigen, und zwar gemittelt über ein Zeitintervall von zum Beispiel einer Stunde. Das erlaubt sinnvolle Aussagen wie "alle meine Gadgets haben in diesem Zeitraum soviel verbraucht wie ein Fön im Vollauf".
Damit ein Graph den Stromverbrauch in Watt anzeigen kann, muss der gemeldete Zählerstand in Wattstunden erst vom dem am letzten Ablesezeitpunkt ermittelten abgezogen werden. Dieser Wert wird nun durch die seit dem letzten Ablesen verstrichene Zeit geteilt. Heraus kommt ein Mittelwert für die gezogene Leistung über das gemessene Zeitfenster. Wurden zum Beispiel in fünf Minuten laut Zählerstand zehn Wattstunden verbraucht, ziehen alle angeschlossenen Geräte im Schnitt 10 * 3600 / 300 = 120 Watt.
Als Speicher für historische Messdaten soll eine SQLite-Datenbank dienen, mit einer Tabelle, die pro Messung eine Reihe mit dem Datumsstempel und den ausgelesenen Monitorwerten für die während des aktuellen Tages akkumulierten Wattstunden enthält (total_wh
, Abbildung 10). So kann die Auswertung später nicht nur die Differenz zum vorletzten gemessenen Zählerwert ermitteln, sondern auch aussagekräftige Graphen zum zeitlichen Verlauf des Stromverbrauchs im Haushalt zeichnen.
![]() |
Abbildung 10: Messdaten in der Datenbank |
Listing 2 implementiert einen Wrapper um die Datenbankfunktionen. Mit NewDB()
ab Zeile 10 legt das Hauptprogramm später eine neue SQLite-Datenbank an, falls diese noch nicht als Flatfile existiert und definiert das Schema mit der Tabelle samples
darin.
01 package main 02 import ( 03 "database/sql" 04 "time" 05 _ "github.com/mattn/go-sqlite3" 06 ) 07 type DB struct { 08 db *sql.DB 09 } 10 func NewDB(dbPath string) *DB { 11 db, err := sql.Open("sqlite3", dbPath) 12 must(err) 13 _, err = db.Exec(` 14 CREATE TABLE IF NOT EXISTS samples ( 15 id INTEGER PRIMARY KEY AUTOINCREMENT, 16 date TEXT NOT NULL, 17 total_wh INTEGER NOT NULL, 18 wh_delta INTEGER NOT NULL, 19 secs_since_last INTEGER NOT NULL 20 )`) 21 must(err) 22 return &DB{db: db} 23 } 24 func (s *DB) Add(dt time.Time, totalWH int64) error { 25 lastTotalWH, secsSinceLast := s.Prev(dt) 26 whDelta := int(totalWH) - lastTotalWH 27 if lastTotalWH == 0 || whDelta < 0 { 28 whDelta = 0 29 } 30 _, err := s.db.Exec(` 31 INSERT INTO samples (date, total_wh, wh_delta, secs_since_last) 32 VALUES (?, ?, ?, ?)`, 33 dt.Format("2006-01-02 15:04:05"), totalWH, whDelta, secsSinceLast) 34 return err 35 } 36 func must(err error) { 37 if err != nil { 38 panic(err) 39 } 40 } 41 func (s *DB) Prev(dt time.Time) (int, int) { 42 var lastTotalWH int 43 var lastDateStr string 44 err := s.db.QueryRow("SELECT total_wh, date FROM samples ORDER BY id DESC LIMIT 1").Scan(&lastTotalWH, &lastDateStr) 45 if err == sql.ErrNoRows { 46 return 0, 0 47 } 48 must(err) 49 lastDate, err := time.Parse("2006-01-02 15:04:05", lastDateStr) 50 must(err) 51 secs := int(dt.Sub(lastDate).Seconds()) 52 if secs < 0 { 53 secs = 0 54 } 55 return lastTotalWH, secs 56 }
Neben dem Zeitstempel date
als String (SQLite hat keinen expliziten Datumstyp) wandert so auch der Messwert als Integer pro Zeile in die Datenbank. Des weiteren ermittelt der Code gleich die Differenz zum vorherigen Wert und legt den Wert in der Spalte wh_delta
ab, sowie in secs_since_last
, die Anzahl der verstrichenen Sekunden seitdem. Diese berechneten Werte sind eigentlich redundant, aber helfen später beim Zeichnen des Graphen.
Um Zeilen beim Abdruck des Source-Codes zu sparen, definiert Listing 2 ab Zeile 32 die Funktion must
, die eine ihr übergebene Variable vom Typ error
daraufhin überprüft, ob sie einen tatsächlich aufgetretenen Fehler repräsentiert oder auf nil
steht, also den erfolgreichen Aufruf einer Funktion signalisiert. Go besteht auf expliziter Prüfung von Return-Werten aufgerufener Funktionen. Krücken wie must()
durchkreuzen die Pläne der Sprachdesigner, in aktiv gewartetem Code sollte die Fehlerprüfung tatsächlich jedesmal explizit in einer if
-Bedingung stehen, damit später das Wartungspersonal beim Blick auf den Code intuitiv den Ablauf begreift.
Die Funktion Add()
ab Zeile 24 nimmt die vom Monitor abgelesenen Werte entgegen und sucht in der Datenbank den vorherigen eingespeisten Eintrag. Die dort stehenden Wattstunden nimmt es als Basiswert, den es vom frisch ausgelesenen Wert abzieht. Das Datum des vorherigen Eintrags ist bekannt, und die Funktion berechnet die seit damals verstrichenen Sekunden. Zusammen mit den frisch ausgelesenen Messwerten speist es diese beiden Ergebnisse in die Spalten wh_delta
und secs_since_list
ein. Und mit der oben genannten Formel kann die Auswertung später zu jeder Tabellenzeile die durchschnittliche Leitstungsaufnahme im gemessenen Zeitfenster ausrechnen, ohne weitere SQL-Abfragen.
Fällt der Strom aus oder jemand zieht den Stecker, springen die aufkumulierten Wattstunden wie oben gesehen wieder auf Null zurück, und der abgefragte Wert ist plötzlich kleiner als der in der Datenbank abgelegte. Zeile 27 prüft diesen Fall und setzt die Wattstundendifferenz auf Null, damit sie der Graph später einfach ignorieren kann.
Der zuletzt eingespeiste Messwert für die Wattstunden und die seitdem verstrichenen Sekunden ermittelt Prev()
ab Zeile 41. Der Select-Query in Zeile 44 sortiert dazu die Treffer absteigend nach dem Datum und nimmt mit LIMIT 1
nur den allerletzten. Für die Datumsarithmetik zur Ermittlung der Zeitdifferenz zur aktuellen Uhrzeit in Sekunden wandelt time.Parse()
in Zeile 49 den als String vorliegenden Datumsstempel wieder in den Go-internen Zeit-Typ time.Time
um. Die Zeitrechnung im Hauptprogramm wird in der UTC-Zone erfolgen, also braucht time.Parse()
keine Angabe einer Location.
01 package main 02 import ( 03 "github.com/tidwall/gjson" 04 "io" 05 "os" 06 "time" 07 ) 08 func main() { 09 data, err := io.ReadAll(os.Stdin) 10 must(err) 11 wh := gjson.GetBytes(data, "today_energy") 12 db := NewDB("stasher.db") 13 db.Add(time.Now().UTC(), wh.Int()) 14 }
Das Hauptprogramm in Listing 3 nimmt die Ausgabe von Listing 1 auf Stdin entgegen, sucht im Json-Salat nach den dem interessierenden Messwert und ruft db.Add()
mit dem Zeitstempel der aktuellen Uhrzeit in der UTC-Zone auf.
Ein Cronjob stößt später alle fünf Minuten die Pipeline ./tapo-emeter | ./stasher
an, die die beiden aus Listing 2 und Listing 3 generierten Go-Binaries aufruft, die aktuellen Messwerte abruft und in die Datenbank einspeist.
Nun zur Auswertung und Darstellung der historischen Messdaten. Listing 4 findet heraus, wieviel Strom stündlich durch den Zähler rauscht.
![]() |
Abbildung 11: So viele Watt fließen im Schnitt pro Stunde in Onkel Mike's Hütte |
Das Ergebnis in Abbildung 11 zeigt, dass die mittlere Leistungsaufnahme zwischen 120W und 180W schwankt. Tagsüber dümpelt sie zwischen 120 und 140 Watt herum, gezollt dem Hauptrechner der Perlmeister-Studios im Dauerbetrieb, hinzukommt bei Bedarf ein Flachbildschirm. Abends gehen mehrere Lampen an, und der Verbrauch schnellt auf 180 Watt hoch für ein paar Stunden, bevor das Bett ruft und der Nachtwächter beim letzten Rundgang im Arbeitszimmer das Licht löscht.
01 package main 02 import ( 03 "database/sql" 04 "fmt" 05 "time" 06 _ "github.com/mattn/go-sqlite3" 07 ) 08 type HourlyStats struct { 09 Sum float64 10 Count int 11 } 12 func main() { 13 db, err := sql.Open("sqlite3", "stasher.db") 14 must(err) 15 defer db.Close() 16 loc, err := time.LoadLocation("America/Los_Angeles") 17 must(err) 18 rows, err := db.Query("SELECT date, wh_delta, secs_since_last FROM samples") 19 must(err) 20 defer rows.Close() 21 var hourly [24]HourlyStats 22 for rows.Next() { 23 var dateStr string 24 var whDelta, secs int 25 err := rows.Scan(&dateStr, &whDelta, &secs) 26 must(err) 27 if secs == 0 { 28 continue 29 } 30 t, err := time.Parse("2006-01-02 15:04:05", dateStr) 31 must(err) 32 hour := t.In(loc).Hour() 33 value := float64(whDelta) * 3600 / float64(secs) 34 hourly[hour].Sum += value 35 hourly[hour].Count++ 36 } 37 for hour := 0; hour < 24; hour++ { 38 avg := 0.0 39 if hourly[hour].Count > 0 { 40 avg = hourly[hour].Sum / float64(hourly[hour].Count) 41 } 42 fmt.Printf("%02d,%f\n", hour, avg) 43 } 44 } 45 func must(err error) { 46 if err != nil { 47 panic(err) 48 } 49 }
Listing 4 arbeitet sich durch alle bislang in der Datenbank erfassten Messwerte und holt sich jeweils die vorher errechnete Zählerdifferenz aus Wattstunden, sowie die Anzahl der im Zeitfenster verstrichenen Sekunden. Um aus dem Zeitstempel einer Messung die Stunde der Uhrzeit zu extrahieren, wandelt Parse()
das Messdatum in einen time.Time
-Typ um. Allerdings ist der in der UTC-Zeitzone angesiedelt und In()
in Zeile 32 katapultiert ihn wieder in die in Zeile 16 definierte Pacific-Zeitzone in meiner Wahlheimat Kalifornien zurück. Aus diesem Wert fieselt wiederum Hour()
die Stunde heraus. Den Watt-Wert ermittelt die oben erläuterte Formel in Zeile 33 aus whDelta()
und den Zeitfenstersekunden.
Pro Stunde steht im Array-Slice hourly
ein Eimer mit der Zwischensumme Sum
und einem stetig wachsenden Zähler bereit. Am Ende der Auswertung iteriert die For-Schleife ab Zeile 37 über alle Tagesstunden von 0 bis 23 und generiert den Mittelwert mit einer Division der Zwischensumme durch den Zähler. Die Print-Anweisung in Zeile 42 gibt die Ergebnisse zeilenweise im CSV-Format auf Stdout
aus, wo sie später das Binary csv2bar
, das aus Listing 5 generiert wurde, aufschnappt und als Bar-Chart nach Abbildung 11 darstellt.
01 package main 02 import ( 03 "encoding/csv" 04 "flag" 05 "github.com/wcharczuk/go-chart/v2" 06 "io" 07 "os" 08 "strconv" 09 ) 10 func main() { 11 title := flag.String("title", "", "Chart Title") 12 flag.Parse() 13 maxY := float64(0) 14 bars := []chart.Value{} 15 r := csv.NewReader(os.Stdin) 16 for { 17 record, err := r.Read() 18 if err == io.EOF { 19 break 20 } 21 if err != nil { 22 panic(err) 23 } 24 val, err := strconv.ParseFloat(record[1], 64) 25 if err != nil { 26 panic(err) 27 } 28 if val > maxY { 29 maxY = val 30 } 31 bars = append(bars, chart.Value{ 32 Label: record[0], 33 Value: val, 34 }) 35 } 36 graph := chart.BarChart{ 37 Title: *title, 38 Height: 512, 39 BarWidth: 18, 40 Bars: bars, 41 YAxis: chart.YAxis{ 42 Range: &chart.ContinuousRange{ 43 Min: 0, 44 Max: maxY, 45 }, 46 }, 47 } 48 err := graph.Render(chart.PNG, os.Stdout) 49 if err != nil { 50 panic(err) 51 } 52 }
Listing 5 nimmt auf der Kommandozeile mit der Option --title
die Überschrift der zu generierenden PNG-Datei entgegen. Die Datenpaare kommen jeweils mit einem Integer-Wert für die Tagesstunde und einem Watt-Wert als Fließkommazahl im CSV-Format daher.
Die For-Schleife ab Zeile 16 hangelt sich durch alle Einträge, erzeugt daraus die Datenstruktur bars
und überreicht sie der Struktur chart.BarChart
aus dem Go-Paket go-chart
. Die Funktion Render()
macht daraus ein farbschönes Diagramm, das Listing 5 auf Stdout
ausgibt, sodass ./hourly >hourly.png
das Schaubild aus Abbildung 11 in der angegebenen Datei erzeugt.
Das Paket go-chart
hat allerdings die unangenehme Angewohnheit, die Balken im Normalfall nicht in voller Länge zu zeichnen, sondern erst vom niedrigsten X-Wert an. Das Diagramm soll hingegen auch die gezogene Grundlast anzeigen. Wer nun meint, es genüge, Min
im Konstrukt YAxis
auf Null zu setzen, wird eines besseren belehrt, denn go-chart
ignoriert dies, bis auch Max
in Zeile 44 gesetzt wird. Allerdings muss der Code hierzu erstmal den Maximalwert für Y wissen, was er durch die mitgeführte Variable maxY
bewerkstelligt.
Die übliche Sequenz go mod init hourly; go mod tidy
holt alle abhängigen Pakete von Github ab und go build
compiliert und linkt das Binary hourly
zusammen. Gleiches gilt für alle anderen in dieser Ausgabe vorgestellten eigenständigen Go-Programme.
![]() |
Abbildung 12: Wieviel Strom wird pro Wochentag verbraucht |
Aber das ist natürlich erst der Anfang, wie immer sind dem kundigen Programmierer keine Grenzen gesetzt! Abbildung 12 zeigt ein weiteres Beispiel, bei dem ausgerechnet wurde, wieviel Strom im Schnitt pro Wochentag verbraucht wurde. Die Ausgabe erfolgte wie oben im CSV-Format, und csv2bar
machte daraus das aufschlussreiche Diagramm.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2025/10/snapshot/
"Tapo-Api"-Package auf Github: https://github.com/fabiankachlock/tapo-api
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc