From 1e0eff520f4060fa23ff08e546a811d57615f83c Mon Sep 17 00:00:00 2001 From: "Rasmus \"Pez\" Wejlgaard" Date: Thu, 23 Apr 2026 21:03:28 +0100 Subject: [PATCH] major: initial release (#4) --- cmd/octopus_exporter/account.go | 21 ++ cmd/octopus_exporter/auth.go | 24 +++ cmd/octopus_exporter/client.go | 80 ++++++++ cmd/octopus_exporter/main.go | 323 +++++++++--------------------- cmd/octopus_exporter/meters.go | 154 ++++++++++++++ cmd/octopus_exporter/rates.go | 69 +++++++ cmd/octopus_exporter/telemetry.go | 41 ++++ 7 files changed, 483 insertions(+), 229 deletions(-) create mode 100644 cmd/octopus_exporter/account.go create mode 100644 cmd/octopus_exporter/auth.go create mode 100644 cmd/octopus_exporter/client.go create mode 100644 cmd/octopus_exporter/meters.go create mode 100644 cmd/octopus_exporter/rates.go create mode 100644 cmd/octopus_exporter/telemetry.go diff --git a/cmd/octopus_exporter/account.go b/cmd/octopus_exporter/account.go new file mode 100644 index 0000000..a664852 --- /dev/null +++ b/cmd/octopus_exporter/account.go @@ -0,0 +1,21 @@ +package main + +import "errors" + +func getAccountBalance(token string) (float64, error) { + result, err := doGraphQL(gqlRequest{ + Query: `{ viewer { accounts { ... on AccountType { balance } } } }`, + }, token) + if err != nil { + return 0, err + } + + accounts, _ := result["data"].(map[string]any)["viewer"].(map[string]any)["accounts"].([]any) + for _, a := range accounts { + if bal, ok := a.(map[string]any)["balance"].(float64); ok { + return bal, nil + } + } + + return 0, errors.New("balance not found in response") +} diff --git a/cmd/octopus_exporter/auth.go b/cmd/octopus_exporter/auth.go new file mode 100644 index 0000000..aef606b --- /dev/null +++ b/cmd/octopus_exporter/auth.go @@ -0,0 +1,24 @@ +package main + +import "errors" + +var errTokenExpired = errors.New("token expired") + +func getKrakenToken(apiKey string) (string, error) { + result, err := doGraphQL(gqlRequest{ + Variables: map[string]any{"apikey": apiKey}, + Query: `mutation krakenTokenAuthentication($apikey: String!) { + obtainKrakenToken(input: {APIKey: $apikey}) { + token + } + }`, + }, "") + if err != nil { + return "", err + } + token, ok := result["data"].(map[string]any)["obtainKrakenToken"].(map[string]any)["token"].(string) + if !ok { + return "", errors.New("token not found in response") + } + return token, nil +} diff --git a/cmd/octopus_exporter/client.go b/cmd/octopus_exporter/client.go new file mode 100644 index 0000000..9c97d31 --- /dev/null +++ b/cmd/octopus_exporter/client.go @@ -0,0 +1,80 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" +) + +const octopusGraphQL = "https://api.octopus.energy/v1/graphql/" + +type gqlRequest struct { + OperationName string `json:"operationName,omitempty"` + Variables map[string]any `json:"variables"` + Query string `json:"query"` +} + +// jsonFloat unmarshals both JSON numbers and quoted strings into float64. +type jsonFloat float64 + +func (f *jsonFloat) UnmarshalJSON(data []byte) error { + var n float64 + if err := json.Unmarshal(data, &n); err == nil { + *f = jsonFloat(n) + return nil + } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + n, err := strconv.ParseFloat(s, 64) + if err != nil { + return err + } + *f = jsonFloat(n) + return nil +} + +func doGraphQL(req gqlRequest, authToken string) (map[string]any, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + httpReq, err := http.NewRequest(http.MethodPost, octopusGraphQL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + if authToken != "" { + httpReq.Header.Set("Authorization", "JWT "+authToken) + } + resp, err := http.DefaultClient.Do(httpReq) + 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 + } + if errs, ok := result["errors"].([]any); ok && len(errs) > 0 { + if e, ok := errs[0].(map[string]any); ok { + return nil, fmt.Errorf("GraphQL error: %s", e["message"]) + } + return nil, errors.New("GraphQL error") + } + return result, nil +} + +func toSlice(v any) []any { + s, _ := v.([]any) + return s +} diff --git a/cmd/octopus_exporter/main.go b/cmd/octopus_exporter/main.go index e0af8f7..7846b2c 100644 --- a/cmd/octopus_exporter/main.go +++ b/cmd/octopus_exporter/main.go @@ -1,11 +1,6 @@ package main import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" "log" "net/http" "os" @@ -15,8 +10,6 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" ) -const octopusGraphQL = "https://api.octopus.energy/v1/graphql/" - var ( apiKey = mustEnv("OCTOPUS_API_KEY") port = envOrDefault("PORT", "9359") @@ -37,209 +30,60 @@ func envOrDefault(key, def string) string { return def } -type gqlRequest struct { - OperationName string `json:"operationName,omitempty"` - Variables map[string]any `json:"variables"` - Query string `json:"query"` -} - -func doGraphQL(req gqlRequest, authToken string) (map[string]any, error) { - body, err := json.Marshal(req) - if err != nil { - return nil, err - } - - httpReq, err := http.NewRequest(http.MethodPost, octopusGraphQL, bytes.NewReader(body)) - if err != nil { - return nil, err - } - httpReq.Header.Set("Content-Type", "application/json") - if authToken != "" { - httpReq.Header.Set("Authorization", "JWT "+authToken) - } - - resp, err := http.DefaultClient.Do(httpReq) - 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 - } - - if errs, ok := result["errors"].([]any); ok && len(errs) > 0 { - if e, ok := errs[0].(map[string]any); ok { - return nil, fmt.Errorf("GraphQL error: %s", e["message"]) - } - return nil, errors.New("GraphQL error") - } - - return result, nil -} - -func getKrakenToken(apiKey string) (string, error) { - result, err := doGraphQL(gqlRequest{ - Variables: map[string]any{"apikey": apiKey}, - Query: `mutation krakenTokenAuthentication($apikey: String!) { - obtainKrakenToken(input: {APIKey: $apikey}) { - token - } - }`, - }, "") - if err != nil { - return "", err - } - - token, ok := result["data"].(map[string]any)["obtainKrakenToken"].(map[string]any)["token"].(string) - if !ok { - return "", errors.New("token not found in response") - } - return token, nil -} - -type meterCandidate struct { - mpan string - serial string - deviceID string -} - -func getMeters(token string) ([]meterCandidate, error) { - result, err := doGraphQL(gqlRequest{ - Query: `{ viewer { accounts { ... on AccountType { properties { - electricityMeterPoints { - mpan - meters { - serialNumber - smartDevices { deviceId } - } - } - } } } } }`, - }, token) - if err != nil { - return nil, err - } - - var candidates []meterCandidate - - accounts, _ := result["data"].(map[string]any)["viewer"].(map[string]any)["accounts"].([]any) - for _, a := range accounts { - props, _ := a.(map[string]any)["properties"].([]any) - for _, p := range props { - mps, _ := p.(map[string]any)["electricityMeterPoints"].([]any) - for _, mp := range mps { - mpan, _ := mp.(map[string]any)["mpan"].(string) - meters, _ := mp.(map[string]any)["meters"].([]any) - for _, m := range meters { - serial, _ := m.(map[string]any)["serialNumber"].(string) - devices, _ := m.(map[string]any)["smartDevices"].([]any) - for _, d := range devices { - deviceID, _ := d.(map[string]any)["deviceId"].(string) - if deviceID != "" { - candidates = append(candidates, meterCandidate{ - mpan: mpan, - serial: serial, - deviceID: deviceID, - }) - } - } - } - } - } - } - - return candidates, nil -} - -func resolveDeviceID(token string) (string, error) { - wantDeviceID := os.Getenv("OCTOPUS_DEVICE_ID") - wantMPAN := os.Getenv("OCTOPUS_MPAN") - wantSerial := os.Getenv("OCTOPUS_SERIAL") - - if wantDeviceID != "" && wantMPAN == "" && wantSerial == "" { - return wantDeviceID, nil - } - - log.Println("discovering meters from account...") - candidates, err := getMeters(token) - if err != nil { - return "", err - } - if len(candidates) == 0 { - return "", errors.New("no smart meters found on account") - } - - for _, c := range candidates { - if wantDeviceID != "" && c.deviceID != wantDeviceID { - continue - } - if wantMPAN != "" && c.mpan != wantMPAN { - continue - } - if wantSerial != "" && c.serial != wantSerial { - continue - } - log.Printf("using meter: MPAN=%s serial=%s deviceID=%s", c.mpan, c.serial, c.deviceID) - return c.deviceID, nil - } - - return "", fmt.Errorf("no meter matched OCTOPUS_DEVICE_ID=%q OCTOPUS_MPAN=%q OCTOPUS_SERIAL=%q", wantDeviceID, wantMPAN, wantSerial) -} - -type telemetryReading struct { - ReadAt string `json:"readAt"` - Consumption float64 `json:"consumption"` - Demand float64 `json:"demand"` -} - -var errTokenExpired = errors.New("token expired") - -func getLiveConsumption(token, deviceID string) (*telemetryReading, error) { - result, err := doGraphQL(gqlRequest{ - OperationName: "getSmartMeterTelemetry", - Variables: map[string]any{"meterDeviceId": deviceID}, - Query: "query getSmartMeterTelemetry($meterDeviceId: String!, $start: DateTime, $end: DateTime, $grouping: TelemetryGrouping) {\n smartMeterTelemetry(deviceId: $meterDeviceId, start: $start, end: $end, grouping: $grouping) {\n readAt\n consumption\n demand\n __typename\n }\n}\n", - }, token) - if err != nil { - if err.Error() == "GraphQL error: Signature of the JWT has expired." { - return nil, errTokenExpired - } - return nil, err - } - - telemetry, ok := result["data"].(map[string]any)["smartMeterTelemetry"].([]any) - if !ok || len(telemetry) == 0 { - return nil, errors.New("no data found") - } - - raw, err := json.Marshal(telemetry[0]) - if err != nil { - return nil, err - } - - var reading telemetryReading - if err := json.Unmarshal(raw, &reading); err != nil { - return nil, err - } - return &reading, nil +func gauge(name, help string) prometheus.Gauge { + return prometheus.NewGauge(prometheus.GaugeOpts{Name: name, Help: help}) } func main() { - liveConsumption := prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "octopus_live_consumption", - Help: "Octopus Energy live consumption in watts", - }) - lastRead := prometheus.NewGauge(prometheus.GaugeOpts{ - Name: "octopus_live_consumption_last_read", - Help: "Octopus Energy live consumption last read in seconds since epoch", - }) - prometheus.MustRegister(liveConsumption, lastRead) + token, err := getKrakenToken(apiKey) + if err != nil { + log.Fatalf("failed to get initial token: %v", err) + } + + elecDeviceID, err := resolveDeviceID(token, electricity) + if err != nil { + log.Fatalf("failed to resolve electricity meter: %v", err) + } + if elecDeviceID == "" { + log.Fatal("no electricity smart meter found on account") + } + + gasDeviceID, err := resolveDeviceID(token, gas) + if err != nil { + log.Fatalf("failed to resolve gas meter: %v", err) + } + if gasDeviceID == "" { + log.Println("no gas smart meter found — gas metrics disabled") + } + + // Electricity telemetry + 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 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") + + // Account + accountBalance := gauge("octopus_account_balance_pence", "Account balance in pence (positive = credit, negative = debit)") + + toRegister := []prometheus.Collector{elecDemand, elecLastRead, elecUnitRate, elecStandingCharge, accountBalance} + + var ( + gasDemand prometheus.Gauge + gasLastRead prometheus.Gauge + gasUnitRate prometheus.Gauge + gasStandCharge prometheus.Gauge + ) + if gasDeviceID != "" { + gasDemand = gauge("octopus_gas_demand_watts", "Live gas demand in watts") + gasLastRead = gauge("octopus_gas_last_read_timestamp", "Unix timestamp of last gas reading") + 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) + } + + prometheus.MustRegister(toRegister...) http.Handle("/metrics", promhttp.Handler()) go func() { @@ -249,34 +93,55 @@ func main() { } }() - token, err := getKrakenToken(apiKey) - if err != nil { - log.Fatalf("failed to get initial token: %v", err) - } - - deviceID, err := resolveDeviceID(token) - if err != nil { - log.Fatalf("failed to resolve device ID: %v", err) - } - for { - reading, err := getLiveConsumption(token, deviceID) + // Electricity telemetry + reading, err := getLiveConsumption(token, elecDeviceID) if err != nil { - log.Printf("error fetching telemetry: %v", err) - token, err = getKrakenToken(apiKey) - if err != nil { - log.Printf("failed to refresh token: %v", err) + log.Printf("electricity telemetry error: %v", err) + if token, err = getKrakenToken(apiKey); err != nil { + log.Printf("token refresh failed: %v", err) } } else { - liveConsumption.Set(reading.Demand) - - t, err := time.Parse("2006-01-02T15:04:05+00:00", reading.ReadAt) - if err != nil { - log.Printf("failed to parse readAt %q: %v", reading.ReadAt, err) - } else { - lastRead.Set(float64(t.Unix())) + 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) + if err != nil { + log.Printf("gas telemetry error: %v", err) + } else { + gasDemand.Set(float64(reading.Demand)) + if t, err := time.Parse("2006-01-02T15:04:05+00:00", reading.ReadAt); err == nil { + gasLastRead.Set(float64(t.Unix())) + } + } + } + + // Rates + rates, err := getRates(token) + if err != nil { + log.Printf("rates error: %v", err) + } else { + elecUnitRate.Set(rates.ElectricityUnitRate) + elecStandingCharge.Set(rates.ElectricityStandingCharge) + if gasDeviceID != "" { + gasUnitRate.Set(rates.GasUnitRate) + gasStandCharge.Set(rates.GasStandingCharge) + } + } + + // Account balance + balance, err := getAccountBalance(token) + if err != nil { + log.Printf("account balance error: %v", err) + } else { + accountBalance.Set(balance) + } + time.Sleep(60 * time.Second) } } diff --git a/cmd/octopus_exporter/meters.go b/cmd/octopus_exporter/meters.go new file mode 100644 index 0000000..c3106ae --- /dev/null +++ b/cmd/octopus_exporter/meters.go @@ -0,0 +1,154 @@ +package main + +import ( + "errors" + "fmt" + "log" + "os" +) + +type meterKind string + +const ( + electricity meterKind = "electricity" + gas meterKind = "gas" +) + +type meterCandidate struct { + kind meterKind + mpan string + mprn string + serial string + deviceID string +} + +func getMeters(token string) ([]meterCandidate, error) { + result, err := doGraphQL(gqlRequest{ + Query: `{ viewer { accounts { ... on AccountType { properties { + electricityMeterPoints { + mpan + meters { + serialNumber + smartDevices { deviceId } + } + } + gasMeterPoints { + mprn + meters { + serialNumber + smartDevices { deviceId } + } + } + } } } } }`, + }, token) + if err != nil { + return nil, err + } + + var candidates []meterCandidate + + accounts, _ := result["data"].(map[string]any)["viewer"].(map[string]any)["accounts"].([]any) + for _, a := range accounts { + props, _ := a.(map[string]any)["properties"].([]any) + for _, p := range props { + pm := p.(map[string]any) + + for _, mp := range toSlice(pm["electricityMeterPoints"]) { + mpan, _ := mp.(map[string]any)["mpan"].(string) + for _, m := range toSlice(mp.(map[string]any)["meters"]) { + serial, _ := m.(map[string]any)["serialNumber"].(string) + for _, d := range toSlice(m.(map[string]any)["smartDevices"]) { + deviceID, _ := d.(map[string]any)["deviceId"].(string) + if deviceID != "" { + candidates = append(candidates, meterCandidate{kind: electricity, mpan: mpan, serial: serial, deviceID: deviceID}) + } + } + } + } + + for _, mp := range toSlice(pm["gasMeterPoints"]) { + mprn, _ := mp.(map[string]any)["mprn"].(string) + for _, m := range toSlice(mp.(map[string]any)["meters"]) { + serial, _ := m.(map[string]any)["serialNumber"].(string) + for _, d := range toSlice(m.(map[string]any)["smartDevices"]) { + deviceID, _ := d.(map[string]any)["deviceId"].(string) + if deviceID != "" { + candidates = append(candidates, meterCandidate{kind: gas, mprn: mprn, serial: serial, deviceID: deviceID}) + } + } + } + } + } + } + + 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) { + var wantDeviceID, wantID, wantSerial string + switch kind { + case electricity: + wantDeviceID = os.Getenv("OCTOPUS_DEVICE_ID") + wantID = os.Getenv("OCTOPUS_MPAN") + wantSerial = os.Getenv("OCTOPUS_SERIAL") + case gas: + wantDeviceID = os.Getenv("OCTOPUS_GAS_DEVICE_ID") + wantID = os.Getenv("OCTOPUS_GAS_MPRN") + 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 + } + + for _, c := range candidates { + if c.kind != kind { + continue + } + if wantDeviceID != "" && c.deviceID != wantDeviceID { + continue + } + if wantID != "" { + if kind == electricity && c.mpan != wantID { + continue + } + if kind == gas && c.mprn != wantID { + continue + } + } + if wantSerial != "" && c.serial != wantSerial { + continue + } + switch kind { + case electricity: + log.Printf("using electricity meter: MPAN=%s serial=%s deviceID=%s", c.mpan, c.serial, c.deviceID) + case gas: + log.Printf("using gas meter: MPRN=%s serial=%s deviceID=%s", c.mprn, c.serial, c.deviceID) + } + return c.deviceID, nil + } + + if wantDeviceID != "" || wantID != "" || wantSerial != "" { + return "", 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") +} diff --git a/cmd/octopus_exporter/rates.go b/cmd/octopus_exporter/rates.go new file mode 100644 index 0000000..d21d9cb --- /dev/null +++ b/cmd/octopus_exporter/rates.go @@ -0,0 +1,69 @@ +package main + +type tariffRates struct { + ElectricityUnitRate float64 + ElectricityStandingCharge float64 + 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 + `} } + } + gasMeterPoints { + agreements { validTo tariff { unitRate standingCharge } } + } + } } } } }`, + }, token) + if err != nil { + return nil, err + } + + rates := &tariffRates{} + + accounts, _ := result["data"].(map[string]any)["viewer"].(map[string]any)["accounts"].([]any) + for _, a := range accounts { + props, _ := a.(map[string]any)["properties"].([]any) + for _, p := range props { + pm := p.(map[string]any) + + for _, mp := range toSlice(pm["electricityMeterPoints"]) { + if tariff := activeAgreementTariff(mp); tariff != nil { + rates.ElectricityUnitRate, _ = tariff["unitRate"].(float64) + rates.ElectricityStandingCharge, _ = tariff["standingCharge"].(float64) + } + } + + for _, mp := range toSlice(pm["gasMeterPoints"]) { + if tariff := activeAgreementTariff(mp); tariff != nil { + rates.GasUnitRate, _ = tariff["unitRate"].(float64) + rates.GasStandingCharge, _ = tariff["standingCharge"].(float64) + } + } + } + } + + return rates, nil +} + +// activeAgreementTariff returns the tariff map for the agreement with validTo == null. +func activeAgreementTariff(meterPoint any) map[string]any { + for _, ag := range toSlice(meterPoint.(map[string]any)["agreements"]) { + agm := ag.(map[string]any) + if agm["validTo"] == nil { + tariff, _ := agm["tariff"].(map[string]any) + return tariff + } + } + return nil +} diff --git a/cmd/octopus_exporter/telemetry.go b/cmd/octopus_exporter/telemetry.go new file mode 100644 index 0000000..0e42856 --- /dev/null +++ b/cmd/octopus_exporter/telemetry.go @@ -0,0 +1,41 @@ +package main + +import ( + "encoding/json" + "errors" +) + +type telemetryReading struct { + ReadAt string `json:"readAt"` + Consumption jsonFloat `json:"consumption"` + Demand jsonFloat `json:"demand"` +} + +func getLiveConsumption(token, deviceID string) (*telemetryReading, error) { + result, err := doGraphQL(gqlRequest{ + OperationName: "getSmartMeterTelemetry", + Variables: map[string]any{"meterDeviceId": deviceID}, + Query: "query getSmartMeterTelemetry($meterDeviceId: String!, $start: DateTime, $end: DateTime, $grouping: TelemetryGrouping) {\n smartMeterTelemetry(deviceId: $meterDeviceId, start: $start, end: $end, grouping: $grouping) {\n readAt\n consumption\n demand\n __typename\n }\n}\n", + }, token) + if err != nil { + if err.Error() == "GraphQL error: Signature of the JWT has expired." { + return nil, errTokenExpired + } + return nil, err + } + + telemetry := toSlice(result["data"].(map[string]any)["smartMeterTelemetry"]) + if len(telemetry) == 0 { + return nil, errors.New("no telemetry data returned") + } + + raw, err := json.Marshal(telemetry[0]) + if err != nil { + return nil, err + } + var reading telemetryReading + if err := json.Unmarshal(raw, &reading); err != nil { + return nil, err + } + return &reading, nil +}