Bevor bei Sparfuchs Mike Schilli ein neues Gadget in den Haushalt kommt, überwacht er dessen Stromverbrauch mit einem Go-Programm und entscheidet mittels aufschlussreicher Graphen, ob es bleiben darf.
Strom ist teuer, selbst bei mir in Kalifornien kostet die Kilowattstunde um die 40 Cent. Verbraucht ein neues Gadget im Dauerlauf 20 Watt, summiert sich das auf 14 kWh im Monat oder fast 6 Euro. 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 Einsparpotenzial.
Ein Energiemonitor wie der TP-Link Tapo P110 (Abbildung 2) gibt Auskunft darüber, wie viel Strom gerade fließt, und hält die Messergebnisse für Clients per API bereit. Das kleine Gerät gibt auf Anfrage die momentan gezogene Leistung in Milliwatt aus, unterhält aber auch zwei Stromzähler, die den Verbrauch aller eingestöpselten Geräte messen. Kumuliert wird über den heutigen Tag oder den aktuellen Monat in Wattstunden.
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 3). Ein Blick in die Sektion Device Info offenbart die IP-Adresse, die das Gerät bei der Installation vom DHCP-Server zugewiesen bekam (Abbildung 4).
Verschlüsselte Kommunikation
Nun kann ein Client API-Requests an das Gerät schicken, die den Zählerstand des Energie-Monitors abfragen. Das verwendete Protokoll zieht einige Security-Register, wohl als Antwort auf die ursprünglich schludrige Implementierung 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ücksendet. Mit diesem wiederum darf der Client die aktuellen Messwerte anfordern, die ebenfalls mit dem Session-Key verschlüsselt zurückkommen.
Listing 1 zeigt die Implementierung des Monitor-Clients »tapo-emeter«, die ein Go-Paket von Github [1] zu Hilfe nimmt, um die Spezialkodierung für Anfragen sowie die Dekodierung der Antworten zu bewerkstelligen.
Die Abbildung 5 zeigt die Ausgabe des aus der Source kompilierten Binarys. 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 unter 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.
Listing 1
tapo-emeter.go
package main
import (
"fmt"
"encoding/json"
"github.com/fabiankachlock/tapo-api"
"github.com/fabiankachlock/tapo-api/pkg/api/request"
"github.com/mschilli/go-murmur"
)
func main() {
tapoIp := "192.168.8.205"
m := murmur.NewMurmur()
tapoEmail, _ := m.Lookup("tapo_user")
tapoPass, _ := m.Lookup("tapo_pass")
client := tapo.NewClient(tapoEmail, tapoPass)
device, err := client.P115(tapoIp)
if err != nil {
panic(err)
}
resp, err := device.GetEnergyUsage(request.GetEnergyDataParams{})
if err != nil {
panic(err)
}
txt, err := json.MarshalIndent(resp, "", " ")
if err != nil {
panic(err)
}
fmt.Println(string(txt))
}
Simple Elektrotechnik
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 6.
Multipliziert man den gezogenen Strom mit der anliegenden Spannung, kommen Watt heraus. Zieht ein Föhn zum Beispiel 5 Ampere bei 220 Volt, verbraucht er über 1000 Watt Leistung. Ist er eine Stunde lang gelaufen, 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öhn nach 20 Sekunden wieder ausschaltet, muss den E-Werken keine Kilowattstunde bezahlen. Statt des momentanen Werts ermittelt die Auswertung deshalb später einen über fünf Minuten gezogenen Mittelwert.
Schritte vor und zurück
Zum Glück führt der Energiemonitor nicht nur Buch über die gegenwärtig gezogene Leistung, sondern unterhält auch einen Stromzähler, ähnlich demjenigen des örtlichen Stromversorgers. Der erhöht einen stetig wachsenden internen Wert, der die bislang verbrauchten Wattstunden anzeigt (Abbildung 7). Zieht jemand den Stecker, fällt der Strom aus oder beginnt ein neuer Tag, springt der Tageszähler auf null zurück. Das gilt es später bei der Auswertung zu berücksichtigen. Hangelt sich der Code durch die Messwerte und will die in einem bestimmten Zeitraum verbrauchte Energie ermitteln, muss er prüfen, ob der aktuelle Zählerstand niedriger ist als der letzte gemessene. In dem Spezialfall ergibt sich der Verbrauch nicht aus der Differenz beider Messwerte, sondern ist über den Daumen gepeilt gleich dem aktuellen Zählerstand.
In einem Graph über die Zeit wäre es nun allerdings sinnvoll, nicht verbrauchte Wattstunden, sondern Watt anzuzeigen, und zwar gemittelt über ein Intervall von zum Beispiel einer Stunde. Das erlaubt sinnvolle Aussagen wie die, dass alle Gadgets in diesem Zeitraum so viel verbraucht haben wie ein Föhn bei Volllast.
Damit ein Graph den Stromverbrauch in Watt anzeigt, gilt es, den gemeldeten Zählerstand in Wattstunden erst von dem am letzten Ablesezeitpunkt ermittelten abzuziehen. Dieser Wert wird durch die seit dem letzten Ablesen verstrichene Zeit geteilt. Heraus kommt ein Mittelwert für die gezogene Leistung über das gemessene Zeitfenster. Summiert sich zum Beispiel der Verbrauch in fünf Minuten (300 Sekunden) laut Zählerstand auf zehn Wattstunden, ziehen alle angeschlossenen Geräte im Schnitt 10W x 3600s / 300s = 120 Watt.
Speichern für später
Als Speicher für historische Messdaten dient eine SQLite-Datenbank mit einer Tabelle, die pro Messung eine Reihe mit dem Datumsstempel und den ausgelesenen Monitorwerten für die während des aktuellen Tags akkumulierten Wattstunden enthält (»total_wh«, Abbildung 8). So kann die Auswertung später nicht nur die Differenz zum vorletzten gemessenen Zählerwert ermitteln, sondern auch aussagekräftige Graphen mit dem zeitlichen Verlauf des Stromverbrauchs im Haushalt liefern.
Listing 2 implementiert einen Wrapper um die Datenbankfunktionen. Mit »NewDB()« ab Zeile 10 legt das Hauptprogramm später eine neue SQLite-Datenbank an, falls sie noch nicht als Flatfile existiert, und definiert darin das Schema mit der Tabelle »samples«.
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 die Differenz zum vorherigen Wert und legt das Resultat in den Spalten »wh_delta« und »secs_since_last« (Anzahl der seitdem verstrichenen Sekunden) ab. Diese berechneten Werte sind eigentlich redundant, helfen später aber beim Zeichnen des Graphen.
Listing 2
db.go
package main
import (
"database/sql"
"time"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
db *sql.DB
}
func NewDB(dbPath string) *DB {
db, err := sql.Open("sqlite3", dbPath)
must(err)
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
total_wh INTEGER NOT NULL,
wh_delta INTEGER NOT NULL,
secs_since_last INTEGER NOT NULL
)`)
must(err)
return &DB{db: db}
}
func (s *DB) Add(dt time.Time, totalWH int64) error {
lastTotalWH, secsSinceLast := s.Prev(dt)
whDelta := int(totalWH) - lastTotalWH
if lastTotalWH == 0 || whDelta < 0 {
whDelta = 0
}
_, err := s.db.Exec(`
INSERT INTO samples (date, total_wh, wh_delta, secs_since_last)
VALUES (?, ?, ?, ?)`,
dt.Format("2006-01-02 15:04:05"), totalWH, whDelta, secsSinceLast)
return err
}
func must(err error) {
if err != nil {
panic(err)
}
}
func (s *DB) Prev(dt time.Time) (int, int) {
var lastTotalWH int
var lastDateStr string
err := s.db.QueryRow("SELECT total_wh, date FROM samples ORDER BY id DESC LIMIT 1").Scan(&lastTotalWH, &lastDateStr)
if err == sql.ErrNoRows {
return 0, 0
}
must(err)
lastDate, err := time.Parse("2006-01-02
15:04:05", lastDateStr)
must(err)
secs := int(dt.Sub(lastDate).Seconds())
if secs < 0 {
secs = 0
}
return lastTotalWH, secs
}
Kurz und gut
Um beim Abdruck des Quellcodes Zeilen zu sparen, definiert Listing 2 ab Zeile 36 die Funktion »must()«. Sie überprüft eine ihr übergebene Variable vom Typ »error« daraufhin, ob sie einen tatsächlich aufgetretenen Fehler repräsentiert oder mit »nil« den erfolgreichen Aufruf einer Funktion signalisiert. Go besteht auf einer expliziten 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 besser jedes Mal 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 dienen als Basiswert, den die Funktion vom frisch ausgelesenen Wert abzieht. Das Datum des vorherigen Eintrags ist bekannt, und »Add()« berechnet die seit damals verstrichenen Sekunden. Zusammen mit den frisch ausgelesenen Messwerten speist die Funktion die beiden Ergebnisse in die Spalten »wh_delta« und »secs_since_list« ein. So kann die Auswertung später ohne zusätzliche SQL-Abfragen zu jeder Tabellenzeile die durchschnittliche Leistungsaufnahme im gemessenen Zeitfenster berechnen.
Fällt der Strom aus oder zieht jemand den Stecker, springen die kumulierten 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 ebenfalls 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. Die Select-Query in Zeile 44 sortiert dazu die Treffer absteigend nach dem Datum und verwendet mit »LIMIT 1« nur den letzten. 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 Zeittyp »time.Time« um. Die Zeitrechnung im Hauptprogramm erfolgt in der UTC-Zone, sodass »time.Parse()« keine Angabe einer Location erfordert.
Das Hauptprogramm in Listing 3 nimmt die Ausgabe von Listing 1 auf Stdin entgegen, sucht im JSON-Salat nach dem relevanten Messwert und ruft in Zeile 13 »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 sie in die Datenbank einspeist.
Listing 3
stasher.go
package main
import (
"github.com/tidwall/gjson"
"io"
"os"
"time"
)
func main() {
data, err := io.ReadAll(os.Stdin)
must(err)
wh := gjson.GetBytes(data, "today_energy")
db := NewDB("stasher.db")
db.Add(time.Now().UTC(), wh.Int())
}
Daten aufbrezeln
Nun geht es an die Auswertung und Darstellung der historischen Messdaten. Listing 4 findet heraus, wie viel Strom stündlich durch den Zähler rauscht. Das Ergebnis in Abbildung 9 zeigt, dass die mittlere Leistungsaufnahme zwischen 120 Watt und 180 Watt schwankt. Tagsüber dümpelt sie zwischen 120 und 140 Watt herum, die sich primär der Hauptrechner der Perlmeister-Studios im Dauerbetrieb genehmigt. Hinzu kommt bei Bedarf ein Flachbildschirm. Abends gehen mehrere Lampen an, und der Verbrauch schnellt für ein paar Stunden auf 180 Watt hoch. Dann ruft das Bett, und der Nachtwächter löscht beim letzten Rundgang im Arbeitszimmer das Licht.
Listing 4
hourly.go
package main
import (
"database/sql"
"fmt"
"time"
_ "github.com/mattn/go-sqlite3"
)
type HourlyStats struct {
Sum float64
Count int
}
func main() {
db, err := sql.Open("sqlite3", "stasher.db")
must(err)
defer db.Close()
loc, err := time.LoadLocation("America/Los_Angeles")
must(err)
rows, err := db.Query("SELECT date, wh_delta, secs_since_last FROM samples")
must(err)
defer rows.Close()
var hourly [24]HourlyStats
for rows.Next() {
var dateStr string
var whDelta, secs int
err := rows.Scan(&dateStr, &whDelta, &secs)
must(err)
if secs == 0 {
continue
}
t, err := time.Parse("2006-01-02 15:04:05", dateStr)
must(err)
hour := t.In(loc).Hour()
value := float64(whDelta) * 3600 / float64(secs)
hourly[hour].Sum += value
hourly[hour].Count++
}
for hour := 0; hour < 24; hour++ {
avg := 0.0
if hourly[hour].Count > 0 {
avg = hourly[hour].Sum / float64(hourly[hour].Count)
}
fmt.Printf("%02d,%f\n", hour, avg)
}
}
func must(err error) {
if err != nil {
panic(err)
}
}
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. »In()« in Zeile 32 katapultiert ihn wieder in die in Zeile 16 definierte Pacific-Zeitzone 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 der Dauer des Zeitfensters in Sekunden.
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.
Darstellung
Von dort bezieht sie später das Binary Csv2bar und stellt sie wie in Abbildung 9 gezeigt dar. Dazu nimmt Listing 5 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 9 in der angegebenen Datei erzeugt.
Listing 5
csv2bar.go
package main
import (
"encoding/csv"
"flag"
"github.com/wcharczuk/go-chart/v2"
"io"
"os"
"strconv"
)
func main() {
title := flag.String("title", "", "Chart Title")
flag.Parse()
maxY := float64(0)
bars := []chart.Value{}
r := csv.NewReader(os.Stdin)
for {
record, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
panic(err)
}
val, err := strconv.ParseFloat(record[1], 64)
if err != nil {
panic(err)
}
if val > maxY {
maxY = val
}
bars = append(bars, chart.Value{
Label: record[0],
Value: val,
})
}
graph := chart.BarChart{
Title: *title,
Height: 512,
BarWidth: 18,
Bars: bars,
YAxis: chart.YAxis{
Range: &chart.ContinuousRange{
Min: 0,
Max: maxY,
},
},
}
err := graph.Render(chart.PNG, os.Stdout)
if err != nil {
panic(err)
}
}
Das Paket go-chart weist allerdings die unangenehme Angewohnheit auf, die Balken im Normalfall nicht in voller Länge zu zeichnen, sondern erst vom niedrigsten X-Wert an. Das Diagramm soll jedoch auch die gezogene Grundlast anzeigen. Wer nun meint, es genügt, dazu »Min« im Konstrukt »YAxis« auf null zu setzen, der irrt sich: go-chart ignoriert das, bis auch »Max« in Zeile 44 gesetzt ist. Allerdings muss der Code dazu erst einmal den Maximalwert für Y ermitteln, was er durch die mitgeführte Variable »maxY« bewerkstelligt.
Fix und fertig
Die übliche Sequenz aus den Befehlen »go mod init hourly« und »go mod tidy« holt alle abhängigen Pakete von Github ab. Ein folgendes »go build« kompiliert und linkt das Binary »hourly«. Dasselbe gilt für alle anderen hier vorgestellten eigenständigen Go-Programme.
Das ist jedoch erst der Anfang, wie immer sind dem kundigen Programmierer keine Grenzen gesetzt. Abbildung 10 zeigt ein weiteres Beispiel, das ermittelt, wie hoch der Stromverbrauch im Schnitt pro Wochentag liegt. Die Ausgabe erfolgte wie gehabt im CSV-Format, und Csv2bar verarbeitet das Ganze zu einem aufschlussreichen Diagramm. (uba)
Infos
- Tapo-API-Package auf Github: https://github.com/fabiankachlock/tapo-api
















