Compare commits

..

No commits in common. "main" and "v1.2.0" have entirely different histories.
main ... v1.2.0

14 changed files with 81 additions and 377 deletions

View file

@ -8,9 +8,9 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: actions/setup-go@v6
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
@ -18,6 +18,6 @@ jobs:
run: go test -race ./...
- name: Lint
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@v6
with:
version: latest

View file

@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
if: "!startsWith(github.event.head_commit.message, 'chore:')"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
@ -49,19 +49,19 @@ jobs:
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PAT }}
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
push: true

View file

@ -1,4 +1,4 @@
FROM golang:1.26-alpine AS builder
FROM golang:1.24-alpine AS builder
ARG VERSION=dev
ARG COMMIT=none
WORKDIR /src

View file

@ -28,18 +28,6 @@ Gas metrics are only exposed if a smart gas meter is found on the account.
| `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 |
### Solar / export
Solar metrics are only exposed if an electricity export meter point (direction `EXPORT`) is found on the account. Solar export usually shares the physical meter and smart device with the import meter, so live export watts come from the same telemetry call as electricity demand (no extra API request).
| Metric | Source | Description |
|---|---|---|
| `octopus_solar_export_watts` | GraphQL | Live electricity export to the grid in watts |
| `octopus_solar_last_read_timestamp` | GraphQL | Unix timestamp of last solar export reading |
| `octopus_solar_export_kwh` | REST | Latest half-hourly energy exported to the grid in kWh |
| `octopus_solar_export_interval_timestamp` | REST | Unix timestamp of the start of the latest export interval |
| `octopus_solar_export_rate_pence` | GraphQL | Current export (outgoing) unit rate in pence per kWh |
### Account
| Metric | Source | Description |
@ -70,12 +58,8 @@ Metrics are refreshed every `POLL_INTERVAL` (default 60 seconds).
| `OCTOPUS_GAS_MPRN` | No | Filter gas meter by MPRN |
| `OCTOPUS_GAS_SERIAL` | No | Filter gas meter by serial number |
| `OCTOPUS_GAS_DEVICE_ID` | No | Use a specific gas smart device ID directly |
| `OCTOPUS_SOLAR_MPAN` | No | Filter solar export meter by MPAN |
| `OCTOPUS_SOLAR_SERIAL` | No | Filter solar export meter by serial number |
| `OCTOPUS_SOLAR_DEVICE_ID` | No | Use a specific solar export smart device ID directly |
| `PORT` | No | Port to expose metrics on (default: `9359`) |
| `POLL_INTERVAL` | No | How often to poll Octopus APIs (Go duration, default: `60s`) |
| `LOG_LEVEL` | No | Minimum log level: `debug`, `info`, `warn`, `error` (default: `info`). Logs are emitted as JSON to stderr |
If no filter variables are set, the exporter auto-discovers the first smart meter of each type found on the account. Use `OCTOPUS_MPAN` / `OCTOPUS_MPRN` to pin to a specific meter on accounts with multiple meters.

View file

@ -6,7 +6,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"log"
"net/http"
"strconv"
"strings"
@ -76,7 +76,7 @@ func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) {
wait = time.Duration(secs) * time.Second
}
}
slog.Warn("rate limited; retrying", "wait", wait, "attempt", attempt+1, "max_retries", maxRetries)
log.Printf("rate limited; retrying in %v (attempt %d/%d)", wait, attempt+1, maxRetries)
time.Sleep(wait)
backoff *= 2
continue

View file

@ -7,8 +7,6 @@ import (
"time"
)
var errNoConsumptionData = errors.New("no consumption data in last 7 days")
type consumptionReading struct {
KWh float64
IntervalStart time.Time
@ -23,11 +21,9 @@ func getLatestConsumption(kind meterKind, id, serial, key string) (*consumptionR
path = fmt.Sprintf("/v1/gas-meter-points/%s/meters/%s/consumption/", id, serial)
}
// Consumption data can lag significantly — meters that report once a day can be
// 2448h behind — so use a wide window and take the latest entry. The interval
// timestamp metric tells consumers how stale the latest reading actually is.
// 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(-7 * 24 * time.Hour).Format(time.RFC3339)},
"period_from": {time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)},
"order_by": {"period"},
}, key)
if err != nil {
@ -36,7 +32,7 @@ func getLatestConsumption(kind meterKind, id, serial, key string) (*consumptionR
results := toSlice(result["results"])
if len(results) == 0 {
return nil, errNoConsumptionData
return nil, errors.New("no consumption data in last 24h")
}
latest, ok := results[len(results)-1].(map[string]any)

View file

@ -3,7 +3,7 @@ package main
import (
"context"
"errors"
"log/slog"
"log"
"net/http"
"os"
"os/signal"
@ -23,15 +23,10 @@ var (
commit = "none"
)
func fatal(msg string, args ...any) {
slog.Error(msg, args...)
os.Exit(1)
}
func mustEnv(key string) string {
v := os.Getenv(key)
if v == "" {
fatal("required environment variable not set", "var", key)
log.Fatalf("required environment variable %s is not set", key)
}
return v
}
@ -58,63 +53,43 @@ func parseInterval(key string, def time.Duration) time.Duration {
}
d, err := time.ParseDuration(v)
if err != nil {
slog.Warn("invalid duration, falling back to default", "key", key, "value", v, "default", def, "err", err)
log.Printf("invalid %s=%q, falling back to %s: %v", key, v, def, err)
return def
}
return d
}
func main() {
logLevel := slog.LevelInfo
badLevel := os.Getenv("LOG_LEVEL")
if badLevel != "" {
if err := logLevel.UnmarshalText([]byte(badLevel)); err == nil {
badLevel = ""
}
}
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})))
if badLevel != "" {
slog.Warn("invalid LOG_LEVEL, falling back to info", "value", badLevel)
}
apiKey = mustEnv("OCTOPUS_API_KEY")
port = envOrDefault("PORT", "9359")
pollInterval := parseInterval("POLL_INTERVAL", 60*time.Second)
slog.Info("starting octopus_exporter", "version", version, "commit", commit, "poll_interval", pollInterval)
log.Printf("octopus_exporter %s (%s), poll interval %s", version, commit, pollInterval)
token, err := getKrakenToken(apiKey)
if err != nil {
fatal("failed to get initial token", "err", err)
log.Fatalf("failed to get initial token: %v", err)
}
slog.Info("discovering meters from account")
log.Println("discovering meters from account...")
candidates, err := getMeters(token)
if err != nil {
fatal("failed to discover meters", "err", err)
log.Fatalf("failed to discover meters: %v", err)
}
elecMeter, err := resolveMeter(candidates, electricity)
if err != nil {
fatal("failed to resolve electricity meter", "err", err)
log.Fatalf("failed to resolve electricity meter: %v", err)
}
if elecMeter == nil {
fatal("no electricity smart meter found on account")
log.Fatal("no electricity smart meter found on account")
}
gasMeter, err := resolveMeter(candidates, gas)
if err != nil {
fatal("failed to resolve gas meter", "err", err)
log.Fatalf("failed to resolve gas meter: %v", err)
}
if gasMeter == nil {
slog.Info("no gas smart meter found — gas metrics disabled")
}
solarMeter, err := resolveMeter(candidates, solar)
if err != nil {
fatal("failed to resolve solar export meter", "err", err)
}
if solarMeter == nil {
slog.Info("no solar export meter found — solar metrics disabled")
log.Println("no gas smart meter found — gas metrics disabled")
}
// --- Metrics ---
@ -174,22 +149,6 @@ func main() {
toRegister = append(toRegister, gasDemand, gasLastRead, gasConsumption, gasConsumptionInterval, gasUnitRate, gasStandCharge)
}
var (
solarExportWatts prometheus.Gauge
solarLastRead prometheus.Gauge
solarExport prometheus.Gauge
solarExportInterval prometheus.Gauge
solarExportRate prometheus.Gauge
)
if solarMeter != nil {
solarExportWatts = gauge("octopus_solar_export_watts", "Live electricity export (solar) to the grid in watts")
solarLastRead = gauge("octopus_solar_last_read_timestamp", "Unix timestamp of last solar export reading")
solarExport = gauge("octopus_solar_export_kwh", "Half-hourly electricity exported to the grid in kWh")
solarExportInterval = gauge("octopus_solar_export_interval_timestamp", "Unix timestamp of the start of the latest solar export interval")
solarExportRate = gauge("octopus_solar_export_rate_pence", "Current export (outgoing) unit rate in pence per kWh")
toRegister = append(toRegister, solarExportWatts, solarLastRead, solarExport, solarExportInterval, solarExportRate)
}
prometheus.MustRegister(toRegister...)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
@ -207,9 +166,9 @@ func main() {
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
slog.Info("serving metrics", "addr", ":"+port+"/metrics")
log.Printf("serving metrics on :%s/metrics", port)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
fatal("HTTP server failed", "err", err)
log.Fatal(err)
}
}()
@ -233,7 +192,7 @@ func main() {
newT, e := getKrakenToken(apiKey)
if e != nil {
tokenMu.Unlock()
slog.Error("token refresh failed", "err", e)
log.Printf("token refresh failed: %v", e)
return err
}
token = newT
@ -254,8 +213,8 @@ func main() {
failedAny atomic.Bool
)
fail := func(msg string, args ...any) {
slog.Error(msg, args...)
fail := func(format string, args ...any) {
log.Printf(format, args...)
pollErrors.Inc()
failedAny.Store(true)
}
@ -264,24 +223,12 @@ func main() {
wg.Add(1)
go func() {
defer wg.Done()
err := fn()
switch {
case err == nil:
case errors.Is(err, errNoTelemetryData):
slog.Warn("no telemetry data", "collector", name)
case errors.Is(err, errNoConsumptionData):
slog.Info("no consumption data", "collector", name)
default:
fail(name+" error", "err", err)
if err := fn(); err != nil {
fail("%s error: %v", name, err)
}
}()
}
// Solar export shares the physical meter/device with the import meter on
// most installs, so we can read live export watts from the same telemetry
// call rather than spending a second request.
solarSharesDevice := solarMeter != nil && solarMeter.deviceID != "" && solarMeter.deviceID == elecMeter.deviceID
// Electricity telemetry (live demand)
if elecMeter.deviceID != "" {
collect("electricity telemetry", func() error {
@ -294,29 +241,6 @@ func main() {
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
elecLastRead.Set(float64(ts.Unix()))
}
if solarSharesDevice {
solarExportWatts.Set(float64(reading.Export))
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
solarLastRead.Set(float64(ts.Unix()))
}
}
return nil
})
})
}
// Solar telemetry on a distinct export device (rare).
if solarMeter != nil && solarMeter.deviceID != "" && !solarSharesDevice {
collect("solar telemetry", func() error {
return withToken(func(t string) error {
reading, err := getLiveConsumption(t, solarMeter.deviceID)
if err != nil {
return err
}
solarExportWatts.Set(float64(reading.Export))
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
solarLastRead.Set(float64(ts.Unix()))
}
return nil
})
})
@ -365,19 +289,6 @@ func main() {
})
}
// Solar half-hourly export (REST; uses the electricity meter-point path with the export MPAN)
if solarMeter != nil && solarMeter.mpan != "" && solarMeter.serial != "" {
collect("solar export", func() error {
c, err := getLatestConsumption(electricity, solarMeter.mpan, solarMeter.serial, apiKey)
if err != nil {
return err
}
solarExport.Set(c.KWh)
solarExportInterval.Set(float64(c.IntervalStart.Unix()))
return nil
})
}
// Tariff rates (result needed for optional agile lookup after wg.Wait)
var collectedRates *tariffRates
collect("rates", func() error {
@ -411,7 +322,7 @@ func main() {
if collectedRates.ElectricityIsAgile && collectedRates.ElectricityProductCode != "" && collectedRates.ElectricityTariffCode != "" {
agileRate, err := getCurrentAgileRate(collectedRates.ElectricityProductCode, collectedRates.ElectricityTariffCode, apiKey)
if err != nil {
fail("agile rate error", "err", err)
fail("agile rate error: %v", err)
} else {
unitRate = agileRate
}
@ -422,9 +333,6 @@ func main() {
gasUnitRate.Set(collectedRates.GasUnitRate)
gasStandCharge.Set(collectedRates.GasStandingCharge)
}
if solarMeter != nil && collectedRates.SolarHasExport {
solarExportRate.Set(collectedRates.SolarExportRate)
}
}
if failedAny.Load() {
@ -439,11 +347,11 @@ func main() {
for {
select {
case <-ctx.Done():
slog.Info("shutdown signal received, draining HTTP server")
log.Println("shutdown signal received, draining HTTP server")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("HTTP shutdown error", "err", err)
log.Printf("HTTP shutdown error: %v", err)
}
return
case <-ticker.C:

View file

@ -2,7 +2,7 @@ package main
import (
"fmt"
"log/slog"
"log"
"os"
)
@ -11,9 +11,6 @@ type meterKind string
const (
electricity meterKind = "electricity"
gas meterKind = "gas"
// solar is an electricity export meter point (direction EXPORT) — a separate
// MPAN that usually shares the physical meter/device with the import meter.
solar meterKind = "solar"
)
type meterCandidate struct {
@ -36,7 +33,6 @@ func getMeters(token string) ([]meterCandidate, error) {
Query: `{ viewer { accounts { ... on AccountType { properties {
electricityMeterPoints {
mpan
direction
meters {
serialNumber
smartDevices { deviceId }
@ -74,12 +70,6 @@ func getMeters(token string) ([]meterCandidate, error) {
continue
}
mpan, _ := mpPoint["mpan"].(string)
// EXPORT meter points are solar generation export; everything else
// (IMPORT or unknown/legacy with no direction) is grid import.
kind := electricity
if dir, _ := mpPoint["direction"].(string); dir == "EXPORT" {
kind = solar
}
for _, m := range toSlice(mpPoint["meters"]) {
meterMap, ok := m.(map[string]any)
if !ok {
@ -94,12 +84,12 @@ func getMeters(token string) ([]meterCandidate, error) {
}
deviceID, _ := dMap["deviceId"].(string)
if deviceID != "" {
candidates = append(candidates, meterCandidate{kind: kind, 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(devices) == 0 && serial != "" {
candidates = append(candidates, meterCandidate{kind: kind, mpan: mpan, serial: serial})
candidates = append(candidates, meterCandidate{kind: electricity, mpan: mpan, serial: serial})
}
}
}
@ -151,10 +141,6 @@ func resolveMeter(candidates []meterCandidate, kind meterKind) (*resolvedMeter,
wantDeviceID = os.Getenv("OCTOPUS_GAS_DEVICE_ID")
wantID = os.Getenv("OCTOPUS_GAS_MPRN")
wantSerial = os.Getenv("OCTOPUS_GAS_SERIAL")
case solar:
wantDeviceID = os.Getenv("OCTOPUS_SOLAR_DEVICE_ID")
wantID = os.Getenv("OCTOPUS_SOLAR_MPAN")
wantSerial = os.Getenv("OCTOPUS_SOLAR_SERIAL")
}
for _, c := range candidates {
@ -165,7 +151,7 @@ func resolveMeter(candidates []meterCandidate, kind meterKind) (*resolvedMeter,
continue
}
if wantID != "" {
if (kind == electricity || kind == solar) && c.mpan != wantID {
if kind == electricity && c.mpan != wantID {
continue
}
if kind == gas && c.mprn != wantID {
@ -179,11 +165,9 @@ func resolveMeter(candidates []meterCandidate, kind meterKind) (*resolvedMeter,
m := &resolvedMeter{deviceID: c.deviceID, mpan: c.mpan, mprn: c.mprn, serial: c.serial}
switch kind {
case electricity:
slog.Info("using electricity meter", "mpan", m.mpan, "serial", m.serial, "device_id", m.deviceID)
log.Printf("using electricity meter: MPAN=%s serial=%s deviceID=%s", m.mpan, m.serial, m.deviceID)
case gas:
slog.Info("using gas meter", "mprn", m.mprn, "serial", m.serial, "device_id", m.deviceID)
case solar:
slog.Info("using solar export meter", "mpan", m.mpan, "serial", m.serial, "device_id", m.deviceID)
log.Printf("using gas meter: MPRN=%s serial=%s deviceID=%s", m.mprn, m.serial, m.deviceID)
}
return m, nil
}

View file

@ -173,56 +173,6 @@ func TestGetMeters_MeterWithoutSmartDevice(t *testing.T) {
}
}
func TestGetMeters_ExportClassifiedAsSolar(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
"electricityMeterPoints":[
{"mpan":"1900000000001","direction":"EXPORT","meters":[{"serialNumber":"A001","smartDevices":[{"deviceId":"dev1"}]}]},
{"mpan":"1900000000002","direction":"IMPORT","meters":[{"serialNumber":"A001","smartDevices":[{"deviceId":"dev1"}]}]}
],
"gasMeterPoints":[]
}]}]}}}`)
}))
defer srv.Close()
octopusGraphQL = srv.URL + "/"
candidates, err := getMeters("token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
byKind := map[meterKind]meterCandidate{}
for _, c := range candidates {
byKind[c.kind] = c
}
if byKind[electricity].mpan != "1900000000002" {
t.Errorf("electricity mpan: got %q, want 1900000000002", byKind[electricity].mpan)
}
if byKind[solar].mpan != "1900000000001" {
t.Errorf("solar mpan: got %q, want 1900000000001", byKind[solar].mpan)
}
}
func TestResolveMeter_Solar(t *testing.T) {
t.Setenv("OCTOPUS_SOLAR_DEVICE_ID", "")
t.Setenv("OCTOPUS_SOLAR_MPAN", "")
t.Setenv("OCTOPUS_SOLAR_SERIAL", "")
candidates := []meterCandidate{
{kind: electricity, mpan: "1900000000002", serial: "A001", deviceID: "dev1"},
{kind: solar, mpan: "1900000000001", serial: "A001", deviceID: "dev1"},
}
m, err := resolveMeter(candidates, solar)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m == nil {
t.Fatal("expected solar meter, got nil")
}
if m.mpan != "1900000000001" {
t.Errorf("got mpan %q, want 1900000000001", m.mpan)
}
}
func TestGetMeters_BothElectricityAndGas(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{

View file

@ -1,7 +1,5 @@
package main
import "time"
type tariffRates struct {
ElectricityUnitRate float64
ElectricityStandingCharge float64
@ -10,23 +8,20 @@ type tariffRates struct {
ElectricityIsAgile bool
GasUnitRate float64
GasStandingCharge float64
SolarExportRate float64
SolarHasExport bool
}
func getRates(token string) (*tariffRates, error) {
result, err := doGraphQL(gqlRequest{
Query: `{ viewer { accounts { ... on AccountType { properties {
electricityMeterPoints {
direction
agreements { validFrom validTo tariff {
agreements { validTo tariff {
... on StandardTariff { unitRate standingCharge productCode tariffCode }
... on HalfHourlyTariff { standingCharge productCode tariffCode }
... on PrepayTariff { unitRate standingCharge productCode tariffCode }
} }
}
gasMeterPoints {
agreements { validFrom validTo tariff { unitRate standingCharge } }
agreements { validTo tariff { unitRate standingCharge } }
}
} } } } }`,
}, token)
@ -48,23 +43,15 @@ func getRates(token string) (*tariffRates, error) {
}
for _, mp := range toSlice(pm["electricityMeterPoints"]) {
tariff := activeAgreementTariff(mp)
if tariff == nil {
continue
}
mpm, _ := mp.(map[string]any)
if dir, _ := mpm["direction"].(string); dir == "EXPORT" {
rates.SolarExportRate, _ = tariff["unitRate"].(float64)
rates.SolarHasExport = true
continue
}
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.
if _, hasUnit := tariff["unitRate"]; !hasUnit {
rates.ElectricityIsAgile = true
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.
if _, hasUnit := tariff["unitRate"]; !hasUnit {
rates.ElectricityIsAgile = true
}
}
}
@ -80,33 +67,21 @@ func getRates(token string) (*tariffRates, error) {
return rates, nil
}
// activeAgreementTariff returns the tariff for the agreement in effect now:
// validFrom in the past (or absent) and validTo null or in the future. Fixed-term
// tariffs carry a future validTo (their contract end), so matching only validTo==null
// would miss them.
// activeAgreementTariff returns the tariff map for the agreement with validTo == null.
func activeAgreementTariff(meterPoint any) map[string]any {
mp, ok := meterPoint.(map[string]any)
if !ok {
return nil
}
now := time.Now()
for _, ag := range toSlice(mp["agreements"]) {
agm, ok := ag.(map[string]any)
if !ok {
continue
}
if from, ok := agm["validFrom"].(string); ok {
if t, err := time.Parse(time.RFC3339, from); err == nil && t.After(now) {
continue // not started yet
}
if agm["validTo"] == nil {
tariff, _ := agm["tariff"].(map[string]any)
return tariff
}
if to, ok := agm["validTo"].(string); ok {
if t, err := time.Parse(time.RFC3339, to); err == nil && !t.After(now) {
continue // already ended
}
}
tariff, _ := agm["tariff"].(map[string]any)
return tariff
}
return nil
}

View file

@ -23,33 +23,6 @@ func TestActiveAgreementTariff_ActiveFound(t *testing.T) {
}
}
func TestActiveAgreementTariff_FixedTermFutureValidTo(t *testing.T) {
// Fixed-term tariffs carry a future validTo (contract end), not null.
mp := map[string]any{
"agreements": []any{
map[string]any{"validFrom": "2020-01-01T00:00:00Z", "validTo": "2099-01-01T00:00:00Z", "tariff": map[string]any{"unitRate": 23.37}},
},
}
tariff := activeAgreementTariff(mp)
if tariff == nil {
t.Fatal("expected tariff for in-effect fixed-term agreement, got nil")
}
if tariff["unitRate"].(float64) != 23.37 {
t.Errorf("got unitRate %v, want 23.37", tariff["unitRate"])
}
}
func TestActiveAgreementTariff_NotYetStarted(t *testing.T) {
mp := map[string]any{
"agreements": []any{
map[string]any{"validFrom": "2099-01-01T00:00:00Z", "validTo": nil, "tariff": map[string]any{"unitRate": 99.0}},
},
}
if tariff := activeAgreementTariff(mp); tariff != nil {
t.Errorf("expected nil for not-yet-started agreement, got %v", tariff)
}
}
func TestActiveAgreementTariff_NoneActive(t *testing.T) {
mp := map[string]any{
"agreements": []any{
@ -122,40 +95,6 @@ func TestGetRates_AgileTariff(t *testing.T) {
}
}
func TestGetRates_SplitsImportAndExport(t *testing.T) {
// Export meter point listed first (as the live API returns it) must not
// clobber the import unit rate.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
"electricityMeterPoints":[
{"direction":"EXPORT","agreements":[{"validTo":null,"tariff":{
"unitRate":12.0,"standingCharge":0.0,"productCode":"OUTGOING-VAR-24-10-26","tariffCode":"E-1R-OUTGOING-VAR-24-10-26-J"
}}]},
{"direction":"IMPORT","agreements":[{"validFrom":"2020-01-01T00:00:00Z","validTo":"2099-01-01T00:00:00Z","tariff":{
"unitRate":23.37,"standingCharge":46.63,"productCode":"OE-FIX-12M","tariffCode":"E-1R-OE-FIX-12M-J"
}}]}
],
"gasMeterPoints":[]
}]}]}}}`)
}))
defer srv.Close()
octopusGraphQL = srv.URL + "/"
rates, err := getRates("token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rates.ElectricityUnitRate != 23.37 {
t.Errorf("import unit rate: got %v, want 23.37", rates.ElectricityUnitRate)
}
if !rates.SolarHasExport {
t.Error("expected SolarHasExport=true")
}
if rates.SolarExportRate != 12.0 {
t.Errorf("export rate: got %v, want 12.0", rates.SolarExportRate)
}
}
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":[{

View file

@ -6,20 +6,17 @@ import (
"fmt"
)
var errNoTelemetryData = errors.New("no telemetry data returned")
type telemetryReading struct {
ReadAt string `json:"readAt"`
Consumption jsonFloat `json:"consumption"`
Demand jsonFloat `json:"demand"`
Export jsonFloat `json:"export"`
}
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 export\n __typename\n }\n}\n",
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 {
return nil, err
@ -31,7 +28,7 @@ func getLiveConsumption(token, deviceID string) (*telemetryReading, error) {
}
telemetry := toSlice(data["smartMeterTelemetry"])
if len(telemetry) == 0 {
return nil, errNoTelemetryData
return nil, errors.New("no telemetry data returned")
}
raw, err := json.Marshal(telemetry[0])

17
go.mod
View file

@ -2,17 +2,14 @@ module github.com/pez/octopus_exporter
go 1.24
require github.com/prometheus/client_golang v1.23.2
require github.com/prometheus/client_golang v1.19.1
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sys v0.35.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
golang.org/x/sys v0.17.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
)

58
go.sum
View file

@ -1,46 +1,20 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=