feat: more metrics and adding tests (#7)

This commit is contained in:
Rasmus Wejlgaard 2026-04-24 22:04:57 +01:00 committed by GitHub
parent c1491d886e
commit c50cb902f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 648 additions and 80 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
.env .env
coverage.out

View file

@ -6,29 +6,33 @@ A Prometheus exporter for Octopus Energy smart meter data, using the Kraken Grap
### Electricity ### Electricity
| Metric | Description | | Metric | Source | Description |
|---|---| |---|---|---|
| `octopus_electricity_demand_watts` | Live electricity demand in watts | | `octopus_electricity_demand_watts` | GraphQL | Live electricity demand in watts |
| `octopus_electricity_last_read_timestamp` | Unix timestamp of last electricity reading | | `octopus_electricity_last_read_timestamp` | GraphQL | Unix timestamp of last electricity reading |
| `octopus_electricity_unit_rate_pence` | Current unit rate in pence per kWh | | `octopus_electricity_consumption_kwh` | REST | Latest half-hourly consumption in kWh |
| `octopus_electricity_standing_charge_pence` | Current standing charge in pence per day | | `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
Gas metrics are only exposed if a smart gas meter is found on the account. Gas metrics are only exposed if a smart gas meter is found on the account.
| Metric | Description | | Metric | Source | Description |
|---|---| |---|---|---|
| `octopus_gas_demand_watts` | Live gas demand in watts | | `octopus_gas_demand_watts` | GraphQL | Live gas demand in watts |
| `octopus_gas_last_read_timestamp` | Unix timestamp of last gas reading | | `octopus_gas_last_read_timestamp` | GraphQL | Unix timestamp of last gas reading |
| `octopus_gas_unit_rate_pence` | Current unit rate in pence per kWh | | `octopus_gas_consumption_kwh` | REST | Latest half-hourly consumption in kWh |
| `octopus_gas_standing_charge_pence` | Current standing charge in pence per day | | `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 ### Account
| Metric | Description | | Metric | Source | Description |
|---|---| |---|---|---|
| `octopus_account_balance_pence` | Account balance in pence (positive = credit, negative = debit) | | `octopus_account_balance_pence` | GraphQL | Account balance in pence (positive = credit, negative = debit) |
Metrics are updated every 60 seconds. Metrics are updated every 60 seconds.

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@ import (
"strconv" "strconv"
) )
const octopusGraphQL = "https://api.octopus.energy/v1/graphql/" var octopusGraphQL = "https://api.octopus.energy/v1/graphql/"
type gqlRequest struct { type gqlRequest struct {
OperationName string `json:"operationName,omitempty"` OperationName string `json:"operationName,omitempty"`

View file

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

View file

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

View file

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

View file

@ -11,8 +11,8 @@ import (
) )
var ( var (
apiKey = mustEnv("OCTOPUS_API_KEY") apiKey string
port = envOrDefault("PORT", "9359") port string
) )
func mustEnv(key string) string { func mustEnv(key string) string {
@ -35,31 +35,40 @@ func gauge(name, help string) prometheus.Gauge {
} }
func main() { func main() {
apiKey = mustEnv("OCTOPUS_API_KEY")
port = envOrDefault("PORT", "9359")
token, err := getKrakenToken(apiKey) token, err := getKrakenToken(apiKey)
if err != nil { if err != nil {
log.Fatalf("failed to get initial token: %v", err) log.Fatalf("failed to get initial token: %v", err)
} }
elecDeviceID, err := resolveDeviceID(token, electricity) elecMeter, err := resolveMeter(token, electricity)
if err != nil { if err != nil {
log.Fatalf("failed to resolve electricity meter: %v", err) log.Fatalf("failed to resolve electricity meter: %v", err)
} }
if elecDeviceID == "" { if elecMeter == nil {
log.Fatal("no electricity smart meter found on account") log.Fatal("no electricity smart meter found on account")
} }
gasDeviceID, err := resolveDeviceID(token, gas) gasMeter, err := resolveMeter(token, gas)
if err != nil { if err != nil {
log.Fatalf("failed to resolve gas meter: %v", err) log.Fatalf("failed to resolve gas meter: %v", err)
} }
if gasDeviceID == "" { if gasMeter == nil {
log.Println("no gas smart meter found — gas metrics disabled") 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") elecDemand := gauge("octopus_electricity_demand_watts", "Live electricity demand in watts")
elecLastRead := gauge("octopus_electricity_last_read_timestamp", "Unix timestamp of last electricity reading") 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 // Electricity tariff
elecUnitRate := gauge("octopus_electricity_unit_rate_pence", "Current electricity unit rate in pence per kWh") 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") elecStandingCharge := gauge("octopus_electricity_standing_charge_pence", "Current electricity standing charge in pence per day")
@ -67,20 +76,29 @@ 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)")
toRegister := []prometheus.Collector{elecDemand, elecLastRead, elecUnitRate, elecStandingCharge, accountBalance} toRegister := []prometheus.Collector{
elecDemand, elecLastRead,
elecConsumption, elecConsumptionInterval,
elecUnitRate, elecStandingCharge,
accountBalance,
}
var ( var (
gasDemand prometheus.Gauge gasDemand prometheus.Gauge
gasLastRead prometheus.Gauge gasLastRead prometheus.Gauge
gasUnitRate prometheus.Gauge gasConsumption prometheus.Gauge
gasStandCharge 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") gasDemand = gauge("octopus_gas_demand_watts", "Live gas demand in watts")
gasLastRead = gauge("octopus_gas_last_read_timestamp", "Unix timestamp of last gas reading") 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") 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") 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...) prometheus.MustRegister(toRegister...)
@ -94,23 +112,25 @@ func main() {
}() }()
for { for {
// Electricity telemetry // Electricity telemetry (live demand)
reading, err := getLiveConsumption(token, elecDeviceID) if elecMeter.deviceID != "" {
if err != nil { reading, err := getLiveConsumption(token, elecMeter.deviceID)
log.Printf("electricity telemetry error: %v", err) if err != nil {
if token, err = getKrakenToken(apiKey); err != nil { log.Printf("electricity telemetry error: %v", err)
log.Printf("token refresh failed: %v", err) if token, err = getKrakenToken(apiKey); err != nil {
} log.Printf("token refresh failed: %v", err)
} else { }
elecDemand.Set(float64(reading.Demand)) } else {
if t, err := time.Parse("2006-01-02T15:04:05+00:00", reading.ReadAt); err == nil { elecDemand.Set(float64(reading.Demand))
elecLastRead.Set(float64(t.Unix())) if t, err := time.Parse("2006-01-02T15:04:05+00:00", reading.ReadAt); err == nil {
elecLastRead.Set(float64(t.Unix()))
}
} }
} }
// Gas telemetry // Gas telemetry (live demand)
if gasDeviceID != "" { if gasMeter != nil && gasMeter.deviceID != "" {
reading, err := getLiveConsumption(token, gasDeviceID) reading, err := getLiveConsumption(token, gasMeter.deviceID)
if err != nil { if err != nil {
log.Printf("gas telemetry error: %v", err) log.Printf("gas telemetry error: %v", err)
} else { } 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) rates, err := getRates(token)
if err != nil { if err != nil {
log.Printf("rates error: %v", err) log.Printf("rates error: %v", err)
} else { } 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) elecStandingCharge.Set(rates.ElectricityStandingCharge)
if gasDeviceID != "" {
if gasMeter != nil {
gasUnitRate.Set(rates.GasUnitRate) gasUnitRate.Set(rates.GasUnitRate)
gasStandCharge.Set(rates.GasStandingCharge) gasStandCharge.Set(rates.GasStandingCharge)
} }

View file

@ -1,7 +1,6 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -22,6 +21,13 @@ type meterCandidate struct {
deviceID string deviceID string
} }
type resolvedMeter struct {
deviceID string
mpan string // electricity
mprn string // gas
serial string
}
func getMeters(token string) ([]meterCandidate, error) { func getMeters(token string) ([]meterCandidate, error) {
result, err := doGraphQL(gqlRequest{ result, err := doGraphQL(gqlRequest{
Query: `{ viewer { accounts { ... on AccountType { properties { 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}) 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}) 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 return candidates, nil
} }
// resolveDeviceID finds the device ID for the given meter kind using environment // resolveMeter finds the meter matching the env var filters for the given kind.
// variable filters. Returns ("", nil) if no meter of that kind exists on the account. // Returns (nil, nil) if no meter of that kind exists on the account.
func resolveDeviceID(token string, kind meterKind) (string, error) { func resolveMeter(token string, kind meterKind) (*resolvedMeter, error) {
var wantDeviceID, wantID, wantSerial string var wantDeviceID, wantID, wantSerial string
switch kind { switch kind {
case electricity: case electricity:
@ -99,14 +112,10 @@ func resolveDeviceID(token string, kind meterKind) (string, error) {
wantSerial = os.Getenv("OCTOPUS_GAS_SERIAL") wantSerial = os.Getenv("OCTOPUS_GAS_SERIAL")
} }
if wantDeviceID != "" && wantID == "" && wantSerial == "" {
return wantDeviceID, nil
}
log.Printf("discovering %s meters from account...", kind) log.Printf("discovering %s meters from account...", kind)
candidates, err := getMeters(token) candidates, err := getMeters(token)
if err != nil { if err != nil {
return "", err return nil, err
} }
for _, c := range candidates { for _, c := range candidates {
@ -127,28 +136,19 @@ func resolveDeviceID(token string, kind meterKind) (string, error) {
if wantSerial != "" && c.serial != wantSerial { if wantSerial != "" && c.serial != wantSerial {
continue continue
} }
m := &resolvedMeter{deviceID: c.deviceID, mpan: c.mpan, mprn: c.mprn, serial: c.serial}
switch kind { switch kind {
case electricity: 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: 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 != "" { 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)
} }
return nil, nil
// 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")
} }

View file

@ -3,22 +3,22 @@ package main
type tariffRates struct { type tariffRates struct {
ElectricityUnitRate float64 ElectricityUnitRate float64
ElectricityStandingCharge float64 ElectricityStandingCharge float64
ElectricityProductCode string
ElectricityTariffCode string
ElectricityIsAgile bool
GasUnitRate float64 GasUnitRate float64
GasStandingCharge 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) { func getRates(token string) (*tariffRates, error) {
result, err := doGraphQL(gqlRequest{ result, err := doGraphQL(gqlRequest{
Query: `{ viewer { accounts { ... on AccountType { properties { Query: `{ viewer { accounts { ... on AccountType { properties {
electricityMeterPoints { 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 { gasMeterPoints {
agreements { validTo tariff { unitRate standingCharge } } agreements { validTo tariff { unitRate standingCharge } }
@ -41,6 +41,13 @@ func getRates(token string) (*tariffRates, error) {
if tariff := activeAgreementTariff(mp); tariff != nil { if tariff := activeAgreementTariff(mp); tariff != nil {
rates.ElectricityUnitRate, _ = tariff["unitRate"].(float64) rates.ElectricityUnitRate, _ = tariff["unitRate"].(float64)
rates.ElectricityStandingCharge, _ = tariff["standingCharge"].(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
}
} }
} }

View file

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

View file

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