diff --git a/.gitignore b/.gitignore index 4c49bd7..fdcd106 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .env +coverage.out diff --git a/README.md b/README.md index 6371ac1..a43f615 100644 --- a/README.md +++ b/README.md @@ -6,29 +6,33 @@ A Prometheus exporter for Octopus Energy smart meter data, using the Kraken Grap ### Electricity -| Metric | Description | -|---|---| -| `octopus_electricity_demand_watts` | Live electricity demand in watts | -| `octopus_electricity_last_read_timestamp` | Unix timestamp of last electricity reading | -| `octopus_electricity_unit_rate_pence` | Current unit rate in pence per kWh | -| `octopus_electricity_standing_charge_pence` | Current standing charge in pence per day | +| Metric | Source | Description | +|---|---|---| +| `octopus_electricity_demand_watts` | GraphQL | Live electricity demand in watts | +| `octopus_electricity_last_read_timestamp` | GraphQL | Unix timestamp of last electricity reading | +| `octopus_electricity_consumption_kwh` | REST | Latest half-hourly consumption in kWh | +| `octopus_electricity_consumption_interval_timestamp` | REST | Unix timestamp of the start of the latest consumption interval | +| `octopus_electricity_unit_rate_pence` | GraphQL / REST | Current unit rate in pence per kWh (Agile customers get the live half-hourly rate from the REST API) | +| `octopus_electricity_standing_charge_pence` | GraphQL | Current standing charge in pence per day | ### Gas Gas metrics are only exposed if a smart gas meter is found on the account. -| Metric | Description | -|---|---| -| `octopus_gas_demand_watts` | Live gas demand in watts | -| `octopus_gas_last_read_timestamp` | Unix timestamp of last gas reading | -| `octopus_gas_unit_rate_pence` | Current unit rate in pence per kWh | -| `octopus_gas_standing_charge_pence` | Current standing charge in pence per day | +| Metric | Source | Description | +|---|---|---| +| `octopus_gas_demand_watts` | GraphQL | Live gas demand in watts | +| `octopus_gas_last_read_timestamp` | GraphQL | Unix timestamp of last gas reading | +| `octopus_gas_consumption_kwh` | REST | Latest half-hourly consumption in kWh | +| `octopus_gas_consumption_interval_timestamp` | REST | Unix timestamp of the start of the latest gas consumption interval | +| `octopus_gas_unit_rate_pence` | GraphQL | Current unit rate in pence per kWh | +| `octopus_gas_standing_charge_pence` | GraphQL | Current standing charge in pence per day | ### Account -| Metric | Description | -|---|---| -| `octopus_account_balance_pence` | Account balance in pence (positive = credit, negative = debit) | +| Metric | Source | Description | +|---|---|---| +| `octopus_account_balance_pence` | GraphQL | Account balance in pence (positive = credit, negative = debit) | Metrics are updated every 60 seconds. diff --git a/cmd/octopus_exporter/account_test.go b/cmd/octopus_exporter/account_test.go new file mode 100644 index 0000000..e4930ad --- /dev/null +++ b/cmd/octopus_exporter/account_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetAccountBalance_Credit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"balance":4250}]}}}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + balance, err := getAccountBalance("token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balance != 4250 { + t.Errorf("got %v, want 4250", balance) + } +} + +func TestGetAccountBalance_Debit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"balance":-1500}]}}}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + balance, err := getAccountBalance("token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if balance != -1500 { + t.Errorf("got %v, want -1500", balance) + } +} + +func TestGetAccountBalance_Missing(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{}]}}}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + _, err := getAccountBalance("token") + if err == nil { + t.Error("expected error when balance is missing, got nil") + } +} diff --git a/cmd/octopus_exporter/agile.go b/cmd/octopus_exporter/agile.go new file mode 100644 index 0000000..a44bfef --- /dev/null +++ b/cmd/octopus_exporter/agile.go @@ -0,0 +1,36 @@ +package main + +import ( + "errors" + "fmt" + "net/url" + "time" +) + +// 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) { + now := time.Now().UTC() + slotStart := now.Truncate(30 * time.Minute) + slotEnd := slotStart.Add(30 * time.Minute) + + path := fmt.Sprintf("/v1/products/%s/electricity-tariffs/%s/standard-unit-rates/", productCode, tariffCode) + result, err := doREST(path, url.Values{ + "period_from": {slotStart.Format(time.RFC3339)}, + "period_to": {slotEnd.Format(time.RFC3339)}, + }) + if err != nil { + return 0, err + } + + results := toSlice(result["results"]) + if len(results) == 0 { + return 0, errors.New("no Agile rate found for current slot") + } + + rate, ok := results[0].(map[string]any)["value_inc_vat"].(float64) + if !ok { + return 0, errors.New("value_inc_vat missing from Agile rate response") + } + return rate, nil +} diff --git a/cmd/octopus_exporter/agile_test.go b/cmd/octopus_exporter/agile_test.go new file mode 100644 index 0000000..c8ea650 --- /dev/null +++ b/cmd/octopus_exporter/agile_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetCurrentAgileRate_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"count":1,"results":[ + {"value_exc_vat":21.68,"value_inc_vat":22.764,"valid_from":"2026-04-24T20:00:00Z","valid_to":"2026-04-24T20:30:00Z","payment_method":null} + ]}`) + })) + defer srv.Close() + octopusREST = srv.URL + apiKey = "test" + + rate, err := getCurrentAgileRate("AGILE-24-10-01", "E-1R-AGILE-24-10-01-C") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rate != 22.764 { + t.Errorf("got rate %v, want 22.764", rate) + } +} + +func TestGetCurrentAgileRate_NoSlot(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"count":0,"results":[]}`) + })) + defer srv.Close() + octopusREST = srv.URL + apiKey = "test" + + _, err := getCurrentAgileRate("AGILE-24-10-01", "E-1R-AGILE-24-10-01-C") + if err == nil { + t.Error("expected error for empty slot, got nil") + } +} + +func TestGetCurrentAgileRate_CorrectPath(t *testing.T) { + var capturedPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + fmt.Fprint(w, `{"count":1,"results":[{"value_inc_vat":22.764}]}`) + })) + defer srv.Close() + octopusREST = srv.URL + apiKey = "test" + + getCurrentAgileRate("AGILE-24-10-01", "E-1R-AGILE-24-10-01-C") + + want := "/v1/products/AGILE-24-10-01/electricity-tariffs/E-1R-AGILE-24-10-01-C/standard-unit-rates/" + if capturedPath != want { + t.Errorf("got path %q, want %q", capturedPath, want) + } +} diff --git a/cmd/octopus_exporter/auth_test.go b/cmd/octopus_exporter/auth_test.go new file mode 100644 index 0000000..27be8e0 --- /dev/null +++ b/cmd/octopus_exporter/auth_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetKrakenToken_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":{"obtainKrakenToken":{"token":"test-token-123"}}}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + token, err := getKrakenToken("test-api-key") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token != "test-token-123" { + t.Errorf("got token %q, want %q", token, "test-token-123") + } +} + +func TestGetKrakenToken_GraphQLError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"errors":[{"message":"Invalid API key"}]}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + _, err := getKrakenToken("bad-key") + if err == nil { + t.Fatal("expected error, got nil") + } +} diff --git a/cmd/octopus_exporter/client.go b/cmd/octopus_exporter/client.go index 9c97d31..9226325 100644 --- a/cmd/octopus_exporter/client.go +++ b/cmd/octopus_exporter/client.go @@ -10,7 +10,7 @@ import ( "strconv" ) -const octopusGraphQL = "https://api.octopus.energy/v1/graphql/" +var octopusGraphQL = "https://api.octopus.energy/v1/graphql/" type gqlRequest struct { OperationName string `json:"operationName,omitempty"` diff --git a/cmd/octopus_exporter/client_test.go b/cmd/octopus_exporter/client_test.go new file mode 100644 index 0000000..76e8915 --- /dev/null +++ b/cmd/octopus_exporter/client_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func TestJsonFloat_Number(t *testing.T) { + var f jsonFloat + if err := json.Unmarshal([]byte(`42.5`), &f); err != nil { + t.Fatal(err) + } + if float64(f) != 42.5 { + t.Errorf("got %v, want 42.5", f) + } +} + +func TestJsonFloat_QuotedString(t *testing.T) { + var f jsonFloat + if err := json.Unmarshal([]byte(`"42.5"`), &f); err != nil { + t.Fatal(err) + } + if float64(f) != 42.5 { + t.Errorf("got %v, want 42.5", f) + } +} + +func TestJsonFloat_InvalidString(t *testing.T) { + var f jsonFloat + if err := json.Unmarshal([]byte(`"not-a-number"`), &f); err == nil { + t.Error("expected error for non-numeric string, got nil") + } +} + +func TestToSlice_Slice(t *testing.T) { + input := []any{1, 2, 3} + got := toSlice(input) + if len(got) != 3 { + t.Errorf("got len %d, want 3", len(got)) + } +} + +func TestToSlice_Nil(t *testing.T) { + if got := toSlice(nil); got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestToSlice_WrongType(t *testing.T) { + if got := toSlice("not a slice"); got != nil { + t.Errorf("expected nil for wrong type, got %v", got) + } +} diff --git a/cmd/octopus_exporter/consumption.go b/cmd/octopus_exporter/consumption.go new file mode 100644 index 0000000..c3724a7 --- /dev/null +++ b/cmd/octopus_exporter/consumption.go @@ -0,0 +1,46 @@ +package main + +import ( + "errors" + "fmt" + "net/url" + "time" +) + +type consumptionReading struct { + KWh float64 + IntervalStart time.Time +} + +func getLatestConsumption(kind meterKind, id, serial string) (*consumptionReading, error) { + var path string + switch kind { + case electricity: + path = fmt.Sprintf("/v1/electricity-meter-points/%s/meters/%s/consumption/", id, serial) + case gas: + path = fmt.Sprintf("/v1/gas-meter-points/%s/meters/%s/consumption/", id, serial) + } + + // Consumption data can lag several hours, so use a 24h window and take the latest entry. + result, err := doREST(path, url.Values{ + "period_from": {time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)}, + "order_by": {"period"}, + }) + if err != nil { + return nil, err + } + + results := toSlice(result["results"]) + if len(results) == 0 { + return nil, errors.New("no consumption data in last 24h") + } + + latest := results[len(results)-1].(map[string]any) + kwh, _ := latest["consumption"].(float64) + start, err := time.Parse(time.RFC3339, latest["interval_start"].(string)) + if err != nil { + return nil, fmt.Errorf("failed to parse interval_start: %w", err) + } + + return &consumptionReading{KWh: kwh, IntervalStart: start}, nil +} diff --git a/cmd/octopus_exporter/consumption_test.go b/cmd/octopus_exporter/consumption_test.go new file mode 100644 index 0000000..f828226 --- /dev/null +++ b/cmd/octopus_exporter/consumption_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetLatestConsumption_ReturnsLatestInterval(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"count":2,"results":[ + {"consumption":0.304,"interval_start":"2026-04-23T22:00:00+00:00","interval_end":"2026-04-23T22:30:00+00:00"}, + {"consumption":0.512,"interval_start":"2026-04-23T22:30:00+00:00","interval_end":"2026-04-23T23:00:00+00:00"} + ]}`) + })) + defer srv.Close() + octopusREST = srv.URL + apiKey = "test" + + c, err := getLatestConsumption(electricity, "MPAN123", "SERIAL456") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.KWh != 0.512 { + t.Errorf("got kWh %v, want 0.512", c.KWh) + } +} + +func TestGetLatestConsumption_Empty(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"count":0,"results":[]}`) + })) + defer srv.Close() + octopusREST = srv.URL + apiKey = "test" + + _, err := getLatestConsumption(electricity, "MPAN123", "SERIAL456") + if err == nil { + t.Error("expected error for empty results, got nil") + } +} + +func TestGetLatestConsumption_GasPath(t *testing.T) { + var capturedPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + fmt.Fprint(w, `{"count":1,"results":[ + {"consumption":1.23,"interval_start":"2026-04-23T22:00:00+00:00","interval_end":"2026-04-23T22:30:00+00:00"} + ]}`) + })) + defer srv.Close() + octopusREST = srv.URL + apiKey = "test" + + getLatestConsumption(gas, "MPRN789", "SERIAL456") + + want := "/v1/gas-meter-points/MPRN789/meters/SERIAL456/consumption/" + if capturedPath != want { + t.Errorf("got path %q, want %q", capturedPath, want) + } +} diff --git a/cmd/octopus_exporter/main.go b/cmd/octopus_exporter/main.go index 7846b2c..6847687 100644 --- a/cmd/octopus_exporter/main.go +++ b/cmd/octopus_exporter/main.go @@ -11,8 +11,8 @@ import ( ) var ( - apiKey = mustEnv("OCTOPUS_API_KEY") - port = envOrDefault("PORT", "9359") + apiKey string + port string ) func mustEnv(key string) string { @@ -35,31 +35,40 @@ func gauge(name, help string) prometheus.Gauge { } func main() { + apiKey = mustEnv("OCTOPUS_API_KEY") + port = envOrDefault("PORT", "9359") + token, err := getKrakenToken(apiKey) if err != nil { log.Fatalf("failed to get initial token: %v", err) } - elecDeviceID, err := resolveDeviceID(token, electricity) + elecMeter, err := resolveMeter(token, electricity) if err != nil { log.Fatalf("failed to resolve electricity meter: %v", err) } - if elecDeviceID == "" { + if elecMeter == nil { log.Fatal("no electricity smart meter found on account") } - gasDeviceID, err := resolveDeviceID(token, gas) + gasMeter, err := resolveMeter(token, gas) if err != nil { log.Fatalf("failed to resolve gas meter: %v", err) } - if gasDeviceID == "" { + if gasMeter == nil { log.Println("no gas smart meter found — gas metrics disabled") } - // Electricity telemetry + // --- Metrics --- + + // Electricity telemetry (live, from GraphQL) elecDemand := gauge("octopus_electricity_demand_watts", "Live electricity demand in watts") elecLastRead := gauge("octopus_electricity_last_read_timestamp", "Unix timestamp of last electricity reading") + // Electricity consumption (half-hourly kWh, from REST) + elecConsumption := gauge("octopus_electricity_consumption_kwh", "Half-hourly electricity consumption in kWh") + elecConsumptionInterval := gauge("octopus_electricity_consumption_interval_timestamp", "Unix timestamp of the start of the latest consumption interval") + // Electricity tariff elecUnitRate := gauge("octopus_electricity_unit_rate_pence", "Current electricity unit rate in pence per kWh") elecStandingCharge := gauge("octopus_electricity_standing_charge_pence", "Current electricity standing charge in pence per day") @@ -67,20 +76,29 @@ func main() { // Account accountBalance := gauge("octopus_account_balance_pence", "Account balance in pence (positive = credit, negative = debit)") - toRegister := []prometheus.Collector{elecDemand, elecLastRead, elecUnitRate, elecStandingCharge, accountBalance} + toRegister := []prometheus.Collector{ + elecDemand, elecLastRead, + elecConsumption, elecConsumptionInterval, + elecUnitRate, elecStandingCharge, + accountBalance, + } var ( - gasDemand prometheus.Gauge - gasLastRead prometheus.Gauge - gasUnitRate prometheus.Gauge - gasStandCharge prometheus.Gauge + gasDemand prometheus.Gauge + gasLastRead prometheus.Gauge + gasConsumption prometheus.Gauge + gasConsumptionInterval prometheus.Gauge + gasUnitRate prometheus.Gauge + gasStandCharge prometheus.Gauge ) - if gasDeviceID != "" { + if gasMeter != nil { gasDemand = gauge("octopus_gas_demand_watts", "Live gas demand in watts") gasLastRead = gauge("octopus_gas_last_read_timestamp", "Unix timestamp of last gas reading") + gasConsumption = gauge("octopus_gas_consumption_kwh", "Half-hourly gas consumption in kWh") + gasConsumptionInterval = gauge("octopus_gas_consumption_interval_timestamp", "Unix timestamp of the start of the latest gas consumption interval") gasUnitRate = gauge("octopus_gas_unit_rate_pence", "Current gas unit rate in pence per kWh") gasStandCharge = gauge("octopus_gas_standing_charge_pence", "Current gas standing charge in pence per day") - toRegister = append(toRegister, gasDemand, gasLastRead, gasUnitRate, gasStandCharge) + toRegister = append(toRegister, gasDemand, gasLastRead, gasConsumption, gasConsumptionInterval, gasUnitRate, gasStandCharge) } prometheus.MustRegister(toRegister...) @@ -94,23 +112,25 @@ func main() { }() for { - // Electricity telemetry - reading, err := getLiveConsumption(token, elecDeviceID) - if err != nil { - log.Printf("electricity telemetry error: %v", err) - if token, err = getKrakenToken(apiKey); err != nil { - log.Printf("token refresh failed: %v", err) - } - } else { - elecDemand.Set(float64(reading.Demand)) - if t, err := time.Parse("2006-01-02T15:04:05+00:00", reading.ReadAt); err == nil { - elecLastRead.Set(float64(t.Unix())) + // Electricity telemetry (live demand) + if elecMeter.deviceID != "" { + reading, err := getLiveConsumption(token, elecMeter.deviceID) + if err != nil { + log.Printf("electricity telemetry error: %v", err) + if token, err = getKrakenToken(apiKey); err != nil { + log.Printf("token refresh failed: %v", err) + } + } else { + elecDemand.Set(float64(reading.Demand)) + if t, err := time.Parse("2006-01-02T15:04:05+00:00", reading.ReadAt); err == nil { + elecLastRead.Set(float64(t.Unix())) + } } } - // Gas telemetry - if gasDeviceID != "" { - reading, err := getLiveConsumption(token, gasDeviceID) + // 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) } else { @@ -121,14 +141,46 @@ func main() { } } - // Rates + // 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 { + elecConsumption.Set(c.KWh) + elecConsumptionInterval.Set(float64(c.IntervalStart.Unix())) + } + } + + // 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 { + gasConsumption.Set(c.KWh) + gasConsumptionInterval.Set(float64(c.IntervalStart.Unix())) + } + } + + // Tariff rates rates, err := getRates(token) if err != nil { log.Printf("rates error: %v", err) } else { - elecUnitRate.Set(rates.ElectricityUnitRate) + unitRate := rates.ElectricityUnitRate + if rates.ElectricityIsAgile && rates.ElectricityProductCode != "" && rates.ElectricityTariffCode != "" { + agileRate, err := getCurrentAgileRate(rates.ElectricityProductCode, rates.ElectricityTariffCode) + if err != nil { + log.Printf("agile rate error: %v", err) + } else { + unitRate = agileRate + } + } + elecUnitRate.Set(unitRate) elecStandingCharge.Set(rates.ElectricityStandingCharge) - if gasDeviceID != "" { + + if gasMeter != nil { gasUnitRate.Set(rates.GasUnitRate) gasStandCharge.Set(rates.GasStandingCharge) } diff --git a/cmd/octopus_exporter/meters.go b/cmd/octopus_exporter/meters.go index c3106ae..3345058 100644 --- a/cmd/octopus_exporter/meters.go +++ b/cmd/octopus_exporter/meters.go @@ -1,7 +1,6 @@ package main import ( - "errors" "fmt" "log" "os" @@ -22,6 +21,13 @@ type meterCandidate struct { deviceID string } +type resolvedMeter struct { + deviceID string + mpan string // electricity + mprn string // gas + serial string +} + func getMeters(token string) ([]meterCandidate, error) { result, err := doGraphQL(gqlRequest{ Query: `{ viewer { accounts { ... on AccountType { properties { @@ -63,6 +69,10 @@ func getMeters(token string) ([]meterCandidate, error) { 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 != "" { + candidates = append(candidates, meterCandidate{kind: electricity, mpan: mpan, serial: serial}) + } } } @@ -76,6 +86,9 @@ func getMeters(token string) ([]meterCandidate, error) { candidates = append(candidates, meterCandidate{kind: gas, mprn: mprn, serial: serial, deviceID: deviceID}) } } + if len(toSlice(m.(map[string]any)["smartDevices"])) == 0 && serial != "" { + candidates = append(candidates, meterCandidate{kind: gas, mprn: mprn, serial: serial}) + } } } } @@ -84,9 +97,9 @@ func getMeters(token string) ([]meterCandidate, error) { return candidates, nil } -// resolveDeviceID finds the device ID for the given meter kind using environment -// variable filters. Returns ("", nil) if no meter of that kind exists on the account. -func resolveDeviceID(token string, kind meterKind) (string, error) { +// resolveMeter finds the meter matching the env var filters for the given kind. +// Returns (nil, nil) if no meter of that kind exists on the account. +func resolveMeter(token string, kind meterKind) (*resolvedMeter, error) { var wantDeviceID, wantID, wantSerial string switch kind { case electricity: @@ -99,14 +112,10 @@ func resolveDeviceID(token string, kind meterKind) (string, error) { wantSerial = os.Getenv("OCTOPUS_GAS_SERIAL") } - if wantDeviceID != "" && wantID == "" && wantSerial == "" { - return wantDeviceID, nil - } - log.Printf("discovering %s meters from account...", kind) candidates, err := getMeters(token) if err != nil { - return "", err + return nil, err } for _, c := range candidates { @@ -127,28 +136,19 @@ func resolveDeviceID(token string, kind meterKind) (string, error) { if wantSerial != "" && c.serial != wantSerial { continue } + + m := &resolvedMeter{deviceID: c.deviceID, mpan: c.mpan, mprn: c.mprn, serial: c.serial} switch kind { case electricity: - log.Printf("using electricity meter: MPAN=%s serial=%s deviceID=%s", c.mpan, c.serial, c.deviceID) + log.Printf("using electricity meter: MPAN=%s serial=%s deviceID=%s", m.mpan, m.serial, m.deviceID) case gas: - log.Printf("using gas meter: MPRN=%s serial=%s deviceID=%s", c.mprn, c.serial, c.deviceID) + log.Printf("using gas meter: MPRN=%s serial=%s deviceID=%s", m.mprn, m.serial, m.deviceID) } - return c.deviceID, nil + return m, nil } if wantDeviceID != "" || wantID != "" || wantSerial != "" { - return "", fmt.Errorf("no %s meter matched the specified filters", kind) + return nil, fmt.Errorf("no %s meter matched the specified filters", kind) } - - // No filters set and no meter found — this kind may not be on the account. - filtered := 0 - for _, c := range candidates { - if c.kind == kind { - filtered++ - } - } - if filtered == 0 { - return "", nil - } - return "", errors.New("unexpected: meters found but none selected") + return nil, nil } diff --git a/cmd/octopus_exporter/rates.go b/cmd/octopus_exporter/rates.go index d21d9cb..6599d33 100644 --- a/cmd/octopus_exporter/rates.go +++ b/cmd/octopus_exporter/rates.go @@ -3,22 +3,22 @@ package main type tariffRates struct { ElectricityUnitRate float64 ElectricityStandingCharge float64 + ElectricityProductCode string + ElectricityTariffCode string + ElectricityIsAgile bool GasUnitRate float64 GasStandingCharge float64 } -// electricityTariffFragments covers all known electricity tariff union types. -const electricityTariffFragments = ` - ... on StandardTariff { unitRate standingCharge } - ... on HalfHourlyTariff { standingCharge } - ... on PrepayTariff { unitRate standingCharge } -` - func getRates(token string) (*tariffRates, error) { result, err := doGraphQL(gqlRequest{ Query: `{ viewer { accounts { ... on AccountType { properties { electricityMeterPoints { - agreements { validTo tariff {` + electricityTariffFragments + `} } + agreements { validTo tariff { + ... on StandardTariff { unitRate standingCharge productCode tariffCode } + ... on HalfHourlyTariff { standingCharge productCode tariffCode } + ... on PrepayTariff { unitRate standingCharge productCode tariffCode } + } } } gasMeterPoints { agreements { validTo tariff { unitRate standingCharge } } @@ -41,6 +41,13 @@ func getRates(token string) (*tariffRates, error) { if tariff := activeAgreementTariff(mp); tariff != nil { rates.ElectricityUnitRate, _ = tariff["unitRate"].(float64) rates.ElectricityStandingCharge, _ = tariff["standingCharge"].(float64) + 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 + } } } diff --git a/cmd/octopus_exporter/rates_test.go b/cmd/octopus_exporter/rates_test.go new file mode 100644 index 0000000..701e38b --- /dev/null +++ b/cmd/octopus_exporter/rates_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestActiveAgreementTariff_ActiveFound(t *testing.T) { + mp := map[string]any{ + "agreements": []any{ + map[string]any{"validTo": "2020-01-01T00:00:00Z", "tariff": map[string]any{"unitRate": 10.0}}, + map[string]any{"validTo": nil, "tariff": map[string]any{"unitRate": 26.3}}, + }, + } + tariff := activeAgreementTariff(mp) + if tariff == nil { + t.Fatal("expected tariff, got nil") + } + if tariff["unitRate"].(float64) != 26.3 { + t.Errorf("got unitRate %v, want 26.3", tariff["unitRate"]) + } +} + +func TestActiveAgreementTariff_NoneActive(t *testing.T) { + mp := map[string]any{ + "agreements": []any{ + map[string]any{"validTo": "2020-01-01T00:00:00Z", "tariff": map[string]any{"unitRate": 10.0}}, + }, + } + if tariff := activeAgreementTariff(mp); tariff != nil { + t.Errorf("expected nil, got %v", tariff) + } +} + +func TestActiveAgreementTariff_NoAgreements(t *testing.T) { + mp := map[string]any{"agreements": []any{}} + if tariff := activeAgreementTariff(mp); tariff != nil { + t.Errorf("expected nil, got %v", tariff) + } +} + +func TestGetRates_StandardTariff(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{ + "electricityMeterPoints":[{"agreements":[{"validTo":null,"tariff":{ + "unitRate":26.32,"standingCharge":54.55,"productCode":"VAR-22-11-01","tariffCode":"E-1R-VAR-22-11-01-C" + }}]}], + "gasMeterPoints":[] + }]}]}}}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + rates, err := getRates("token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rates.ElectricityUnitRate != 26.32 { + t.Errorf("unit rate: got %v, want 26.32", rates.ElectricityUnitRate) + } + if rates.ElectricityStandingCharge != 54.55 { + t.Errorf("standing charge: got %v, want 54.55", rates.ElectricityStandingCharge) + } + if rates.ElectricityTariffCode != "E-1R-VAR-22-11-01-C" { + t.Errorf("tariff code: got %q, want %q", rates.ElectricityTariffCode, "E-1R-VAR-22-11-01-C") + } + if rates.ElectricityIsAgile { + t.Error("expected IsAgile=false for StandardTariff") + } +} + +func TestGetRates_AgileTariff(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{ + "electricityMeterPoints":[{"agreements":[{"validTo":null,"tariff":{ + "standingCharge":54.55,"productCode":"AGILE-24-10-01","tariffCode":"E-1R-AGILE-24-10-01-C" + }}]}], + "gasMeterPoints":[] + }]}]}}}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + rates, err := getRates("token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !rates.ElectricityIsAgile { + t.Error("expected IsAgile=true for HalfHourlyTariff (no unitRate field)") + } + if rates.ElectricityProductCode != "AGILE-24-10-01" { + t.Errorf("product code: got %q, want %q", rates.ElectricityProductCode, "AGILE-24-10-01") + } +} + +func TestGetRates_WithGas(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{ + "electricityMeterPoints":[{"agreements":[{"validTo":null,"tariff":{ + "unitRate":26.32,"standingCharge":54.55,"productCode":"VAR-22-11-01","tariffCode":"E-1R-VAR-22-11-01-C" + }}]}], + "gasMeterPoints":[{"agreements":[{"validTo":null,"tariff":{ + "unitRate":7.22,"standingCharge":29.11 + }}]}] + }]}]}}}`) + })) + defer srv.Close() + octopusGraphQL = srv.URL + "/" + + rates, err := getRates("token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if rates.GasUnitRate != 7.22 { + t.Errorf("gas unit rate: got %v, want 7.22", rates.GasUnitRate) + } + if rates.GasStandingCharge != 29.11 { + t.Errorf("gas standing charge: got %v, want 29.11", rates.GasStandingCharge) + } +} diff --git a/cmd/octopus_exporter/rest.go b/cmd/octopus_exporter/rest.go new file mode 100644 index 0000000..777b1b2 --- /dev/null +++ b/cmd/octopus_exporter/rest.go @@ -0,0 +1,36 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/url" +) + +var octopusREST = "https://api.octopus.energy" + +func doREST(path string, params url.Values) (map[string]any, error) { + u := octopusREST + path + if len(params) > 0 { + u += "?" + params.Encode() + } + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.SetBasicAuth(apiKey, "") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var result map[string]any + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return result, nil +}