31.12.2025
Przed dodaniem nowego gadżetu do domu Mike Schilli zawsze monitoruje jego zużycie energii za pomocą programu Go i tworzy intuicyjne wykresy, aby zdecydować, czy może pozostać.
Przed dodaniem nowego gadżetu do domu Mike Schilli zawsze monitoruje jego zużycie energii za pomocą programu Go i tworzy intuicyjne wykresy, aby zdecydować, czy może pozostać.
Energia elektryczna jest droga. Mieszkam w Kalifornii, gdzie kilowatogodzina kosztuje około 40 centów, czyli około półtora złotego. Jeśli nowy gadżet zużywa w trybie ciągłym 20 W, wiąże się to ze zużyciem 14 kWh miesięcznie, czyli stratą prawie 6 dol./22 złotych. Ponadto cena energii elektrycznej z mojego zakładu energetycznego, Pacific Gas & Electric, zależy od pory dnia (rysunek 1). Podział zużycia energii w zależności od czasu dla wybranych urządzeń może dostarczyć pomysłów na potencjalne oszczędności.
Monitor energii taki jak TP-Link Tapo P110M (rysunek 2) na bieżąco informuje o ilości zużywanej energii; posiada interfejs API, który udostępnia wyniki pomiarów zainteresowanym klientom przez WiFi. Na żądanie to małe urządzenie zgłasza moc aktualnie pobieraną w miliwatach przez wszystkie podłączone do niego odbiorniki. Co więcej, posiada również dwa trwałe liczniki, które mierzą skumulowane watogodziny, jeden dla bieżącego dnia i jeden dla miesiąca.
Chociaż urządzenia TP-Link są przeznaczone głównie do zastrzeżonej chmury TP, odczyty energii można również uzyskać lokalnie przez Wi-Fi za pomocą interfejsu API Tapo przy odrobinie ręcznej pracy. Aby to zrobić, Tapo należy najpierw zainstalować w chmurze w zwykły sposób za pomocą aplikacji Tapo (rysunek 3). Szybkie spojrzenie na sekcję Informacje o urządzeniu ujawnia adres IP, który serwer DHCP przypisał Tapo w czasie instalacji (rysunek 4).
Rysunek 1: Wykres zużycia energii z Pacific Gas & Electric.
Rysunek 2: Monitor energii TP-Link Tapo P110M. © TP-Link
Rysunek 3: Tapo P110M, włączony w aplikacji TP-Link, pokazujący aktualną moc.
Rysunek 4: Adres IP gniazda jest wymieniony w Device Info w aplikacji.
Szyfrowana komunikacja
Kiedy klient wysyła żądania API do urządzenia, aby odczytać aktualną wartość licznika, używa protokołu Tapo, który ma kilka zabezpieczeń, prawdopodobnie w odpowiedzi na to, co pierwotnie było dość niechlujną implementacją w poprzedniej wersji sprzedawanej pod marką Kasa. Na przykład, klient musi najpierw wysłać klucz publiczny do serwera WWW działającego na liczniku, którego serwer używa do zaszyfrowania i odesłania tajnego klucza sesji. Klient może następnie użyć tego do zażądania bieżących odczytów licznika, które są również zwracane w formacie zaszyfrowanym kluczem sesji. Listing 1 pokazuje implementację klienta monitora tapo-emeter, który opiera się na pakiecie Go z GitHub [1] w celu obsługi specjalnego kodowania żądań i dekodowania odpowiedzi.
Listing 1: tapo-emeter.go
01 package main
02
03 import (
04 "encoding/json"
05 "fmt"
06 "github.com/fabiankachlock/tapo-api"
07 "github.com/fabiankachlock/tapo-api/pkg/api/request"
08 "github.com/mschilli/go-murmur"
09 )
10
11 func main() {
12 tapoIp := "192.168.87.38"
13
14 m := murmur.NewMurmur()
15 tapoEmail, _ := m.Lookup("tapo_user")
16 tapoPass, _ := m.Lookup("tapo_pass")
17
18 client := tapo.NewClient(tapoEmail, tapoPass)
19 device, err := client.P115(tapoIp)
20 if err != nil {
21 panic(err)
22 }
23
24 resp, err := device.GetEnergyUsage(request.GetEnergyDataParams{})
25 if err != nil {
26 panic(err)
27 }
28
29 txt, err := json.MarshalIndent(resp, "", " ")
30 if err != nil {
31 panic(err)
32 }
33
34 fmt.Println(string(txt))
35 }
Rysunek 5 przedstawia dane wyjściowe z pliku binarnego po kompilacji z kodu źródłowego. Żądanie wysłane na adres IP urządzenia zwraca odpowiedź JSON zawierającą informację o zużytej do tej pory dzisiaj energii w watogodzinach pod nagłówkiem today_energy.
Rysunek 5: Skompilowany plik binarny pokazuje odpowiedź JSON wtyczki.
Aby obsłużyć zapytanie, klient wymaga również nazwy użytkownika i hasła konta TP-Link, pod którym gniazdo zostało wcześniej zarejestrowane w aplikacji. Aby uniknąć kodowania na sztywno w kodzie, Listing 1 pobiera szczegóły z pliku .murmur w katalogu domowym użytkownika; szczegóły są tutaj przechowywane w formacie YAML jako tapo_user i tapo_pass. Pakiet go-murmur z GitHuba ułatwia importowanie ich do kodu. Zestaw SDK tapo-api, również z GitHuba, później tworzy żądanie i zwraca odpowiedź wtyczki w postaci mapy Go, którą Listing 1 wyprowadza na stdout jako sformatowany JSON.
Prosta fizyka
Jeśli klient wykonuje teraz regularne zapytania w krótkich odstępach czasu, czasowy rozkład aktualnej wartości mocy mierzonej przez wtyczkę wygląda podobnie jak na rysunuk 6.
Rysunek 6: Moc mierzona w gnieździe w czasie.
Jeśli pomnożymy pobierany prąd elektryczny przez dostarczane napięcie, otrzymamy wartość mocy w watach. Na przykład jeśli suszarka do włosów pobiera 10 A przy napięciu 110 V, zużywa ponad 1000 W mocy. Jeśli działała przez 1 h, miernik pokaże o 1 kWh więcej.
Wartość ta odnosi się jednak tylko do mocy pobranej bezpośrednio podczas pomiaru. Jeśli wyłączysz suszarkę ponownie po 20 s, nie będziesz musiał płacić zakładowi energetycznemu za kilowatogodzinę. Aby stworzyć intuicyjną jednostkę miary dla wykresów, kod oblicza później średnie zużycie energii w ciągu 5 min.
Dwa kroki do przodu, jeden do tyłu
Na szczęście monitor energii nie tylko rejestruje aktualnie pobieraną moc, ale ma również licznik energii elektrycznej podobny do tych używanych przez lokalne przedsiębiorstwo użyteczności publicznej. Zwiększa on stale rosnącą wartość wewnętrzną, aby pokazać zużyte do tej pory watogodziny (rysunek 7). Jeśli ktoś wyciągnie wtyczkę, nastąpi przerwa w dostawie prądu lub rozpocznie się nowy dzień, dzienny licznik się wyzeruje. Należy to wziąć pod uwagę podczas późniejszej oceny danych. Jeśli kod tasuje zmierzone wartości, próbując określić energię zużytą w danym okresie, musi sprawdzić, czy bieżący odczyt licznika jest niższy niż ostatni odczyt. W tym szczególnym przypadku różnicowe zużycie energii nie jest różnicą między dwoma odczytami; zamiast tego jest w przybliżeniu równe bieżącemu odczytowi licznika.
Rysunek 7: Odczyt licznika w czasie, z resetem w środku.
Jednak na wykresie w czasie sensowne byłoby wyświetlanie zużycia w watach uśrednionych w przedziale jednej godziny na przykład, a nie w watogodzinach dostarczanych przez wtyczkę. Pozwala to na sensowne stwierdzenia, takie jak „wszystkie te gadżety zużyły tyle, co suszarka do włosów przy pełnym obciążeniu w tym przedziale czasu”.
Aby wykres wyświetlał uśrednione zużycie energii w watach, zgłoszony odczyt licznika w watogodzinach należy najpierw odjąć od wartości określonej dla ostatniego odczytu. Wartość ta jest następnie dzielona przez czas, który upłynął od ostatniego odczytu. Wynikiem jest średnia wartość poboru mocy w mierzonym oknie czasowym. Na przykład jeśli całkowite zużycie w ciągu 5 min (300 s) wynosi 10 Wh zgodnie z odczytem miernika, wszystkie podłączone urządzenia pobrały średnio
10 W × 3600 s / 300 s = 120 W
Zapisywanie na później
Baza danych SQLite z tabelą zawierającą jeden wiersz na pomiar i znacznik daty oraz odczytane wartości monitora dla całkowitej liczby watogodzin na dziś (total_wh, rysunek 8) będzie służyć jako dziennik dla zmierzonych danych.
Rysunek 8: Dane pomiarowe w bazie danych.
W ten sposób, podczas późniejszej oceny, można określić deltę do przedostatniej wartości licznika, a także narysować znaczące wykresy, które przedstawiają zużycie energii elektrycznej w gospodarstwie domowym w czasie.
Listing 2 tworzy opakowanie wokół funkcji bazy danych. Główny program tworzy później nową bazę danych SQLite, wywołując NewDB(), począwszy od linii 13, jeśli baza danych nie istnieje obecnie jako plik płaski, i definiuje schemat dla tabeli samples.
Listing 2: db.go
01 package main
02
03 import (
04 "database/sql"
05 "time"
06 _ "github.com/mattn/go-sqlite3"
07 )
08
09 type DB struct {
10 db *sql.DB
11 }
12
13 func NewDB(dbPath string) *DB {
14 db, err := sql.Open("sqlite3", dbPath)
15 must(err)
16
17 _, err = db.Exec('
18 CREATE TABLE IF NOT EXISTS samples (
19 id INTEGER PRIMARY KEY AUTOINCREMENT,
20 date TEXT NOT NULL,
21 total_wh INTEGER NOT NULL,
22 wh_delta INTEGER NOT NULL,
23 secs_since_last INTEGER NOT NULL
24 )')
25 must(err)
26
27 return &DB{db: db}
28 }
29
30 func (s *DB) Add(dt time.Time, totalWH int64) error {
31 lastTotalWH, secsSinceLast := s.Prev(dt)
32 whDelta := int(totalWH) - lastTotalWH
33 if lastTotalWH == 0 || whDelta < 0 {
34 whDelta = 0
35 }
36
37 _, err := s.db.Exec('
38 INSERT INTO samples (date, total_wh, wh_delta, secs_since_last)
39 VALUES (?, ?, ?, ?)',
40 dt.Format("2006-01-02 15:04:05"), totalWH, whDelta, secsSinceLast)
41
42 return err
43 }
44
45 func must(err error) {
46 if err != nil {
47 panic(err)
48 }
49 }
50
51 func (s *DB) Prev(dt time.Time) (int, int) {
52 var lastTotalWH int
53 var lastDateStr string
54 err := s.db.QueryRow("SELECT total_wh, date FROM samples ORDER BY id DESC LIMIT 1").Scan(&lastTotalWH, &lastDateStr)
55 if err == sql.ErrNoRows {
56 return 0, 0
57 }
58 must(err)
59
60 lastDate, err := time.Parse("2006-01-02 15:04:05", lastDateStr)
61 must(err)
62
63 secs := int(dt.Sub(lastDate).Seconds())
64 if secs < 0 {
65 secs = 0
66 }
67
68 return lastTotalWH, secs
69 }
Oprócz znacznika czasu date, który jest ciągiem znaków (SQLite nie ma jawnego typu daty), zmierzona wartość jest również przesyłana do bazy danych jako pojedyncza liczba całkowita na wiersz. Kod oblicza również różnicę w stosunku do poprzedniej wartości i przechowuje wynik w kolumnach wh_delta i secs_since_last (liczba sekund, które upłynęły od tego czasu). Te obliczone wartości są w rzeczywistości zbędne, ale bardzo pomocne później, gdy przychodzi do rysowania wykresu.
Krótko i prosto
Aby zaoszczędzić linie podczas drukowania kodu źródłowego, Listing 2 definiuje funkcję must() zaczynającą się w linii 45. Sprawdza ona zmienną typu error przekazaną do niej, aby sprawdzić, czy jest to błąd, który faktycznie wystąpił, czy też pomyślne wywołanie funkcji skutkujące nil. Go nalega na wyraźne sprawdzanie wartości zwracanych wszystkich wywoływanych funkcji; hacki takie jak must() psują plany projektantów języka, ale oszczędzają miejsce w mediach drukowanych. W aktywnie utrzymywanym kodzie lepiej sprawdzić błąd w warunku if, aby pomóc opiekunom w intuicyjnym zrozumieniu procesu podczas późniejszego przeglądania kodu.
Funkcja Add() rozpoczynająca się w linii 30 wyświetla wartości odczytane z monitora i wyszukuje poprzedni wpis w bazie danych. Watogodziny w bazie danych stanowią wartość bazową, którą funkcja następnie odejmuje od nowo odczytanej wartości. Znana jest również data poprzedniego wpisu, a funkcja Add() oblicza liczbę sekund, które upłynęły od tego czasu. Wraz ze świeżo odczytanymi zmierzonymi wartościami, funkcja wprowadza dwa wyniki do kolumn wh_delta i secs_since_list. Oznacza to, że podczas późniejszej oceny można obliczyć średnie zużycie energii w zmierzonym oknie czasowym bez dodatkowych zapytań SQL dla każdego wiersza tabeli.
W przypadku przerwy w zasilaniu lub wyciągnięcia wtyczki skumulowane watogodziny są resetowane do zera, jak wspomniano powyżej, a wartość zapytania jest nagle mniejsza niż wartość przechowywana w bazie danych. Linia 33 sprawdza ten przypadek, a także ustawia deltę watogodzin na zero, aby wykres mógł ją później po prostu zignorować.
Prev() rozpoczynające się w linii 51 określa ostatnią zmierzoną wartość wprowadzoną dla watogodzin i sekund, które upłynęły od tego czasu. Zapytanie w linii 54 sortuje dopasowania w porządku malejącym według daty i wywołuje LIMIT 1, aby użyć tylko ostatniego. Aby arytmetyka daty określiła różnicę czasu w stosunku do aktualnego czasu w sekundach, time.Parse() w linii 60 konwertuje znacznik daty (który jest ciągiem znaków, jak pamiętasz) z powrotem na wewnętrzny typ czasu Go time.Time. Obliczenia czasu w głównym programie odbywają się w strefie UTC, więc time.Parse() nie wymaga określenia lokalizacji.
Główny program z Listingu 3 konsumuje dane wyjściowe z Listingu 1 na swoim własnym stdin później, wyszukuje zmierzoną wartość, której potrzebuje w wyniku JSON i wywołuje db.Add() w linii 16 z bieżącym znacznikiem czasu w strefie UTC. Później zadanie cron uruchamia potok ./tapo-emeter | ./stasher co pięć minut; wywołuje dwa pliki binarne Go wygenerowane z Listingu 2 i Listingu 3, pobiera bieżące zmierzone wartości i wprowadza je do bazy danych.
Listing 3: stasher.go
01 package main
02
03 import (
04 "github.com/tidwall/gjson"
05 "io"
06 "os"
07 "time"
08 )
09
10 func main() {
11 data, err := io.ReadAll(os.Stdin)
12 must(err)
13
14 wh := gjson.GetBytes(data, "today_energy")
15 db := NewDB("stasher.db")
16 db.Add(time.Now().UTC(), wh.Int())
17 }
Podrasuj moją metrykę
Teraz nadszedł czas, aby ocenić i wyświetlić historyczne dane pomiarowe. Listing 4 odkrywa, ile energii elektrycznej przepływa przez licznik w każdej godzinie dnia. Wyniki na rysunku 9 pokazują, że średnie zużycie energii waha się między 120 W a 180 W. W ciągu dnia waha się między 120 W a 140 W – przede wszystkim przez główny komputer w mojej męskiej jaskini działający bez przerwy. Monitor z płaskim ekranem jest dodawany w razie potrzeby. Wieczorem włączanych jest kilka świateł, a zużycie energii wzrasta do 180 W przez kilka godzin. Kiedy łóżko wzywa, nocny stróż gasi światło podczas swojej ostatniej wycieczki po obiekcie.
Listing 4: hourly.go
01 package main
02
03 import (
04 "database/sql"
05 "fmt"
06 _ "github.com/mattn/go-sqlite3"
07 "time"
08 )
09
10 type HourlyStats struct {
11 Sum float64
12 Count int
13 }
14
15 func main() {
16 db, err := sql.Open("sqlite3", "stasher.db")
17 must(err)
18 defer db.Close()
19
20 loc, err := time.LoadLocation("America/Los_Angeles")
21 must(err)
22 rows, err := db.Query("SELECT date, wh_delta, secs_since_last FROM samples")
23 must(err)
24 defer rows.Close()
25
26 var hourly [24]HourlyStats
27 for rows.Next() {
28 var dateStr string
29 var whDelta, secs int
30 err := rows.Scan(&dateStr, &whDelta, &secs)
31 must(err)
32 if secs == 0 {
33 continue
34 }
35 t, err := time.Parse("2006-01-02 15:04:05", dateStr)
36 must(err)
37 hour := t.In(loc).Hour()
38 value := float64(whDelta) * 3600 / float64(secs)
39 hourly[hour].Sum += value
40 hourly[hour].Count++
41 }
42
43 for hour := 0; hour < 24; hour++ {
44 avg := 0.0
45 if hourly[hour].Count > 0 {
46 avg = hourly[hour].Sum / float64(hourly[hour].Count)
47 }
48 fmt.Printf("%02d,%f\n", hour, avg)
49 }
50 }
51
52 func must(err error) {
53 if err != nil {
54 panic(err)
55 }
56 }
Rysunek 9: Moc zużywana w męskiej jaskini Mike’a średnio co godzinę.
Listing 4 przechodzi przez wszystkie zmierzone wartości zarejestrowane do tej pory w bazie danych i pobiera wcześniej obliczoną różnicę licznika w watogodzinach plus liczbę sekund, które upłynęły w oknie czasowym. Aby wyodrębnić godzinę wartości czasu ze znacznika czasu pomiaru, funkcja Parse() konwertuje datę próbki na typ time.Time. Jednak ta wartość jest dla strefy czasowej UTC. In() w linii 37 konwertuje ją do strefy czasowej Pacyfiku zdefiniowanej w linii 20 dla mojego adoptowanego domu w Kalifornii. Z kolei Hour() wyodrębnia godzinę z tej wartości. W linii 38 wartość watów jest określana na podstawie whDelta według wzoru wyjaśnionego powyżej plus przedział czasu w sekundach.
Struktura zawierająca sumę częściową Sum i stale rosnący licznik jest dostępna dla każdej godziny dnia w wycinku tablicy hourly. Pod koniec oceny pętla for rozpoczynająca się w linii 43 iteruje po wszystkich godzinach w ciągu dnia od 0 do 23 i generuje ich średnie, dzieląc sumę częściową przez licznik. Instrukcja print w wierszu 48 wyświetla wyniki wiersz po wierszu do stdout w formacie CSV.
Wizualizacja
W następnym kroku binarna funkcja csv2bar wyświetla dane CSV na wykresie słupkowym, jak pokazano na rysunku 9. Aby to zrobić, Listing 5 oczekuje, że tytuł pliku PNG zostanie wygenerowany jako opcja --title w wierszu poleceń. Każda para danych zawiera wartość całkowitą dla godziny dnia i wartość watów jako liczbę zmiennoprzecinkową, wszystkie w formacie CSV.
Listing 5: csv2bar.go
01 package main
02
03 import (
04 "encoding/csv"
05 "flag"
06 "github.com/wcharczuk/go-chart/v2"
07 "io"
08 "os"
09 "strconv"
10 )
11
12 func main() {
13 title := flag.String("title", "", "Chart Title")
14 flag.Parse()
15
16 maxY := float64(0)
17 bars := []chart.Value{}
18 r := csv.NewReader(os.Stdin)
19 for {
20 record, err := r.Read()
21 if err == io.EOF {
22 break
23 }
24 if err != nil {
25 panic(err)
26 }
27 val, err := strconv.ParseFloat(record[1], 64)
28 if err != nil {
29 panic(err)
30 }
31 if val > maxY {
32 maxY = val
33 }
34 bars = append(bars, chart.Value{
35 Label: record[0],
36 Value: val,
37 })
38 }
39
40 graph := chart.BarChart{
41 Title: *title,
42 Height: 512,
43 BarWidth: 18,
44 Bars: bars,
45 YAxis: chart.YAxis{
46 Range: &chart.ContinuousRange{
47 Min: 0,
48 Max: maxY,
49 },
50 },
51 }
52
53 err := graph.Render(chart.PNG, os.Stdout)
54 if err != nil {
55 panic(err)
56 }
57 }
Pętla for rozpoczynająca się w linii 19 przechodzi przez wszystkie wpisy, generuje strukturę danych bars i przekazuje ją do struktury chart.BarChart eksportowanej przez pakiet go-chart Go. Funkcja Render() zamienia to w jaskrawo kolorowy wykres, który Listing 5 następnie wyprowadza do stdout; dlatego ./hourly > hourly.png generuje wykres pokazany na rysunku 9 w określonym pliku.
Niestety, pakiet go-chart ma irytujący zwyczaj nierysowania słupków na całej ich długości, a jedynie od ich najmniejszej wartości X. Oczywiście chcę, aby wykres pokazywał również zużycie bazowe. Jeśli myślisz, że wystarczy ustawić Min na zero w konstrukcji YAxis, pomyśl jeszcze raz: go-chart ignoruje to, dopóki Max nie zostanie również ustawione w linii 48.
Kod musi jednak najpierw określić maksymalną wartość dla Y, co robi za pomocą zmiennej maxY.
I gotowe!
Zwykła sekwencja poleceń go mod init hourly i go mod tidy pobiera wszystkie zależne pakiety z GitHub. Następnie należy wykonać polecenie go build, aby skompilować i połączyć plik binarny hourly. To samo dotyczy wszystkich innych samodzielnych programów Go przedstawionych tutaj.
To dopiero początek; jak zawsze, nie ma ograniczeń co do tego, co może zrobić zdeterminowany programista. Rysunek 10 pokazuje inny przykład, który określa średnie zużycie energii na dzień tygodnia. Jak zwykle, dane wyjściowe są w formacie CSV, a csv2bar przetwarza całość w celu utworzenia pouczającego wykresu. Do dzieła!
Rysunek 10: Ile energii elektrycznej zużywam w ciągu dnia?
Info
[1] Pakiet API Tapo na GitHubie: https://github.com/fabiankachlock/tapo-api
