From dd1e39769e8a86775f2c38f3edcba5b1c0e2babd Mon Sep 17 00:00:00 2001 From: "Rasmus \"Pez\" Wejlgaard" Date: Mon, 27 Apr 2026 20:01:32 +0100 Subject: [PATCH] fix: more proper setup and lots of quality of life changes (#10) --- .github/workflows/pr-check.yml | 18 +- Dockerfile | 2 +- cmd/octopus_exporter/agile.go | 4 +- cmd/octopus_exporter/agile_test.go | 9 +- cmd/octopus_exporter/client.go | 17 +- cmd/octopus_exporter/client_test.go | 68 ++++++++ cmd/octopus_exporter/consumption.go | 15 +- cmd/octopus_exporter/consumption_test.go | 23 ++- cmd/octopus_exporter/main.go | 207 +++++++++++++++------- cmd/octopus_exporter/meters.go | 54 ++++-- cmd/octopus_exporter/meters_test.go | 209 +++++++++++++++++++++++ cmd/octopus_exporter/rates.go | 15 +- cmd/octopus_exporter/rest.go | 4 +- cmd/octopus_exporter/telemetry.go | 7 +- cmd/octopus_exporter/telemetry_test.go | 71 ++++++++ 15 files changed, 608 insertions(+), 115 deletions(-) create mode 100644 cmd/octopus_exporter/meters_test.go create mode 100644 cmd/octopus_exporter/telemetry_test.go diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index f3cd269..8c3ce4c 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -5,20 +5,14 @@ on: branches: [main] jobs: - build: + test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build - uses: docker/build-push-action@v6 + - uses: actions/setup-go@v5 with: - context: . - push: false - platforms: linux/amd64,linux/arm64 + go-version-file: go.mod + + - name: Test + run: go test -race ./... diff --git a/Dockerfile b/Dockerfile index bcc5605..a608897 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22-alpine AS builder +FROM golang:1.24-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download diff --git a/cmd/octopus_exporter/agile.go b/cmd/octopus_exporter/agile.go index a44bfef..e9fcd40 100644 --- a/cmd/octopus_exporter/agile.go +++ b/cmd/octopus_exporter/agile.go @@ -9,7 +9,7 @@ import ( // getCurrentAgileRate returns the unit rate (inc. VAT, pence/kWh) for the current // half-hour slot from the Agile tariff REST endpoint. -func getCurrentAgileRate(productCode, tariffCode string) (float64, error) { +func getCurrentAgileRate(productCode, tariffCode, key string) (float64, error) { now := time.Now().UTC() slotStart := now.Truncate(30 * time.Minute) slotEnd := slotStart.Add(30 * time.Minute) @@ -18,7 +18,7 @@ func getCurrentAgileRate(productCode, tariffCode string) (float64, error) { result, err := doREST(path, url.Values{ "period_from": {slotStart.Format(time.RFC3339)}, "period_to": {slotEnd.Format(time.RFC3339)}, - }) + }, key) if err != nil { return 0, err } diff --git a/cmd/octopus_exporter/agile_test.go b/cmd/octopus_exporter/agile_test.go index c8ea650..0108bf2 100644 --- a/cmd/octopus_exporter/agile_test.go +++ b/cmd/octopus_exporter/agile_test.go @@ -15,9 +15,8 @@ func TestGetCurrentAgileRate_Success(t *testing.T) { })) defer srv.Close() octopusREST = srv.URL - apiKey = "test" - rate, err := getCurrentAgileRate("AGILE-24-10-01", "E-1R-AGILE-24-10-01-C") + rate, err := getCurrentAgileRate("AGILE-24-10-01", "E-1R-AGILE-24-10-01-C", "test") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -32,9 +31,8 @@ func TestGetCurrentAgileRate_NoSlot(t *testing.T) { })) defer srv.Close() octopusREST = srv.URL - apiKey = "test" - _, err := getCurrentAgileRate("AGILE-24-10-01", "E-1R-AGILE-24-10-01-C") + _, err := getCurrentAgileRate("AGILE-24-10-01", "E-1R-AGILE-24-10-01-C", "test") if err == nil { t.Error("expected error for empty slot, got nil") } @@ -48,9 +46,8 @@ func TestGetCurrentAgileRate_CorrectPath(t *testing.T) { })) defer srv.Close() octopusREST = srv.URL - apiKey = "test" - getCurrentAgileRate("AGILE-24-10-01", "E-1R-AGILE-24-10-01-C") + getCurrentAgileRate("AGILE-24-10-01", "E-1R-AGILE-24-10-01-C", "test") want := "/v1/products/AGILE-24-10-01/electricity-tariffs/E-1R-AGILE-24-10-01-C/standard-unit-rates/" if capturedPath != want { diff --git a/cmd/octopus_exporter/client.go b/cmd/octopus_exporter/client.go index 37187b3..e712a4d 100644 --- a/cmd/octopus_exporter/client.go +++ b/cmd/octopus_exporter/client.go @@ -11,11 +11,14 @@ import ( "strconv" "strings" "time" + + "github.com/prometheus/client_golang/prometheus" ) var ( - octopusGraphQL = "https://api.octopus.energy/v1/graphql/" - httpClient = &http.Client{Timeout: 15 * time.Second} + octopusGraphQL = "https://api.octopus.energy/v1/graphql/" + httpClient = &http.Client{Timeout: 15 * time.Second} + rateLimitRetries prometheus.Counter ) type gqlRequest struct { @@ -64,6 +67,9 @@ func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) { if attempt == maxRetries { return nil, errors.New("rate limited: max retries exceeded") } + if rateLimitRetries != nil { + rateLimitRetries.Inc() + } wait := backoff if ra := resp.Header.Get("Retry-After"); ra != "" { if secs, err := strconv.Atoi(ra); err == nil { @@ -80,6 +86,13 @@ func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) { if err != nil { return nil, err } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet := strings.TrimSpace(string(raw)) + if len(snippet) > 200 { + snippet = snippet[:200] + } + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, snippet) + } return raw, nil } return nil, errors.New("rate limited: max retries exceeded") diff --git a/cmd/octopus_exporter/client_test.go b/cmd/octopus_exporter/client_test.go index 76e8915..a6d53fb 100644 --- a/cmd/octopus_exporter/client_test.go +++ b/cmd/octopus_exporter/client_test.go @@ -2,6 +2,11 @@ package main import ( "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" "testing" ) @@ -51,3 +56,66 @@ func TestToSlice_WrongType(t *testing.T) { t.Errorf("expected nil for wrong type, got %v", got) } } + +func TestExecuteWithRetry_RateLimitRetry(t *testing.T) { + var attempts atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := attempts.Add(1) + if n < 3 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + return + } + fmt.Fprint(w, `ok`) + })) + defer srv.Close() + + raw, err := executeWithRetry(func() (*http.Request, error) { + return http.NewRequest(http.MethodGet, srv.URL, nil) + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(raw) != "ok" { + t.Errorf("got body %q, want %q", string(raw), "ok") + } + if attempts.Load() != 3 { + t.Errorf("got %d attempts, want 3", attempts.Load()) + } +} + +func TestExecuteWithRetry_Non200Error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":"unauthorized"}`) + })) + defer srv.Close() + + _, err := executeWithRetry(func() (*http.Request, error) { + return http.NewRequest(http.MethodGet, srv.URL, nil) + }) + if err == nil { + t.Error("expected error for 401, got nil") + } + if !strings.Contains(err.Error(), "401") { + t.Errorf("expected error to contain status code, got: %v", err) + } +} + +func TestExecuteWithRetry_ServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprint(w, `internal server error`) + })) + defer srv.Close() + + _, err := executeWithRetry(func() (*http.Request, error) { + return http.NewRequest(http.MethodGet, srv.URL, nil) + }) + if err == nil { + t.Error("expected error for 500, got nil") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("expected error to contain status code, got: %v", err) + } +} diff --git a/cmd/octopus_exporter/consumption.go b/cmd/octopus_exporter/consumption.go index c3724a7..5a39f07 100644 --- a/cmd/octopus_exporter/consumption.go +++ b/cmd/octopus_exporter/consumption.go @@ -12,7 +12,7 @@ type consumptionReading struct { IntervalStart time.Time } -func getLatestConsumption(kind meterKind, id, serial string) (*consumptionReading, error) { +func getLatestConsumption(kind meterKind, id, serial, key string) (*consumptionReading, error) { var path string switch kind { case electricity: @@ -25,7 +25,7 @@ func getLatestConsumption(kind meterKind, id, serial string) (*consumptionReadin result, err := doREST(path, url.Values{ "period_from": {time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)}, "order_by": {"period"}, - }) + }, key) if err != nil { return nil, err } @@ -35,9 +35,16 @@ func getLatestConsumption(kind meterKind, id, serial string) (*consumptionReadin return nil, errors.New("no consumption data in last 24h") } - latest := results[len(results)-1].(map[string]any) + latest, ok := results[len(results)-1].(map[string]any) + if !ok { + return nil, errors.New("unexpected API response: invalid result entry") + } kwh, _ := latest["consumption"].(float64) - start, err := time.Parse(time.RFC3339, latest["interval_start"].(string)) + startStr, ok := latest["interval_start"].(string) + if !ok { + return nil, errors.New("unexpected API response: missing interval_start") + } + start, err := time.Parse(time.RFC3339, startStr) if err != nil { return nil, fmt.Errorf("failed to parse interval_start: %w", err) } diff --git a/cmd/octopus_exporter/consumption_test.go b/cmd/octopus_exporter/consumption_test.go index f828226..4f28f87 100644 --- a/cmd/octopus_exporter/consumption_test.go +++ b/cmd/octopus_exporter/consumption_test.go @@ -16,9 +16,8 @@ func TestGetLatestConsumption_ReturnsLatestInterval(t *testing.T) { })) defer srv.Close() octopusREST = srv.URL - apiKey = "test" - c, err := getLatestConsumption(electricity, "MPAN123", "SERIAL456") + c, err := getLatestConsumption(electricity, "MPAN123", "SERIAL456", "test") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -33,9 +32,8 @@ func TestGetLatestConsumption_Empty(t *testing.T) { })) defer srv.Close() octopusREST = srv.URL - apiKey = "test" - _, err := getLatestConsumption(electricity, "MPAN123", "SERIAL456") + _, err := getLatestConsumption(electricity, "MPAN123", "SERIAL456", "test") if err == nil { t.Error("expected error for empty results, got nil") } @@ -51,12 +49,25 @@ func TestGetLatestConsumption_GasPath(t *testing.T) { })) defer srv.Close() octopusREST = srv.URL - apiKey = "test" - getLatestConsumption(gas, "MPRN789", "SERIAL456") + getLatestConsumption(gas, "MPRN789", "SERIAL456", "test") want := "/v1/gas-meter-points/MPRN789/meters/SERIAL456/consumption/" if capturedPath != want { t.Errorf("got path %q, want %q", capturedPath, want) } } + +func TestGetLatestConsumption_Non200Error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"error":"unauthorized"}`) + })) + defer srv.Close() + octopusREST = srv.URL + + _, err := getLatestConsumption(electricity, "MPAN123", "SERIAL456", "bad-key") + if err == nil { + t.Error("expected error for 401, got nil") + } +} diff --git a/cmd/octopus_exporter/main.go b/cmd/octopus_exporter/main.go index 021becd..f773216 100644 --- a/cmd/octopus_exporter/main.go +++ b/cmd/octopus_exporter/main.go @@ -5,6 +5,8 @@ import ( "log" "net/http" "os" + "sync" + "sync/atomic" "time" "github.com/prometheus/client_golang/prometheus" @@ -35,6 +37,10 @@ func gauge(name, help string) prometheus.Gauge { return prometheus.NewGauge(prometheus.GaugeOpts{Name: name, Help: help}) } +func counter(name, help string) prometheus.Counter { + return prometheus.NewCounter(prometheus.CounterOpts{Name: name, Help: help}) +} + func main() { apiKey = mustEnv("OCTOPUS_API_KEY") port = envOrDefault("PORT", "9359") @@ -83,20 +89,27 @@ func main() { // Account accountBalance := gauge("octopus_account_balance_pence", "Account balance in pence (positive = credit, negative = debit)") + // Exporter health + exporterUp := gauge("octopus_up", "1 if the last poll cycle completed without errors, 0 otherwise") + pollErrors := counter("octopus_poll_errors_total", "Total number of collector errors per poll cycle") + tokenRefreshCount := counter("octopus_token_refreshes_total", "Total number of successful JWT token refreshes") + rateLimitRetries = counter("octopus_rate_limit_retries_total", "Total number of 429 rate-limit retries across all requests") + toRegister := []prometheus.Collector{ elecDemand, elecLastRead, elecConsumption, elecConsumptionInterval, elecUnitRate, elecStandingCharge, accountBalance, + exporterUp, pollErrors, tokenRefreshCount, rateLimitRetries, } var ( - gasDemand prometheus.Gauge - gasLastRead prometheus.Gauge - gasConsumption prometheus.Gauge + gasDemand prometheus.Gauge + gasLastRead prometheus.Gauge + gasConsumption prometheus.Gauge gasConsumptionInterval prometheus.Gauge - gasUnitRate prometheus.Gauge - gasStandCharge prometheus.Gauge + gasUnitRate prometheus.Gauge + gasStandCharge prometheus.Gauge ) if gasMeter != nil { gasDemand = gauge("octopus_gas_demand_watts", "Live gas demand in watts") @@ -118,100 +131,170 @@ func main() { } }() - tryRefresh := func(err error) { + // tokenMu guards token across concurrent poll goroutines. + var tokenMu sync.RWMutex + + // withToken calls fn with the current token, refreshing once on JWT expiry. + withToken := func(fn func(string) error) error { + tokenMu.RLock() + t := token + tokenMu.RUnlock() + + err := fn(t) if !errors.Is(err, errTokenExpired) { - return + return err } - t, e := getKrakenToken(apiKey) - if e != nil { - log.Printf("token refresh failed: %v", e) - return + + // Only one goroutine refreshes; others will pick up the new token. + tokenMu.Lock() + if token == t { + newT, e := getKrakenToken(apiKey) + if e != nil { + tokenMu.Unlock() + log.Printf("token refresh failed: %v", e) + return err + } + token = newT + tokenRefreshCount.Inc() } - token = t + newT := token + tokenMu.Unlock() + + return fn(newT) } for { + var ( + wg sync.WaitGroup + failedAny atomic.Bool + ) + + fail := func(format string, args ...any) { + log.Printf(format, args...) + pollErrors.Inc() + failedAny.Store(true) + } + + collect := func(name string, fn func() error) { + wg.Add(1) + go func() { + defer wg.Done() + if err := fn(); err != nil { + fail("%s error: %v", name, err) + } + }() + } + // Electricity telemetry (live demand) if elecMeter.deviceID != "" { - reading, err := getLiveConsumption(token, elecMeter.deviceID) - if err != nil { - log.Printf("electricity telemetry error: %v", err) - tryRefresh(err) - } else { - elecDemand.Set(float64(reading.Demand)) - if t, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil { - elecLastRead.Set(float64(t.Unix())) - } - } + collect("electricity telemetry", func() error { + return withToken(func(t string) error { + reading, err := getLiveConsumption(t, elecMeter.deviceID) + if err != nil { + return err + } + elecDemand.Set(float64(reading.Demand)) + if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil { + elecLastRead.Set(float64(ts.Unix())) + } + return nil + }) + }) } // Gas telemetry (live demand) if gasMeter != nil && gasMeter.deviceID != "" { - reading, err := getLiveConsumption(token, gasMeter.deviceID) - if err != nil { - log.Printf("gas telemetry error: %v", err) - tryRefresh(err) - } else { - gasDemand.Set(float64(reading.Demand)) - if t, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil { - gasLastRead.Set(float64(t.Unix())) - } - } + collect("gas telemetry", func() error { + return withToken(func(t string) error { + reading, err := getLiveConsumption(t, gasMeter.deviceID) + if err != nil { + return err + } + gasDemand.Set(float64(reading.Demand)) + if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil { + gasLastRead.Set(float64(ts.Unix())) + } + return nil + }) + }) } // Electricity half-hourly consumption (REST) if elecMeter.mpan != "" && elecMeter.serial != "" { - c, err := getLatestConsumption(electricity, elecMeter.mpan, elecMeter.serial) - if err != nil { - log.Printf("electricity consumption error: %v", err) - } else { + collect("electricity consumption", func() error { + c, err := getLatestConsumption(electricity, elecMeter.mpan, elecMeter.serial, apiKey) + if err != nil { + return err + } elecConsumption.Set(c.KWh) elecConsumptionInterval.Set(float64(c.IntervalStart.Unix())) - } + return nil + }) } // Gas half-hourly consumption (REST) if gasMeter != nil && gasMeter.mprn != "" && gasMeter.serial != "" { - c, err := getLatestConsumption(gas, gasMeter.mprn, gasMeter.serial) - if err != nil { - log.Printf("gas consumption error: %v", err) - } else { + collect("gas consumption", func() error { + c, err := getLatestConsumption(gas, gasMeter.mprn, gasMeter.serial, apiKey) + if err != nil { + return err + } gasConsumption.Set(c.KWh) gasConsumptionInterval.Set(float64(c.IntervalStart.Unix())) - } + return nil + }) } - // Tariff rates - rates, err := getRates(token) - if err != nil { - log.Printf("rates error: %v", err) - tryRefresh(err) - } else { - unitRate := rates.ElectricityUnitRate - if rates.ElectricityIsAgile && rates.ElectricityProductCode != "" && rates.ElectricityTariffCode != "" { - agileRate, err := getCurrentAgileRate(rates.ElectricityProductCode, rates.ElectricityTariffCode) + // Tariff rates (result needed for optional agile lookup after wg.Wait) + var collectedRates *tariffRates + collect("rates", func() error { + return withToken(func(t string) error { + r, err := getRates(t) if err != nil { - log.Printf("agile rate error: %v", err) + return err + } + collectedRates = r + return nil + }) + }) + + // Account balance + collect("account balance", func() error { + return withToken(func(t string) error { + balance, err := getAccountBalance(t) + if err != nil { + return err + } + accountBalance.Set(balance) + return nil + }) + }) + + wg.Wait() + + // Agile rate depends on the rates result, so it runs after the parallel phase. + if collectedRates != nil { + unitRate := collectedRates.ElectricityUnitRate + if collectedRates.ElectricityIsAgile && collectedRates.ElectricityProductCode != "" && collectedRates.ElectricityTariffCode != "" { + agileRate, err := getCurrentAgileRate(collectedRates.ElectricityProductCode, collectedRates.ElectricityTariffCode, apiKey) + if err != nil { + fail("agile rate error: %v", err) } else { unitRate = agileRate } } elecUnitRate.Set(unitRate) - elecStandingCharge.Set(rates.ElectricityStandingCharge) - + elecStandingCharge.Set(collectedRates.ElectricityStandingCharge) if gasMeter != nil { - gasUnitRate.Set(rates.GasUnitRate) - gasStandCharge.Set(rates.GasStandingCharge) + gasUnitRate.Set(collectedRates.GasUnitRate) + gasStandCharge.Set(collectedRates.GasStandingCharge) } } - // Account balance - balance, err := getAccountBalance(token) - if err != nil { - log.Printf("account balance error: %v", err) - tryRefresh(err) + if failedAny.Load() { + exporterUp.Set(0) } else { - accountBalance.Set(balance) + exporterUp.Set(1) } time.Sleep(60 * time.Second) diff --git a/cmd/octopus_exporter/meters.go b/cmd/octopus_exporter/meters.go index 4d2d9d8..6f30acd 100644 --- a/cmd/octopus_exporter/meters.go +++ b/cmd/octopus_exporter/meters.go @@ -53,7 +53,9 @@ func getMeters(token string) ([]meterCandidate, error) { var candidates []meterCandidate - accounts, _ := result["data"].(map[string]any)["viewer"].(map[string]any)["accounts"].([]any) + data, _ := result["data"].(map[string]any) + viewer, _ := data["viewer"].(map[string]any) + accounts, _ := viewer["accounts"].([]any) for _, a := range accounts { props, _ := a.(map[string]any)["properties"].([]any) for _, p := range props { @@ -63,33 +65,59 @@ func getMeters(token string) ([]meterCandidate, error) { } for _, mp := range toSlice(pm["electricityMeterPoints"]) { - mpan, _ := mp.(map[string]any)["mpan"].(string) - for _, m := range toSlice(mp.(map[string]any)["meters"]) { - serial, _ := m.(map[string]any)["serialNumber"].(string) - for _, d := range toSlice(m.(map[string]any)["smartDevices"]) { - deviceID, _ := d.(map[string]any)["deviceId"].(string) + mpPoint, ok := mp.(map[string]any) + if !ok { + continue + } + mpan, _ := mpPoint["mpan"].(string) + for _, m := range toSlice(mpPoint["meters"]) { + meterMap, ok := m.(map[string]any) + if !ok { + continue + } + serial, _ := meterMap["serialNumber"].(string) + devices := toSlice(meterMap["smartDevices"]) + for _, d := range devices { + dMap, ok := d.(map[string]any) + if !ok { + continue + } + deviceID, _ := dMap["deviceId"].(string) if deviceID != "" { candidates = append(candidates, meterCandidate{kind: electricity, mpan: mpan, serial: serial, deviceID: deviceID}) } } // Include meters without smart devices so we can still use the REST consumption endpoint. - if len(toSlice(m.(map[string]any)["smartDevices"])) == 0 && serial != "" { + if len(devices) == 0 && serial != "" { candidates = append(candidates, meterCandidate{kind: electricity, mpan: mpan, serial: serial}) } } } for _, mp := range toSlice(pm["gasMeterPoints"]) { - mprn, _ := mp.(map[string]any)["mprn"].(string) - for _, m := range toSlice(mp.(map[string]any)["meters"]) { - serial, _ := m.(map[string]any)["serialNumber"].(string) - for _, d := range toSlice(m.(map[string]any)["smartDevices"]) { - deviceID, _ := d.(map[string]any)["deviceId"].(string) + mpPoint, ok := mp.(map[string]any) + if !ok { + continue + } + mprn, _ := mpPoint["mprn"].(string) + for _, m := range toSlice(mpPoint["meters"]) { + meterMap, ok := m.(map[string]any) + if !ok { + continue + } + serial, _ := meterMap["serialNumber"].(string) + devices := toSlice(meterMap["smartDevices"]) + for _, d := range devices { + dMap, ok := d.(map[string]any) + if !ok { + continue + } + deviceID, _ := dMap["deviceId"].(string) if deviceID != "" { candidates = append(candidates, meterCandidate{kind: gas, mprn: mprn, serial: serial, deviceID: deviceID}) } } - if len(toSlice(m.(map[string]any)["smartDevices"])) == 0 && serial != "" { + if len(devices) == 0 && serial != "" { candidates = append(candidates, meterCandidate{kind: gas, mprn: mprn, serial: serial}) } } diff --git a/cmd/octopus_exporter/meters_test.go b/cmd/octopus_exporter/meters_test.go new file mode 100644 index 0000000..afc232d --- /dev/null +++ b/cmd/octopus_exporter/meters_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestResolveMeter_NoFilters_ReturnsFirst(t *testing.T) { + t.Setenv("OCTOPUS_DEVICE_ID", "") + t.Setenv("OCTOPUS_MPAN", "") + t.Setenv("OCTOPUS_SERIAL", "") + + candidates := []meterCandidate{ + {kind: electricity, mpan: "1000000000001", serial: "A001", deviceID: "dev1"}, + {kind: electricity, mpan: "1000000000002", serial: "A002", deviceID: "dev2"}, + } + m, err := resolveMeter(candidates, electricity) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m == nil { + t.Fatal("expected meter, got nil") + } + if m.mpan != "1000000000001" { + t.Errorf("got mpan %q, want 1000000000001", m.mpan) + } +} + +func TestResolveMeter_FilterByMPAN(t *testing.T) { + t.Setenv("OCTOPUS_DEVICE_ID", "") + t.Setenv("OCTOPUS_MPAN", "1000000000002") + t.Setenv("OCTOPUS_SERIAL", "") + + candidates := []meterCandidate{ + {kind: electricity, mpan: "1000000000001", serial: "A001", deviceID: "dev1"}, + {kind: electricity, mpan: "1000000000002", serial: "A002", deviceID: "dev2"}, + } + m, err := resolveMeter(candidates, electricity) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m == nil { + t.Fatal("expected meter, got nil") + } + if m.mpan != "1000000000002" { + t.Errorf("got mpan %q, want 1000000000002", m.mpan) + } +} + +func TestResolveMeter_FilterByDeviceID(t *testing.T) { + t.Setenv("OCTOPUS_DEVICE_ID", "dev2") + t.Setenv("OCTOPUS_MPAN", "") + t.Setenv("OCTOPUS_SERIAL", "") + + candidates := []meterCandidate{ + {kind: electricity, mpan: "1000000000001", serial: "A001", deviceID: "dev1"}, + {kind: electricity, mpan: "1000000000002", serial: "A002", deviceID: "dev2"}, + } + m, err := resolveMeter(candidates, electricity) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.deviceID != "dev2" { + t.Errorf("got deviceID %q, want dev2", m.deviceID) + } +} + +func TestResolveMeter_FilterMismatch_ReturnsError(t *testing.T) { + t.Setenv("OCTOPUS_MPAN", "9999999999999") + t.Setenv("OCTOPUS_DEVICE_ID", "") + t.Setenv("OCTOPUS_SERIAL", "") + + candidates := []meterCandidate{ + {kind: electricity, mpan: "1000000000001", serial: "A001"}, + } + _, err := resolveMeter(candidates, electricity) + if err == nil { + t.Error("expected error for unmatched filter, got nil") + } +} + +func TestResolveMeter_NoMetersOfKind_ReturnsNil(t *testing.T) { + t.Setenv("OCTOPUS_DEVICE_ID", "") + t.Setenv("OCTOPUS_MPAN", "") + t.Setenv("OCTOPUS_SERIAL", "") + + candidates := []meterCandidate{ + {kind: gas, mprn: "1111111111", serial: "G001"}, + } + m, err := resolveMeter(candidates, electricity) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m != nil { + t.Errorf("expected nil for no electricity meters, got %+v", m) + } +} + +func TestResolveMeter_GasFilterByMPRN(t *testing.T) { + t.Setenv("OCTOPUS_GAS_DEVICE_ID", "") + t.Setenv("OCTOPUS_GAS_MPRN", "2222222222") + t.Setenv("OCTOPUS_GAS_SERIAL", "") + + candidates := []meterCandidate{ + {kind: gas, mprn: "1111111111", serial: "G001"}, + {kind: gas, mprn: "2222222222", serial: "G002"}, + } + m, err := resolveMeter(candidates, gas) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.mprn != "2222222222" { + t.Errorf("got mprn %q, want 2222222222", m.mprn) + } +} + +func TestGetMeters_ElectricityWithSmartDevice(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{ + "electricityMeterPoints":[{ + "mpan":"1000000000001", + "meters":[{"serialNumber":"A001","smartDevices":[{"deviceId":"dev1"}]}] + }], + "gasMeterPoints":[] + }]}]}}}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + candidates, err := getMeters("token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 1 { + t.Fatalf("got %d candidates, want 1", len(candidates)) + } + c := candidates[0] + if c.kind != electricity { + t.Errorf("kind: got %q, want electricity", c.kind) + } + if c.mpan != "1000000000001" { + t.Errorf("mpan: got %q, want 1000000000001", c.mpan) + } + if c.deviceID != "dev1" { + t.Errorf("deviceID: got %q, want dev1", c.deviceID) + } +} + +func TestGetMeters_MeterWithoutSmartDevice(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{ + "electricityMeterPoints":[{ + "mpan":"1000000000001", + "meters":[{"serialNumber":"A001","smartDevices":[]}] + }], + "gasMeterPoints":[] + }]}]}}}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + candidates, err := getMeters("token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 1 { + t.Fatalf("got %d candidates, want 1", len(candidates)) + } + if candidates[0].deviceID != "" { + t.Errorf("expected empty deviceID for meter without smart device, got %q", candidates[0].deviceID) + } +} + +func TestGetMeters_BothElectricityAndGas(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{ + "electricityMeterPoints":[{ + "mpan":"1000000000001", + "meters":[{"serialNumber":"A001","smartDevices":[{"deviceId":"dev1"}]}] + }], + "gasMeterPoints":[{ + "mprn":"1111111111", + "meters":[{"serialNumber":"G001","smartDevices":[{"deviceId":"gdev1"}]}] + }] + }]}]}}}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + candidates, err := getMeters("token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(candidates) != 2 { + t.Fatalf("got %d candidates, want 2", len(candidates)) + } + kinds := map[meterKind]bool{} + for _, c := range candidates { + kinds[c.kind] = true + } + if !kinds[electricity] { + t.Error("expected electricity candidate") + } + if !kinds[gas] { + t.Error("expected gas candidate") + } +} diff --git a/cmd/octopus_exporter/rates.go b/cmd/octopus_exporter/rates.go index 6d925a9..9b42631 100644 --- a/cmd/octopus_exporter/rates.go +++ b/cmd/octopus_exporter/rates.go @@ -31,11 +31,16 @@ func getRates(token string) (*tariffRates, error) { rates := &tariffRates{} - accounts, _ := result["data"].(map[string]any)["viewer"].(map[string]any)["accounts"].([]any) + data, _ := result["data"].(map[string]any) + viewer, _ := data["viewer"].(map[string]any) + accounts, _ := viewer["accounts"].([]any) for _, a := range accounts { props, _ := a.(map[string]any)["properties"].([]any) for _, p := range props { - pm := p.(map[string]any) + pm, ok := p.(map[string]any) + if !ok { + continue + } for _, mp := range toSlice(pm["electricityMeterPoints"]) { if tariff := activeAgreementTariff(mp); tariff != nil { @@ -44,7 +49,6 @@ func getRates(token string) (*tariffRates, error) { rates.ElectricityProductCode, _ = tariff["productCode"].(string) rates.ElectricityTariffCode, _ = tariff["tariffCode"].(string) // HalfHourlyTariff has no unitRate field — detect Agile by absence. - _, rates.ElectricityIsAgile = tariff["unitRates"] if _, hasUnit := tariff["unitRate"]; !hasUnit { rates.ElectricityIsAgile = true } @@ -70,7 +74,10 @@ func activeAgreementTariff(meterPoint any) map[string]any { return nil } for _, ag := range toSlice(mp["agreements"]) { - agm := ag.(map[string]any) + agm, ok := ag.(map[string]any) + if !ok { + continue + } if agm["validTo"] == nil { tariff, _ := agm["tariff"].(map[string]any) return tariff diff --git a/cmd/octopus_exporter/rest.go b/cmd/octopus_exporter/rest.go index ed10bad..f0581e9 100644 --- a/cmd/octopus_exporter/rest.go +++ b/cmd/octopus_exporter/rest.go @@ -8,7 +8,7 @@ import ( var octopusREST = "https://api.octopus.energy" -func doREST(path string, params url.Values) (map[string]any, error) { +func doREST(path string, params url.Values, key string) (map[string]any, error) { u := octopusREST + path if len(params) > 0 { u += "?" + params.Encode() @@ -18,7 +18,7 @@ func doREST(path string, params url.Values) (map[string]any, error) { if err != nil { return nil, err } - req.SetBasicAuth(apiKey, "") + req.SetBasicAuth(key, "") return req, nil }) if err != nil { diff --git a/cmd/octopus_exporter/telemetry.go b/cmd/octopus_exporter/telemetry.go index 16a9560..aa8acf8 100644 --- a/cmd/octopus_exporter/telemetry.go +++ b/cmd/octopus_exporter/telemetry.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "errors" + "fmt" ) type telemetryReading struct { @@ -21,7 +22,11 @@ func getLiveConsumption(token, deviceID string) (*telemetryReading, error) { return nil, err } - telemetry := toSlice(result["data"].(map[string]any)["smartMeterTelemetry"]) + data, ok := result["data"].(map[string]any) + if !ok { + return nil, fmt.Errorf("unexpected API response: missing data field") + } + telemetry := toSlice(data["smartMeterTelemetry"]) if len(telemetry) == 0 { return nil, errors.New("no telemetry data returned") } diff --git a/cmd/octopus_exporter/telemetry_test.go b/cmd/octopus_exporter/telemetry_test.go new file mode 100644 index 0000000..8b9a85a --- /dev/null +++ b/cmd/octopus_exporter/telemetry_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetLiveConsumption_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":{"smartMeterTelemetry":[ + {"readAt":"2026-04-24T10:00:00Z","consumption":"1.23","demand":"456.7"} + ]}}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + reading, err := getLiveConsumption("test-token", "device-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if float64(reading.Demand) != 456.7 { + t.Errorf("demand: got %v, want 456.7", reading.Demand) + } + if float64(reading.Consumption) != 1.23 { + t.Errorf("consumption: got %v, want 1.23", reading.Consumption) + } + if reading.ReadAt != "2026-04-24T10:00:00Z" { + t.Errorf("readAt: got %q, want %q", reading.ReadAt, "2026-04-24T10:00:00Z") + } +} + +func TestGetLiveConsumption_EmptyTelemetry(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":{"smartMeterTelemetry":[]}}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + _, err := getLiveConsumption("test-token", "device-123") + if err == nil { + t.Error("expected error for empty telemetry, got nil") + } +} + +func TestGetLiveConsumption_MissingDataField(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":null}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + _, err := getLiveConsumption("test-token", "device-123") + if err == nil { + t.Error("expected error for null data, got nil") + } +} + +func TestGetLiveConsumption_GraphQLError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"errors":[{"message":"device not found"}]}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + _, err := getLiveConsumption("test-token", "device-123") + if err == nil { + t.Error("expected error for GraphQL error, got nil") + } +}