mirror of
https://github.com/RWejlgaard/octopus_exporter.git
synced 2026-05-06 04:14:44 +00:00
feat: more metrics and adding tests
This commit is contained in:
parent
c1491d886e
commit
591215fbd7
15 changed files with 648 additions and 80 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
.env
|
||||
coverage.out
|
||||
|
|
|
|||
34
README.md
34
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.
|
||||
|
||||
|
|
|
|||
53
cmd/octopus_exporter/account_test.go
Normal file
53
cmd/octopus_exporter/account_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
36
cmd/octopus_exporter/agile.go
Normal file
36
cmd/octopus_exporter/agile.go
Normal 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
|
||||
}
|
||||
59
cmd/octopus_exporter/agile_test.go
Normal file
59
cmd/octopus_exporter/agile_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
37
cmd/octopus_exporter/auth_test.go
Normal file
37
cmd/octopus_exporter/auth_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
53
cmd/octopus_exporter/client_test.go
Normal file
53
cmd/octopus_exporter/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
46
cmd/octopus_exporter/consumption.go
Normal file
46
cmd/octopus_exporter/consumption.go
Normal 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
|
||||
}
|
||||
62
cmd/octopus_exporter/consumption_test.go
Normal file
62
cmd/octopus_exporter/consumption_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
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,8 +112,9 @@ func main() {
|
|||
}()
|
||||
|
||||
for {
|
||||
// Electricity telemetry
|
||||
reading, err := getLiveConsumption(token, elecDeviceID)
|
||||
// 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 {
|
||||
|
|
@ -107,10 +126,11 @@ func main() {
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
122
cmd/octopus_exporter/rates_test.go
Normal file
122
cmd/octopus_exporter/rates_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
36
cmd/octopus_exporter/rest.go
Normal file
36
cmd/octopus_exporter/rest.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue