Compare commits

...

1 commit
v1.1.1 ... main

Author SHA1 Message Date
dd1e39769e
fix: more proper setup and lots of quality of life changes (#10)
Some checks failed
Release / release (push) Has been cancelled
2026-04-27 20:01:32 +01:00
15 changed files with 608 additions and 115 deletions

View file

@ -5,20 +5,14 @@ on:
branches: [main] branches: [main]
jobs: jobs:
build: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up QEMU - uses: actions/setup-go@v5
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
with: with:
context: . go-version-file: go.mod
push: false
platforms: linux/amd64,linux/arm64 - name: Test
run: go test -race ./...

View file

@ -1,4 +1,4 @@
FROM golang:1.22-alpine AS builder FROM golang:1.24-alpine AS builder
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download

View file

@ -9,7 +9,7 @@ import (
// getCurrentAgileRate returns the unit rate (inc. VAT, pence/kWh) for the current // getCurrentAgileRate returns the unit rate (inc. VAT, pence/kWh) for the current
// half-hour slot from the Agile tariff REST endpoint. // 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() now := time.Now().UTC()
slotStart := now.Truncate(30 * time.Minute) slotStart := now.Truncate(30 * time.Minute)
slotEnd := slotStart.Add(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{ result, err := doREST(path, url.Values{
"period_from": {slotStart.Format(time.RFC3339)}, "period_from": {slotStart.Format(time.RFC3339)},
"period_to": {slotEnd.Format(time.RFC3339)}, "period_to": {slotEnd.Format(time.RFC3339)},
}) }, key)
if err != nil { if err != nil {
return 0, err return 0, err
} }

View file

@ -15,9 +15,8 @@ func TestGetCurrentAgileRate_Success(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
octopusREST = srv.URL 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 { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -32,9 +31,8 @@ func TestGetCurrentAgileRate_NoSlot(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
octopusREST = srv.URL 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 { if err == nil {
t.Error("expected error for empty slot, got nil") t.Error("expected error for empty slot, got nil")
} }
@ -48,9 +46,8 @@ func TestGetCurrentAgileRate_CorrectPath(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
octopusREST = srv.URL 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/" want := "/v1/products/AGILE-24-10-01/electricity-tariffs/E-1R-AGILE-24-10-01-C/standard-unit-rates/"
if capturedPath != want { if capturedPath != want {

View file

@ -11,11 +11,14 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/prometheus/client_golang/prometheus"
) )
var ( var (
octopusGraphQL = "https://api.octopus.energy/v1/graphql/" octopusGraphQL = "https://api.octopus.energy/v1/graphql/"
httpClient = &http.Client{Timeout: 15 * time.Second} httpClient = &http.Client{Timeout: 15 * time.Second}
rateLimitRetries prometheus.Counter
) )
type gqlRequest struct { type gqlRequest struct {
@ -64,6 +67,9 @@ func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) {
if attempt == maxRetries { if attempt == maxRetries {
return nil, errors.New("rate limited: max retries exceeded") return nil, errors.New("rate limited: max retries exceeded")
} }
if rateLimitRetries != nil {
rateLimitRetries.Inc()
}
wait := backoff wait := backoff
if ra := resp.Header.Get("Retry-After"); ra != "" { if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil { if secs, err := strconv.Atoi(ra); err == nil {
@ -80,6 +86,13 @@ func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) {
if err != nil { if err != nil {
return nil, err 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 raw, nil
} }
return nil, errors.New("rate limited: max retries exceeded") return nil, errors.New("rate limited: max retries exceeded")

View file

@ -2,6 +2,11 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing" "testing"
) )
@ -51,3 +56,66 @@ func TestToSlice_WrongType(t *testing.T) {
t.Errorf("expected nil for wrong type, got %v", got) 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)
}
}

View file

@ -12,7 +12,7 @@ type consumptionReading struct {
IntervalStart time.Time 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 var path string
switch kind { switch kind {
case electricity: case electricity:
@ -25,7 +25,7 @@ func getLatestConsumption(kind meterKind, id, serial string) (*consumptionReadin
result, err := doREST(path, url.Values{ result, err := doREST(path, url.Values{
"period_from": {time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)}, "period_from": {time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)},
"order_by": {"period"}, "order_by": {"period"},
}) }, key)
if err != nil { if err != nil {
return nil, err 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") 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) 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 { if err != nil {
return nil, fmt.Errorf("failed to parse interval_start: %w", err) return nil, fmt.Errorf("failed to parse interval_start: %w", err)
} }

View file

@ -16,9 +16,8 @@ func TestGetLatestConsumption_ReturnsLatestInterval(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
octopusREST = srv.URL octopusREST = srv.URL
apiKey = "test"
c, err := getLatestConsumption(electricity, "MPAN123", "SERIAL456") c, err := getLatestConsumption(electricity, "MPAN123", "SERIAL456", "test")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -33,9 +32,8 @@ func TestGetLatestConsumption_Empty(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
octopusREST = srv.URL octopusREST = srv.URL
apiKey = "test"
_, err := getLatestConsumption(electricity, "MPAN123", "SERIAL456") _, err := getLatestConsumption(electricity, "MPAN123", "SERIAL456", "test")
if err == nil { if err == nil {
t.Error("expected error for empty results, got nil") t.Error("expected error for empty results, got nil")
} }
@ -51,12 +49,25 @@ func TestGetLatestConsumption_GasPath(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
octopusREST = srv.URL octopusREST = srv.URL
apiKey = "test"
getLatestConsumption(gas, "MPRN789", "SERIAL456") getLatestConsumption(gas, "MPRN789", "SERIAL456", "test")
want := "/v1/gas-meter-points/MPRN789/meters/SERIAL456/consumption/" want := "/v1/gas-meter-points/MPRN789/meters/SERIAL456/consumption/"
if capturedPath != want { if capturedPath != want {
t.Errorf("got path %q, want %q", 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")
}
}

View file

@ -5,6 +5,8 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"sync"
"sync/atomic"
"time" "time"
"github.com/prometheus/client_golang/prometheus" "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}) 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() { func main() {
apiKey = mustEnv("OCTOPUS_API_KEY") apiKey = mustEnv("OCTOPUS_API_KEY")
port = envOrDefault("PORT", "9359") port = envOrDefault("PORT", "9359")
@ -83,20 +89,27 @@ func main() {
// Account // Account
accountBalance := gauge("octopus_account_balance_pence", "Account balance in pence (positive = credit, negative = debit)") 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{ toRegister := []prometheus.Collector{
elecDemand, elecLastRead, elecDemand, elecLastRead,
elecConsumption, elecConsumptionInterval, elecConsumption, elecConsumptionInterval,
elecUnitRate, elecStandingCharge, elecUnitRate, elecStandingCharge,
accountBalance, accountBalance,
exporterUp, pollErrors, tokenRefreshCount, rateLimitRetries,
} }
var ( var (
gasDemand prometheus.Gauge gasDemand prometheus.Gauge
gasLastRead prometheus.Gauge gasLastRead prometheus.Gauge
gasConsumption prometheus.Gauge gasConsumption prometheus.Gauge
gasConsumptionInterval prometheus.Gauge gasConsumptionInterval prometheus.Gauge
gasUnitRate prometheus.Gauge gasUnitRate prometheus.Gauge
gasStandCharge prometheus.Gauge gasStandCharge prometheus.Gauge
) )
if gasMeter != nil { if gasMeter != nil {
gasDemand = gauge("octopus_gas_demand_watts", "Live gas demand in watts") 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) { if !errors.Is(err, errTokenExpired) {
return return err
} }
t, e := getKrakenToken(apiKey)
if e != nil { // Only one goroutine refreshes; others will pick up the new token.
log.Printf("token refresh failed: %v", e) tokenMu.Lock()
return 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 { 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) // Electricity telemetry (live demand)
if elecMeter.deviceID != "" { if elecMeter.deviceID != "" {
reading, err := getLiveConsumption(token, elecMeter.deviceID) collect("electricity telemetry", func() error {
if err != nil { return withToken(func(t string) error {
log.Printf("electricity telemetry error: %v", err) reading, err := getLiveConsumption(t, elecMeter.deviceID)
tryRefresh(err) if err != nil {
} else { return err
elecDemand.Set(float64(reading.Demand)) }
if t, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil { elecDemand.Set(float64(reading.Demand))
elecLastRead.Set(float64(t.Unix())) if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
} elecLastRead.Set(float64(ts.Unix()))
} }
return nil
})
})
} }
// Gas telemetry (live demand) // Gas telemetry (live demand)
if gasMeter != nil && gasMeter.deviceID != "" { if gasMeter != nil && gasMeter.deviceID != "" {
reading, err := getLiveConsumption(token, gasMeter.deviceID) collect("gas telemetry", func() error {
if err != nil { return withToken(func(t string) error {
log.Printf("gas telemetry error: %v", err) reading, err := getLiveConsumption(t, gasMeter.deviceID)
tryRefresh(err) if err != nil {
} else { return err
gasDemand.Set(float64(reading.Demand)) }
if t, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil { gasDemand.Set(float64(reading.Demand))
gasLastRead.Set(float64(t.Unix())) if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
} gasLastRead.Set(float64(ts.Unix()))
} }
return nil
})
})
} }
// Electricity half-hourly consumption (REST) // Electricity half-hourly consumption (REST)
if elecMeter.mpan != "" && elecMeter.serial != "" { if elecMeter.mpan != "" && elecMeter.serial != "" {
c, err := getLatestConsumption(electricity, elecMeter.mpan, elecMeter.serial) collect("electricity consumption", func() error {
if err != nil { c, err := getLatestConsumption(electricity, elecMeter.mpan, elecMeter.serial, apiKey)
log.Printf("electricity consumption error: %v", err) if err != nil {
} else { return err
}
elecConsumption.Set(c.KWh) elecConsumption.Set(c.KWh)
elecConsumptionInterval.Set(float64(c.IntervalStart.Unix())) elecConsumptionInterval.Set(float64(c.IntervalStart.Unix()))
} return nil
})
} }
// Gas half-hourly consumption (REST) // Gas half-hourly consumption (REST)
if gasMeter != nil && gasMeter.mprn != "" && gasMeter.serial != "" { if gasMeter != nil && gasMeter.mprn != "" && gasMeter.serial != "" {
c, err := getLatestConsumption(gas, gasMeter.mprn, gasMeter.serial) collect("gas consumption", func() error {
if err != nil { c, err := getLatestConsumption(gas, gasMeter.mprn, gasMeter.serial, apiKey)
log.Printf("gas consumption error: %v", err) if err != nil {
} else { return err
}
gasConsumption.Set(c.KWh) gasConsumption.Set(c.KWh)
gasConsumptionInterval.Set(float64(c.IntervalStart.Unix())) gasConsumptionInterval.Set(float64(c.IntervalStart.Unix()))
} return nil
})
} }
// Tariff rates // Tariff rates (result needed for optional agile lookup after wg.Wait)
rates, err := getRates(token) var collectedRates *tariffRates
if err != nil { collect("rates", func() error {
log.Printf("rates error: %v", err) return withToken(func(t string) error {
tryRefresh(err) r, err := getRates(t)
} else {
unitRate := rates.ElectricityUnitRate
if rates.ElectricityIsAgile && rates.ElectricityProductCode != "" && rates.ElectricityTariffCode != "" {
agileRate, err := getCurrentAgileRate(rates.ElectricityProductCode, rates.ElectricityTariffCode)
if err != nil { 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 { } else {
unitRate = agileRate unitRate = agileRate
} }
} }
elecUnitRate.Set(unitRate) elecUnitRate.Set(unitRate)
elecStandingCharge.Set(rates.ElectricityStandingCharge) elecStandingCharge.Set(collectedRates.ElectricityStandingCharge)
if gasMeter != nil { if gasMeter != nil {
gasUnitRate.Set(rates.GasUnitRate) gasUnitRate.Set(collectedRates.GasUnitRate)
gasStandCharge.Set(rates.GasStandingCharge) gasStandCharge.Set(collectedRates.GasStandingCharge)
} }
} }
// Account balance if failedAny.Load() {
balance, err := getAccountBalance(token) exporterUp.Set(0)
if err != nil {
log.Printf("account balance error: %v", err)
tryRefresh(err)
} else { } else {
accountBalance.Set(balance) exporterUp.Set(1)
} }
time.Sleep(60 * time.Second) time.Sleep(60 * time.Second)

View file

@ -53,7 +53,9 @@ func getMeters(token string) ([]meterCandidate, error) {
var candidates []meterCandidate 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 { for _, a := range accounts {
props, _ := a.(map[string]any)["properties"].([]any) props, _ := a.(map[string]any)["properties"].([]any)
for _, p := range props { for _, p := range props {
@ -63,33 +65,59 @@ func getMeters(token string) ([]meterCandidate, error) {
} }
for _, mp := range toSlice(pm["electricityMeterPoints"]) { for _, mp := range toSlice(pm["electricityMeterPoints"]) {
mpan, _ := mp.(map[string]any)["mpan"].(string) mpPoint, ok := mp.(map[string]any)
for _, m := range toSlice(mp.(map[string]any)["meters"]) { if !ok {
serial, _ := m.(map[string]any)["serialNumber"].(string) continue
for _, d := range toSlice(m.(map[string]any)["smartDevices"]) { }
deviceID, _ := d.(map[string]any)["deviceId"].(string) 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 != "" { if deviceID != "" {
candidates = append(candidates, meterCandidate{kind: electricity, mpan: mpan, serial: serial, deviceID: 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. // 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}) candidates = append(candidates, meterCandidate{kind: electricity, mpan: mpan, serial: serial})
} }
} }
} }
for _, mp := range toSlice(pm["gasMeterPoints"]) { for _, mp := range toSlice(pm["gasMeterPoints"]) {
mprn, _ := mp.(map[string]any)["mprn"].(string) mpPoint, ok := mp.(map[string]any)
for _, m := range toSlice(mp.(map[string]any)["meters"]) { if !ok {
serial, _ := m.(map[string]any)["serialNumber"].(string) continue
for _, d := range toSlice(m.(map[string]any)["smartDevices"]) { }
deviceID, _ := d.(map[string]any)["deviceId"].(string) 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 != "" { if deviceID != "" {
candidates = append(candidates, meterCandidate{kind: gas, mprn: mprn, serial: serial, deviceID: 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}) candidates = append(candidates, meterCandidate{kind: gas, mprn: mprn, serial: serial})
} }
} }

View file

@ -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")
}
}

View file

@ -31,11 +31,16 @@ func getRates(token string) (*tariffRates, error) {
rates := &tariffRates{} 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 { for _, a := range accounts {
props, _ := a.(map[string]any)["properties"].([]any) props, _ := a.(map[string]any)["properties"].([]any)
for _, p := range props { for _, p := range props {
pm := p.(map[string]any) pm, ok := p.(map[string]any)
if !ok {
continue
}
for _, mp := range toSlice(pm["electricityMeterPoints"]) { for _, mp := range toSlice(pm["electricityMeterPoints"]) {
if tariff := activeAgreementTariff(mp); tariff != nil { if tariff := activeAgreementTariff(mp); tariff != nil {
@ -44,7 +49,6 @@ func getRates(token string) (*tariffRates, error) {
rates.ElectricityProductCode, _ = tariff["productCode"].(string) rates.ElectricityProductCode, _ = tariff["productCode"].(string)
rates.ElectricityTariffCode, _ = tariff["tariffCode"].(string) rates.ElectricityTariffCode, _ = tariff["tariffCode"].(string)
// HalfHourlyTariff has no unitRate field — detect Agile by absence. // HalfHourlyTariff has no unitRate field — detect Agile by absence.
_, rates.ElectricityIsAgile = tariff["unitRates"]
if _, hasUnit := tariff["unitRate"]; !hasUnit { if _, hasUnit := tariff["unitRate"]; !hasUnit {
rates.ElectricityIsAgile = true rates.ElectricityIsAgile = true
} }
@ -70,7 +74,10 @@ func activeAgreementTariff(meterPoint any) map[string]any {
return nil return nil
} }
for _, ag := range toSlice(mp["agreements"]) { for _, ag := range toSlice(mp["agreements"]) {
agm := ag.(map[string]any) agm, ok := ag.(map[string]any)
if !ok {
continue
}
if agm["validTo"] == nil { if agm["validTo"] == nil {
tariff, _ := agm["tariff"].(map[string]any) tariff, _ := agm["tariff"].(map[string]any)
return tariff return tariff

View file

@ -8,7 +8,7 @@ import (
var octopusREST = "https://api.octopus.energy" 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 u := octopusREST + path
if len(params) > 0 { if len(params) > 0 {
u += "?" + params.Encode() u += "?" + params.Encode()
@ -18,7 +18,7 @@ func doREST(path string, params url.Values) (map[string]any, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.SetBasicAuth(apiKey, "") req.SetBasicAuth(key, "")
return req, nil return req, nil
}) })
if err != nil { if err != nil {

View file

@ -3,6 +3,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
) )
type telemetryReading struct { type telemetryReading struct {
@ -21,7 +22,11 @@ func getLiveConsumption(token, deviceID string) (*telemetryReading, error) {
return nil, err 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 { if len(telemetry) == 0 {
return nil, errors.New("no telemetry data returned") return nil, errors.New("no telemetry data returned")
} }

View file

@ -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")
}
}