mirror of
https://github.com/RWejlgaard/octopus_exporter.git
synced 2026-05-06 04:14:44 +00:00
Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd1e39769e | |||
| c0fcbd7c99 | |||
| ef72127d3c | |||
| c50cb902f6 | |||
| c1491d886e |
20 changed files with 1307 additions and 147 deletions
18
.github/workflows/pr-check.yml
vendored
18
.github/workflows/pr-check.yml
vendored
|
|
@ -5,20 +5,14 @@ on:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up QEMU
|
- uses: actions/setup-go@v5
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
with:
|
||||||
context: .
|
go-version-file: go.mod
|
||||||
push: false
|
|
||||||
platforms: linux/amd64,linux/arm64
|
- name: Test
|
||||||
|
run: go test -race ./...
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
||||||
.env
|
.env
|
||||||
|
coverage.out
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM golang:1.22-alpine AS builder
|
FROM golang:1.24-alpine AS builder
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
|
||||||
60
README.md
60
README.md
|
|
@ -1,13 +1,38 @@
|
||||||
# octopus_exporter
|
# octopus_exporter
|
||||||
|
|
||||||
A Prometheus exporter for live electricity consumption data from [Octopus Energy](https://octopus.energy), using the Kraken GraphQL API.
|
A Prometheus exporter for Octopus Energy smart meter data, using the Kraken GraphQL API.
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
|
|
||||||
| Metric | Description |
|
### Electricity
|
||||||
|---|---|
|
|
||||||
| `octopus_live_consumption` | Live electricity demand in watts |
|
| Metric | Source | Description |
|
||||||
| `octopus_live_consumption_last_read` | Timestamp of the last meter reading (Unix epoch) |
|
|---|---|---|
|
||||||
|
| `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 | 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 | Source | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
|
@ -16,12 +41,15 @@ Metrics are updated every 60 seconds.
|
||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `OCTOPUS_API_KEY` | Yes | Your Octopus Energy API key |
|
| `OCTOPUS_API_KEY` | Yes | Your Octopus Energy API key |
|
||||||
| `OCTOPUS_MPAN` | No | Filter by MPAN (recommended if you have multiple meters) |
|
| `OCTOPUS_MPAN` | No | Filter electricity meter by MPAN |
|
||||||
| `OCTOPUS_SERIAL` | No | Filter by meter serial number |
|
| `OCTOPUS_SERIAL` | No | Filter electricity meter by serial number |
|
||||||
| `OCTOPUS_DEVICE_ID` | No | Use a specific smart device ID directly |
|
| `OCTOPUS_DEVICE_ID` | No | Use a specific electricity smart device ID directly |
|
||||||
|
| `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 |
|
||||||
| `PORT` | No | Port to expose metrics on (default: `9359`) |
|
| `PORT` | No | Port to expose metrics on (default: `9359`) |
|
||||||
|
|
||||||
If none of `OCTOPUS_MPAN`, `OCTOPUS_SERIAL`, or `OCTOPUS_DEVICE_ID` are set, the exporter will automatically select the first smart meter found on your account. For accounts with multiple meters, use `OCTOPUS_MPAN` or `OCTOPUS_SERIAL` to pin to a specific one.
|
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.
|
||||||
|
|
||||||
Your API key can be found in the [Octopus Energy developer dashboard](https://octopus.energy/dashboard/new/accounts/personal-details/api-access).
|
Your API key can be found in the [Octopus Energy developer dashboard](https://octopus.energy/dashboard/new/accounts/personal-details/api-access).
|
||||||
|
|
||||||
|
|
@ -35,6 +63,20 @@ docker run -d \
|
||||||
rwejlgaard/octopus_exporter
|
rwejlgaard/octopus_exporter
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
octopus-exporter:
|
||||||
|
image: rwejlgaard/octopus_exporter:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
OCTOPUS_API_KEY: sk_live_...
|
||||||
|
OCTOPUS_MPAN: "1234567890123" # optional, auto-discovered if omitted
|
||||||
|
ports:
|
||||||
|
- "9359:9359"
|
||||||
|
```
|
||||||
|
|
||||||
## Running from source
|
## Running from source
|
||||||
|
|
||||||
Requires Go 1.22+.
|
Requires Go 1.22+.
|
||||||
|
|
|
||||||
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, key 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)},
|
||||||
|
}, key)
|
||||||
|
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
|
||||||
|
}
|
||||||
56
cmd/octopus_exporter/agile_test.go
Normal file
56
cmd/octopus_exporter/agile_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
rate, err := getCurrentAgileRate("AGILE-24-10-01", "E-1R-AGILE-24-10-01-C", "test")
|
||||||
|
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
|
||||||
|
|
||||||
|
_, err := getCurrentAgileRate("AGILE-24-10-01", "E-1R-AGILE-24-10-01-C", "test")
|
||||||
|
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
|
||||||
|
|
||||||
|
getCurrentAgileRate("AGILE-24-10-01", "E-1R-AGILE-24-10-01-C", "test")
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,11 +6,20 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
)
|
)
|
||||||
|
|
||||||
const octopusGraphQL = "https://api.octopus.energy/v1/graphql/"
|
var (
|
||||||
|
octopusGraphQL = "https://api.octopus.energy/v1/graphql/"
|
||||||
|
httpClient = &http.Client{Timeout: 15 * time.Second}
|
||||||
|
rateLimitRetries prometheus.Counter
|
||||||
|
)
|
||||||
|
|
||||||
type gqlRequest struct {
|
type gqlRequest struct {
|
||||||
OperationName string `json:"operationName,omitempty"`
|
OperationName string `json:"operationName,omitempty"`
|
||||||
|
|
@ -39,11 +48,62 @@ func (f *jsonFloat) UnmarshalJSON(data []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// executeWithRetry executes an HTTP request, retrying on 429 with exponential
|
||||||
|
// backoff (honouring Retry-After when present). Returns the raw response body.
|
||||||
|
func executeWithRetry(makeReq func() (*http.Request, error)) ([]byte, error) {
|
||||||
|
const maxRetries = 5
|
||||||
|
backoff := 30 * time.Second
|
||||||
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||||
|
req, err := makeReq()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
|
resp.Body.Close()
|
||||||
|
if attempt == maxRetries {
|
||||||
|
return nil, errors.New("rate limited: max retries exceeded")
|
||||||
|
}
|
||||||
|
if rateLimitRetries != nil {
|
||||||
|
rateLimitRetries.Inc()
|
||||||
|
}
|
||||||
|
wait := backoff
|
||||||
|
if ra := resp.Header.Get("Retry-After"); ra != "" {
|
||||||
|
if secs, err := strconv.Atoi(ra); err == nil {
|
||||||
|
wait = time.Duration(secs) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("rate limited; retrying in %v (attempt %d/%d)", wait, attempt+1, maxRetries)
|
||||||
|
time.Sleep(wait)
|
||||||
|
backoff *= 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
snippet := strings.TrimSpace(string(raw))
|
||||||
|
if len(snippet) > 200 {
|
||||||
|
snippet = snippet[:200]
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, snippet)
|
||||||
|
}
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("rate limited: max retries exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
func doGraphQL(req gqlRequest, authToken string) (map[string]any, error) {
|
func doGraphQL(req gqlRequest, authToken string) (map[string]any, error) {
|
||||||
body, err := json.Marshal(req)
|
body, err := json.Marshal(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
raw, err := executeWithRetry(func() (*http.Request, error) {
|
||||||
httpReq, err := http.NewRequest(http.MethodPost, octopusGraphQL, bytes.NewReader(body))
|
httpReq, err := http.NewRequest(http.MethodPost, octopusGraphQL, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -52,12 +112,8 @@ func doGraphQL(req gqlRequest, authToken string) (map[string]any, error) {
|
||||||
if authToken != "" {
|
if authToken != "" {
|
||||||
httpReq.Header.Set("Authorization", "JWT "+authToken)
|
httpReq.Header.Set("Authorization", "JWT "+authToken)
|
||||||
}
|
}
|
||||||
resp, err := http.DefaultClient.Do(httpReq)
|
return httpReq, nil
|
||||||
if err != nil {
|
})
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +123,11 @@ func doGraphQL(req gqlRequest, authToken string) (map[string]any, error) {
|
||||||
}
|
}
|
||||||
if errs, ok := result["errors"].([]any); ok && len(errs) > 0 {
|
if errs, ok := result["errors"].([]any); ok && len(errs) > 0 {
|
||||||
if e, ok := errs[0].(map[string]any); ok {
|
if e, ok := errs[0].(map[string]any); ok {
|
||||||
return nil, fmt.Errorf("GraphQL error: %s", e["message"])
|
msg, _ := e["message"].(string)
|
||||||
|
if strings.Contains(msg, "JWT") && strings.Contains(msg, "expired") {
|
||||||
|
return nil, errTokenExpired
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("GraphQL error: %s", msg)
|
||||||
}
|
}
|
||||||
return nil, errors.New("GraphQL error")
|
return nil, errors.New("GraphQL error")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
cmd/octopus_exporter/client_test.go
Normal file
121
cmd/octopus_exporter/client_test.go
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteWithRetry_RateLimitRetry(t *testing.T) {
|
||||||
|
var attempts atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := attempts.Add(1)
|
||||||
|
if n < 3 {
|
||||||
|
w.Header().Set("Retry-After", "0")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `ok`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
raw, err := executeWithRetry(func() (*http.Request, error) {
|
||||||
|
return http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if string(raw) != "ok" {
|
||||||
|
t.Errorf("got body %q, want %q", string(raw), "ok")
|
||||||
|
}
|
||||||
|
if attempts.Load() != 3 {
|
||||||
|
t.Errorf("got %d attempts, want 3", attempts.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteWithRetry_Non200Error(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
fmt.Fprint(w, `{"error":"unauthorized"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := executeWithRetry(func() (*http.Request, error) {
|
||||||
|
return http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for 401, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "401") {
|
||||||
|
t.Errorf("expected error to contain status code, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteWithRetry_ServerError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(w, `internal server error`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := executeWithRetry(func() (*http.Request, error) {
|
||||||
|
return http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for 500, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "500") {
|
||||||
|
t.Errorf("expected error to contain status code, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
cmd/octopus_exporter/consumption.go
Normal file
53
cmd/octopus_exporter/consumption.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type consumptionReading struct {
|
||||||
|
KWh float64
|
||||||
|
IntervalStart time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLatestConsumption(kind meterKind, id, serial, key 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"},
|
||||||
|
}, key)
|
||||||
|
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, ok := results[len(results)-1].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("unexpected API response: invalid result entry")
|
||||||
|
}
|
||||||
|
kwh, _ := latest["consumption"].(float64)
|
||||||
|
startStr, ok := latest["interval_start"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("unexpected API response: missing interval_start")
|
||||||
|
}
|
||||||
|
start, err := time.Parse(time.RFC3339, startStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse interval_start: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &consumptionReading{KWh: kwh, IntervalStart: start}, nil
|
||||||
|
}
|
||||||
73
cmd/octopus_exporter/consumption_test.go
Normal file
73
cmd/octopus_exporter/consumption_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
c, err := getLatestConsumption(electricity, "MPAN123", "SERIAL456", "test")
|
||||||
|
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
|
||||||
|
|
||||||
|
_, err := getLatestConsumption(electricity, "MPAN123", "SERIAL456", "test")
|
||||||
|
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
|
||||||
|
|
||||||
|
getLatestConsumption(gas, "MPRN789", "SERIAL456", "test")
|
||||||
|
|
||||||
|
want := "/v1/gas-meter-points/MPRN789/meters/SERIAL456/consumption/"
|
||||||
|
if capturedPath != want {
|
||||||
|
t.Errorf("got path %q, want %q", capturedPath, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLatestConsumption_Non200Error(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
fmt.Fprint(w, `{"error":"unauthorized"}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
octopusREST = srv.URL
|
||||||
|
|
||||||
|
_, err := getLatestConsumption(electricity, "MPAN123", "SERIAL456", "bad-key")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for 401, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
@ -11,8 +14,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 {
|
||||||
|
|
@ -34,32 +37,51 @@ func gauge(name, help string) prometheus.Gauge {
|
||||||
return prometheus.NewGauge(prometheus.GaugeOpts{Name: name, Help: help})
|
return prometheus.NewGauge(prometheus.GaugeOpts{Name: name, Help: help})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func counter(name, help string) prometheus.Counter {
|
||||||
|
return prometheus.NewCounter(prometheus.CounterOpts{Name: name, Help: help})
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
log.Println("discovering meters from account...")
|
||||||
|
candidates, err := getMeters(token)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to discover meters: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
elecMeter, err := resolveMeter(candidates, 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(candidates, 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 +89,36 @@ 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}
|
// Exporter health
|
||||||
|
exporterUp := gauge("octopus_up", "1 if the last poll cycle completed without errors, 0 otherwise")
|
||||||
|
pollErrors := counter("octopus_poll_errors_total", "Total number of collector errors per poll cycle")
|
||||||
|
tokenRefreshCount := counter("octopus_token_refreshes_total", "Total number of successful JWT token refreshes")
|
||||||
|
rateLimitRetries = counter("octopus_rate_limit_retries_total", "Total number of 429 rate-limit retries across all requests")
|
||||||
|
|
||||||
|
toRegister := []prometheus.Collector{
|
||||||
|
elecDemand, elecLastRead,
|
||||||
|
elecConsumption, elecConsumptionInterval,
|
||||||
|
elecUnitRate, elecStandingCharge,
|
||||||
|
accountBalance,
|
||||||
|
exporterUp, pollErrors, tokenRefreshCount, rateLimitRetries,
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
gasDemand prometheus.Gauge
|
gasDemand prometheus.Gauge
|
||||||
gasLastRead prometheus.Gauge
|
gasLastRead prometheus.Gauge
|
||||||
|
gasConsumption prometheus.Gauge
|
||||||
|
gasConsumptionInterval prometheus.Gauge
|
||||||
gasUnitRate prometheus.Gauge
|
gasUnitRate prometheus.Gauge
|
||||||
gasStandCharge 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...)
|
||||||
|
|
@ -93,53 +131,170 @@ func main() {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// tokenMu guards token across concurrent poll goroutines.
|
||||||
|
var tokenMu sync.RWMutex
|
||||||
|
|
||||||
|
// withToken calls fn with the current token, refreshing once on JWT expiry.
|
||||||
|
withToken := func(fn func(string) error) error {
|
||||||
|
tokenMu.RLock()
|
||||||
|
t := token
|
||||||
|
tokenMu.RUnlock()
|
||||||
|
|
||||||
|
err := fn(t)
|
||||||
|
if !errors.Is(err, errTokenExpired) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only one goroutine refreshes; others will pick up the new token.
|
||||||
|
tokenMu.Lock()
|
||||||
|
if token == t {
|
||||||
|
newT, e := getKrakenToken(apiKey)
|
||||||
|
if e != nil {
|
||||||
|
tokenMu.Unlock()
|
||||||
|
log.Printf("token refresh failed: %v", e)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
token = newT
|
||||||
|
tokenRefreshCount.Inc()
|
||||||
|
}
|
||||||
|
newT := token
|
||||||
|
tokenMu.Unlock()
|
||||||
|
|
||||||
|
return fn(newT)
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// Electricity telemetry
|
var (
|
||||||
reading, err := getLiveConsumption(token, elecDeviceID)
|
wg sync.WaitGroup
|
||||||
if err != nil {
|
failedAny atomic.Bool
|
||||||
log.Printf("electricity telemetry error: %v", err)
|
)
|
||||||
if token, err = getKrakenToken(apiKey); err != nil {
|
|
||||||
log.Printf("token refresh failed: %v", err)
|
fail := func(format string, args ...any) {
|
||||||
|
log.Printf(format, args...)
|
||||||
|
pollErrors.Inc()
|
||||||
|
failedAny.Store(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
collect := func(name string, fn func() error) {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := fn(); err != nil {
|
||||||
|
fail("%s error: %v", name, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Electricity telemetry (live demand)
|
||||||
|
if elecMeter.deviceID != "" {
|
||||||
|
collect("electricity telemetry", func() error {
|
||||||
|
return withToken(func(t string) error {
|
||||||
|
reading, err := getLiveConsumption(t, elecMeter.deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
elecDemand.Set(float64(reading.Demand))
|
elecDemand.Set(float64(reading.Demand))
|
||||||
if t, err := time.Parse("2006-01-02T15:04:05+00:00", reading.ReadAt); err == nil {
|
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
|
||||||
elecLastRead.Set(float64(t.Unix()))
|
elecLastRead.Set(float64(ts.Unix()))
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gas telemetry
|
// Gas telemetry (live demand)
|
||||||
if gasDeviceID != "" {
|
if gasMeter != nil && gasMeter.deviceID != "" {
|
||||||
reading, err := getLiveConsumption(token, gasDeviceID)
|
collect("gas telemetry", func() error {
|
||||||
|
return withToken(func(t string) error {
|
||||||
|
reading, err := getLiveConsumption(t, gasMeter.deviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("gas telemetry error: %v", err)
|
return err
|
||||||
} else {
|
}
|
||||||
gasDemand.Set(float64(reading.Demand))
|
gasDemand.Set(float64(reading.Demand))
|
||||||
if t, err := time.Parse("2006-01-02T15:04:05+00:00", reading.ReadAt); err == nil {
|
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
|
||||||
gasLastRead.Set(float64(t.Unix()))
|
gasLastRead.Set(float64(ts.Unix()))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rates
|
// Electricity half-hourly consumption (REST)
|
||||||
rates, err := getRates(token)
|
if elecMeter.mpan != "" && elecMeter.serial != "" {
|
||||||
|
collect("electricity consumption", func() error {
|
||||||
|
c, err := getLatestConsumption(electricity, elecMeter.mpan, elecMeter.serial, apiKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("rates error: %v", err)
|
return err
|
||||||
} else {
|
|
||||||
elecUnitRate.Set(rates.ElectricityUnitRate)
|
|
||||||
elecStandingCharge.Set(rates.ElectricityStandingCharge)
|
|
||||||
if gasDeviceID != "" {
|
|
||||||
gasUnitRate.Set(rates.GasUnitRate)
|
|
||||||
gasStandCharge.Set(rates.GasStandingCharge)
|
|
||||||
}
|
}
|
||||||
|
elecConsumption.Set(c.KWh)
|
||||||
|
elecConsumptionInterval.Set(float64(c.IntervalStart.Unix()))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gas half-hourly consumption (REST)
|
||||||
|
if gasMeter != nil && gasMeter.mprn != "" && gasMeter.serial != "" {
|
||||||
|
collect("gas consumption", func() error {
|
||||||
|
c, err := getLatestConsumption(gas, gasMeter.mprn, gasMeter.serial, apiKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
gasConsumption.Set(c.KWh)
|
||||||
|
gasConsumptionInterval.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 {
|
||||||
|
return withToken(func(t string) error {
|
||||||
|
r, err := getRates(t)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
collectedRates = r
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Account balance
|
// Account balance
|
||||||
balance, err := getAccountBalance(token)
|
collect("account balance", func() error {
|
||||||
|
return withToken(func(t string) error {
|
||||||
|
balance, err := getAccountBalance(t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("account balance error: %v", err)
|
return err
|
||||||
} else {
|
}
|
||||||
accountBalance.Set(balance)
|
accountBalance.Set(balance)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Agile rate depends on the rates result, so it runs after the parallel phase.
|
||||||
|
if collectedRates != nil {
|
||||||
|
unitRate := collectedRates.ElectricityUnitRate
|
||||||
|
if collectedRates.ElectricityIsAgile && collectedRates.ElectricityProductCode != "" && collectedRates.ElectricityTariffCode != "" {
|
||||||
|
agileRate, err := getCurrentAgileRate(collectedRates.ElectricityProductCode, collectedRates.ElectricityTariffCode, apiKey)
|
||||||
|
if err != nil {
|
||||||
|
fail("agile rate error: %v", err)
|
||||||
|
} else {
|
||||||
|
unitRate = agileRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elecUnitRate.Set(unitRate)
|
||||||
|
elecStandingCharge.Set(collectedRates.ElectricityStandingCharge)
|
||||||
|
if gasMeter != nil {
|
||||||
|
gasUnitRate.Set(collectedRates.GasUnitRate)
|
||||||
|
gasStandCharge.Set(collectedRates.GasStandingCharge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if failedAny.Load() {
|
||||||
|
exporterUp.Set(0)
|
||||||
|
} else {
|
||||||
|
exporterUp.Set(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(60 * time.Second)
|
time.Sleep(60 * time.Second)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -47,35 +53,73 @@ func getMeters(token string) ([]meterCandidate, error) {
|
||||||
|
|
||||||
var candidates []meterCandidate
|
var candidates []meterCandidate
|
||||||
|
|
||||||
accounts, _ := result["data"].(map[string]any)["viewer"].(map[string]any)["accounts"].([]any)
|
data, _ := result["data"].(map[string]any)
|
||||||
|
viewer, _ := data["viewer"].(map[string]any)
|
||||||
|
accounts, _ := viewer["accounts"].([]any)
|
||||||
for _, a := range accounts {
|
for _, a := range accounts {
|
||||||
props, _ := a.(map[string]any)["properties"].([]any)
|
props, _ := a.(map[string]any)["properties"].([]any)
|
||||||
for _, p := range props {
|
for _, p := range props {
|
||||||
pm := p.(map[string]any)
|
pm, ok := p.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for _, mp := range toSlice(pm["electricityMeterPoints"]) {
|
for _, mp := range toSlice(pm["electricityMeterPoints"]) {
|
||||||
mpan, _ := mp.(map[string]any)["mpan"].(string)
|
mpPoint, ok := mp.(map[string]any)
|
||||||
for _, m := range toSlice(mp.(map[string]any)["meters"]) {
|
if !ok {
|
||||||
serial, _ := m.(map[string]any)["serialNumber"].(string)
|
continue
|
||||||
for _, d := range toSlice(m.(map[string]any)["smartDevices"]) {
|
}
|
||||||
deviceID, _ := d.(map[string]any)["deviceId"].(string)
|
mpan, _ := mpPoint["mpan"].(string)
|
||||||
|
for _, m := range toSlice(mpPoint["meters"]) {
|
||||||
|
meterMap, ok := m.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
serial, _ := meterMap["serialNumber"].(string)
|
||||||
|
devices := toSlice(meterMap["smartDevices"])
|
||||||
|
for _, d := range devices {
|
||||||
|
dMap, ok := d.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deviceID, _ := dMap["deviceId"].(string)
|
||||||
if deviceID != "" {
|
if deviceID != "" {
|
||||||
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(devices) == 0 && serial != "" {
|
||||||
|
candidates = append(candidates, meterCandidate{kind: electricity, mpan: mpan, serial: serial})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, mp := range toSlice(pm["gasMeterPoints"]) {
|
for _, mp := range toSlice(pm["gasMeterPoints"]) {
|
||||||
mprn, _ := mp.(map[string]any)["mprn"].(string)
|
mpPoint, ok := mp.(map[string]any)
|
||||||
for _, m := range toSlice(mp.(map[string]any)["meters"]) {
|
if !ok {
|
||||||
serial, _ := m.(map[string]any)["serialNumber"].(string)
|
continue
|
||||||
for _, d := range toSlice(m.(map[string]any)["smartDevices"]) {
|
}
|
||||||
deviceID, _ := d.(map[string]any)["deviceId"].(string)
|
mprn, _ := mpPoint["mprn"].(string)
|
||||||
|
for _, m := range toSlice(mpPoint["meters"]) {
|
||||||
|
meterMap, ok := m.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
serial, _ := meterMap["serialNumber"].(string)
|
||||||
|
devices := toSlice(meterMap["smartDevices"])
|
||||||
|
for _, d := range devices {
|
||||||
|
dMap, ok := d.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deviceID, _ := dMap["deviceId"].(string)
|
||||||
if deviceID != "" {
|
if deviceID != "" {
|
||||||
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(devices) == 0 && serial != "" {
|
||||||
|
candidates = append(candidates, meterCandidate{kind: gas, mprn: mprn, serial: serial})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -84,9 +128,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(candidates []meterCandidate, kind meterKind) (*resolvedMeter, error) {
|
||||||
var wantDeviceID, wantID, wantSerial string
|
var wantDeviceID, wantID, wantSerial string
|
||||||
switch kind {
|
switch kind {
|
||||||
case electricity:
|
case electricity:
|
||||||
|
|
@ -99,16 +143,6 @@ 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)
|
|
||||||
candidates, err := getMeters(token)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range candidates {
|
for _, c := range candidates {
|
||||||
if c.kind != kind {
|
if c.kind != kind {
|
||||||
continue
|
continue
|
||||||
|
|
@ -127,28 +161,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")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
209
cmd/octopus_exporter/meters_test.go
Normal file
209
cmd/octopus_exporter/meters_test.go
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveMeter_NoFilters_ReturnsFirst(t *testing.T) {
|
||||||
|
t.Setenv("OCTOPUS_DEVICE_ID", "")
|
||||||
|
t.Setenv("OCTOPUS_MPAN", "")
|
||||||
|
t.Setenv("OCTOPUS_SERIAL", "")
|
||||||
|
|
||||||
|
candidates := []meterCandidate{
|
||||||
|
{kind: electricity, mpan: "1000000000001", serial: "A001", deviceID: "dev1"},
|
||||||
|
{kind: electricity, mpan: "1000000000002", serial: "A002", deviceID: "dev2"},
|
||||||
|
}
|
||||||
|
m, err := resolveMeter(candidates, electricity)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("expected meter, got nil")
|
||||||
|
}
|
||||||
|
if m.mpan != "1000000000001" {
|
||||||
|
t.Errorf("got mpan %q, want 1000000000001", m.mpan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMeter_FilterByMPAN(t *testing.T) {
|
||||||
|
t.Setenv("OCTOPUS_DEVICE_ID", "")
|
||||||
|
t.Setenv("OCTOPUS_MPAN", "1000000000002")
|
||||||
|
t.Setenv("OCTOPUS_SERIAL", "")
|
||||||
|
|
||||||
|
candidates := []meterCandidate{
|
||||||
|
{kind: electricity, mpan: "1000000000001", serial: "A001", deviceID: "dev1"},
|
||||||
|
{kind: electricity, mpan: "1000000000002", serial: "A002", deviceID: "dev2"},
|
||||||
|
}
|
||||||
|
m, err := resolveMeter(candidates, electricity)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("expected meter, got nil")
|
||||||
|
}
|
||||||
|
if m.mpan != "1000000000002" {
|
||||||
|
t.Errorf("got mpan %q, want 1000000000002", m.mpan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMeter_FilterByDeviceID(t *testing.T) {
|
||||||
|
t.Setenv("OCTOPUS_DEVICE_ID", "dev2")
|
||||||
|
t.Setenv("OCTOPUS_MPAN", "")
|
||||||
|
t.Setenv("OCTOPUS_SERIAL", "")
|
||||||
|
|
||||||
|
candidates := []meterCandidate{
|
||||||
|
{kind: electricity, mpan: "1000000000001", serial: "A001", deviceID: "dev1"},
|
||||||
|
{kind: electricity, mpan: "1000000000002", serial: "A002", deviceID: "dev2"},
|
||||||
|
}
|
||||||
|
m, err := resolveMeter(candidates, electricity)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if m.deviceID != "dev2" {
|
||||||
|
t.Errorf("got deviceID %q, want dev2", m.deviceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMeter_FilterMismatch_ReturnsError(t *testing.T) {
|
||||||
|
t.Setenv("OCTOPUS_MPAN", "9999999999999")
|
||||||
|
t.Setenv("OCTOPUS_DEVICE_ID", "")
|
||||||
|
t.Setenv("OCTOPUS_SERIAL", "")
|
||||||
|
|
||||||
|
candidates := []meterCandidate{
|
||||||
|
{kind: electricity, mpan: "1000000000001", serial: "A001"},
|
||||||
|
}
|
||||||
|
_, err := resolveMeter(candidates, electricity)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for unmatched filter, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMeter_NoMetersOfKind_ReturnsNil(t *testing.T) {
|
||||||
|
t.Setenv("OCTOPUS_DEVICE_ID", "")
|
||||||
|
t.Setenv("OCTOPUS_MPAN", "")
|
||||||
|
t.Setenv("OCTOPUS_SERIAL", "")
|
||||||
|
|
||||||
|
candidates := []meterCandidate{
|
||||||
|
{kind: gas, mprn: "1111111111", serial: "G001"},
|
||||||
|
}
|
||||||
|
m, err := resolveMeter(candidates, electricity)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if m != nil {
|
||||||
|
t.Errorf("expected nil for no electricity meters, got %+v", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMeter_GasFilterByMPRN(t *testing.T) {
|
||||||
|
t.Setenv("OCTOPUS_GAS_DEVICE_ID", "")
|
||||||
|
t.Setenv("OCTOPUS_GAS_MPRN", "2222222222")
|
||||||
|
t.Setenv("OCTOPUS_GAS_SERIAL", "")
|
||||||
|
|
||||||
|
candidates := []meterCandidate{
|
||||||
|
{kind: gas, mprn: "1111111111", serial: "G001"},
|
||||||
|
{kind: gas, mprn: "2222222222", serial: "G002"},
|
||||||
|
}
|
||||||
|
m, err := resolveMeter(candidates, gas)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if m.mprn != "2222222222" {
|
||||||
|
t.Errorf("got mprn %q, want 2222222222", m.mprn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMeters_ElectricityWithSmartDevice(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
|
||||||
|
"electricityMeterPoints":[{
|
||||||
|
"mpan":"1000000000001",
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
c := candidates[0]
|
||||||
|
if c.kind != electricity {
|
||||||
|
t.Errorf("kind: got %q, want electricity", c.kind)
|
||||||
|
}
|
||||||
|
if c.mpan != "1000000000001" {
|
||||||
|
t.Errorf("mpan: got %q, want 1000000000001", c.mpan)
|
||||||
|
}
|
||||||
|
if c.deviceID != "dev1" {
|
||||||
|
t.Errorf("deviceID: got %q, want dev1", c.deviceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMeters_MeterWithoutSmartDevice(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
|
||||||
|
"electricityMeterPoints":[{
|
||||||
|
"mpan":"1000000000001",
|
||||||
|
"meters":[{"serialNumber":"A001","smartDevices":[]}]
|
||||||
|
}],
|
||||||
|
"gasMeterPoints":[]
|
||||||
|
}]}]}}}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
octopusGraphQL = srv.URL + "/"
|
||||||
|
|
||||||
|
candidates, err := getMeters("token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1", len(candidates))
|
||||||
|
}
|
||||||
|
if candidates[0].deviceID != "" {
|
||||||
|
t.Errorf("expected empty deviceID for meter without smart device, got %q", candidates[0].deviceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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":[{
|
||||||
|
"electricityMeterPoints":[{
|
||||||
|
"mpan":"1000000000001",
|
||||||
|
"meters":[{"serialNumber":"A001","smartDevices":[{"deviceId":"dev1"}]}]
|
||||||
|
}],
|
||||||
|
"gasMeterPoints":[{
|
||||||
|
"mprn":"1111111111",
|
||||||
|
"meters":[{"serialNumber":"G001","smartDevices":[{"deviceId":"gdev1"}]}]
|
||||||
|
}]
|
||||||
|
}]}]}}}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
octopusGraphQL = srv.URL + "/"
|
||||||
|
|
||||||
|
candidates, err := getMeters("token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(candidates) != 2 {
|
||||||
|
t.Fatalf("got %d candidates, want 2", len(candidates))
|
||||||
|
}
|
||||||
|
kinds := map[meterKind]bool{}
|
||||||
|
for _, c := range candidates {
|
||||||
|
kinds[c.kind] = true
|
||||||
|
}
|
||||||
|
if !kinds[electricity] {
|
||||||
|
t.Error("expected electricity candidate")
|
||||||
|
}
|
||||||
|
if !kinds[gas] {
|
||||||
|
t.Error("expected gas candidate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 } }
|
||||||
|
|
@ -31,16 +31,27 @@ func getRates(token string) (*tariffRates, error) {
|
||||||
|
|
||||||
rates := &tariffRates{}
|
rates := &tariffRates{}
|
||||||
|
|
||||||
accounts, _ := result["data"].(map[string]any)["viewer"].(map[string]any)["accounts"].([]any)
|
data, _ := result["data"].(map[string]any)
|
||||||
|
viewer, _ := data["viewer"].(map[string]any)
|
||||||
|
accounts, _ := viewer["accounts"].([]any)
|
||||||
for _, a := range accounts {
|
for _, a := range accounts {
|
||||||
props, _ := a.(map[string]any)["properties"].([]any)
|
props, _ := a.(map[string]any)["properties"].([]any)
|
||||||
for _, p := range props {
|
for _, p := range props {
|
||||||
pm := p.(map[string]any)
|
pm, ok := p.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for _, mp := range toSlice(pm["electricityMeterPoints"]) {
|
for _, mp := range toSlice(pm["electricityMeterPoints"]) {
|
||||||
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.
|
||||||
|
if _, hasUnit := tariff["unitRate"]; !hasUnit {
|
||||||
|
rates.ElectricityIsAgile = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,8 +69,15 @@ func getRates(token string) (*tariffRates, error) {
|
||||||
|
|
||||||
// activeAgreementTariff returns the tariff map for the agreement with validTo == null.
|
// activeAgreementTariff returns the tariff map for the agreement with validTo == null.
|
||||||
func activeAgreementTariff(meterPoint any) map[string]any {
|
func activeAgreementTariff(meterPoint any) map[string]any {
|
||||||
for _, ag := range toSlice(meterPoint.(map[string]any)["agreements"]) {
|
mp, ok := meterPoint.(map[string]any)
|
||||||
agm := ag.(map[string]any)
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, ag := range toSlice(mp["agreements"]) {
|
||||||
|
agm, ok := ag.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if agm["validTo"] == nil {
|
if agm["validTo"] == nil {
|
||||||
tariff, _ := agm["tariff"].(map[string]any)
|
tariff, _ := agm["tariff"].(map[string]any)
|
||||||
return tariff
|
return tariff
|
||||||
|
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
cmd/octopus_exporter/rest.go
Normal file
32
cmd/octopus_exporter/rest.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
var octopusREST = "https://api.octopus.energy"
|
||||||
|
|
||||||
|
func doREST(path string, params url.Values, key string) (map[string]any, error) {
|
||||||
|
u := octopusREST + path
|
||||||
|
if len(params) > 0 {
|
||||||
|
u += "?" + params.Encode()
|
||||||
|
}
|
||||||
|
raw, err := executeWithRetry(func() (*http.Request, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(key, "")
|
||||||
|
return req, nil
|
||||||
|
})
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package main
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type telemetryReading struct {
|
type telemetryReading struct {
|
||||||
|
|
@ -18,13 +19,14 @@ func getLiveConsumption(token, deviceID string) (*telemetryReading, error) {
|
||||||
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",
|
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)
|
}, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() == "GraphQL error: Signature of the JWT has expired." {
|
|
||||||
return nil, errTokenExpired
|
|
||||||
}
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
telemetry := toSlice(result["data"].(map[string]any)["smartMeterTelemetry"])
|
data, ok := result["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected API response: missing data field")
|
||||||
|
}
|
||||||
|
telemetry := toSlice(data["smartMeterTelemetry"])
|
||||||
if len(telemetry) == 0 {
|
if len(telemetry) == 0 {
|
||||||
return nil, errors.New("no telemetry data returned")
|
return nil, errors.New("no telemetry data returned")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
cmd/octopus_exporter/telemetry_test.go
Normal file
71
cmd/octopus_exporter/telemetry_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetLiveConsumption_Success(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"data":{"smartMeterTelemetry":[
|
||||||
|
{"readAt":"2026-04-24T10:00:00Z","consumption":"1.23","demand":"456.7"}
|
||||||
|
]}}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
octopusGraphQL = srv.URL + "/"
|
||||||
|
|
||||||
|
reading, err := getLiveConsumption("test-token", "device-123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if float64(reading.Demand) != 456.7 {
|
||||||
|
t.Errorf("demand: got %v, want 456.7", reading.Demand)
|
||||||
|
}
|
||||||
|
if float64(reading.Consumption) != 1.23 {
|
||||||
|
t.Errorf("consumption: got %v, want 1.23", reading.Consumption)
|
||||||
|
}
|
||||||
|
if reading.ReadAt != "2026-04-24T10:00:00Z" {
|
||||||
|
t.Errorf("readAt: got %q, want %q", reading.ReadAt, "2026-04-24T10:00:00Z")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLiveConsumption_EmptyTelemetry(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"data":{"smartMeterTelemetry":[]}}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
octopusGraphQL = srv.URL + "/"
|
||||||
|
|
||||||
|
_, err := getLiveConsumption("test-token", "device-123")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for empty telemetry, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLiveConsumption_MissingDataField(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"data":null}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
octopusGraphQL = srv.URL + "/"
|
||||||
|
|
||||||
|
_, err := getLiveConsumption("test-token", "device-123")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for null data, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetLiveConsumption_GraphQLError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"errors":[{"message":"device not found"}]}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
octopusGraphQL = srv.URL + "/"
|
||||||
|
|
||||||
|
_, err := getLiveConsumption("test-token", "device-123")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for GraphQL error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue