mirror of
https://github.com/RWejlgaard/octopus_exporter.git
synced 2026-05-06 04:14:44 +00:00
major: initial release (#4)
This commit is contained in:
parent
36f8003435
commit
1e0eff520f
7 changed files with 483 additions and 229 deletions
21
cmd/octopus_exporter/account.go
Normal file
21
cmd/octopus_exporter/account.go
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
func getAccountBalance(token string) (float64, error) {
|
||||||
|
result, err := doGraphQL(gqlRequest{
|
||||||
|
Query: `{ viewer { accounts { ... on AccountType { balance } } } }`,
|
||||||
|
}, token)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
accounts, _ := result["data"].(map[string]any)["viewer"].(map[string]any)["accounts"].([]any)
|
||||||
|
for _, a := range accounts {
|
||||||
|
if bal, ok := a.(map[string]any)["balance"].(float64); ok {
|
||||||
|
return bal, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, errors.New("balance not found in response")
|
||||||
|
}
|
||||||
24
cmd/octopus_exporter/auth.go
Normal file
24
cmd/octopus_exporter/auth.go
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var errTokenExpired = errors.New("token expired")
|
||||||
|
|
||||||
|
func getKrakenToken(apiKey string) (string, error) {
|
||||||
|
result, err := doGraphQL(gqlRequest{
|
||||||
|
Variables: map[string]any{"apikey": apiKey},
|
||||||
|
Query: `mutation krakenTokenAuthentication($apikey: String!) {
|
||||||
|
obtainKrakenToken(input: {APIKey: $apikey}) {
|
||||||
|
token
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
}, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
token, ok := result["data"].(map[string]any)["obtainKrakenToken"].(map[string]any)["token"].(string)
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("token not found in response")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
80
cmd/octopus_exporter/client.go
Normal file
80
cmd/octopus_exporter/client.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
const octopusGraphQL = "https://api.octopus.energy/v1/graphql/"
|
||||||
|
|
||||||
|
type gqlRequest struct {
|
||||||
|
OperationName string `json:"operationName,omitempty"`
|
||||||
|
Variables map[string]any `json:"variables"`
|
||||||
|
Query string `json:"query"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonFloat unmarshals both JSON numbers and quoted strings into float64.
|
||||||
|
type jsonFloat float64
|
||||||
|
|
||||||
|
func (f *jsonFloat) UnmarshalJSON(data []byte) error {
|
||||||
|
var n float64
|
||||||
|
if err := json.Unmarshal(data, &n); err == nil {
|
||||||
|
*f = jsonFloat(n)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*f = jsonFloat(n)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func doGraphQL(req gqlRequest, authToken string) (map[string]any, error) {
|
||||||
|
body, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpReq, err := http.NewRequest(http.MethodPost, octopusGraphQL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
if authToken != "" {
|
||||||
|
httpReq.Header.Set("Authorization", "JWT "+authToken)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if errs, ok := result["errors"].([]any); ok && len(errs) > 0 {
|
||||||
|
if e, ok := errs[0].(map[string]any); ok {
|
||||||
|
return nil, fmt.Errorf("GraphQL error: %s", e["message"])
|
||||||
|
}
|
||||||
|
return nil, errors.New("GraphQL error")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSlice(v any) []any {
|
||||||
|
s, _ := v.([]any)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -15,8 +10,6 @@ import (
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
const octopusGraphQL = "https://api.octopus.energy/v1/graphql/"
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
apiKey = mustEnv("OCTOPUS_API_KEY")
|
apiKey = mustEnv("OCTOPUS_API_KEY")
|
||||||
port = envOrDefault("PORT", "9359")
|
port = envOrDefault("PORT", "9359")
|
||||||
|
|
@ -37,209 +30,60 @@ func envOrDefault(key, def string) string {
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
type gqlRequest struct {
|
func gauge(name, help string) prometheus.Gauge {
|
||||||
OperationName string `json:"operationName,omitempty"`
|
return prometheus.NewGauge(prometheus.GaugeOpts{Name: name, Help: help})
|
||||||
Variables map[string]any `json:"variables"`
|
|
||||||
Query string `json:"query"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func doGraphQL(req gqlRequest, authToken string) (map[string]any, error) {
|
|
||||||
body, err := json.Marshal(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
httpReq, err := http.NewRequest(http.MethodPost, octopusGraphQL, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
if authToken != "" {
|
|
||||||
httpReq.Header.Set("Authorization", "JWT "+authToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(httpReq)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
raw, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]any
|
|
||||||
if err := json.Unmarshal(raw, &result); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if errs, ok := result["errors"].([]any); ok && len(errs) > 0 {
|
|
||||||
if e, ok := errs[0].(map[string]any); ok {
|
|
||||||
return nil, fmt.Errorf("GraphQL error: %s", e["message"])
|
|
||||||
}
|
|
||||||
return nil, errors.New("GraphQL error")
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getKrakenToken(apiKey string) (string, error) {
|
|
||||||
result, err := doGraphQL(gqlRequest{
|
|
||||||
Variables: map[string]any{"apikey": apiKey},
|
|
||||||
Query: `mutation krakenTokenAuthentication($apikey: String!) {
|
|
||||||
obtainKrakenToken(input: {APIKey: $apikey}) {
|
|
||||||
token
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
}, "")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
token, ok := result["data"].(map[string]any)["obtainKrakenToken"].(map[string]any)["token"].(string)
|
|
||||||
if !ok {
|
|
||||||
return "", errors.New("token not found in response")
|
|
||||||
}
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type meterCandidate struct {
|
|
||||||
mpan string
|
|
||||||
serial string
|
|
||||||
deviceID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func getMeters(token string) ([]meterCandidate, error) {
|
|
||||||
result, err := doGraphQL(gqlRequest{
|
|
||||||
Query: `{ viewer { accounts { ... on AccountType { properties {
|
|
||||||
electricityMeterPoints {
|
|
||||||
mpan
|
|
||||||
meters {
|
|
||||||
serialNumber
|
|
||||||
smartDevices { deviceId }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} } } } }`,
|
|
||||||
}, token)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var candidates []meterCandidate
|
|
||||||
|
|
||||||
accounts, _ := result["data"].(map[string]any)["viewer"].(map[string]any)["accounts"].([]any)
|
|
||||||
for _, a := range accounts {
|
|
||||||
props, _ := a.(map[string]any)["properties"].([]any)
|
|
||||||
for _, p := range props {
|
|
||||||
mps, _ := p.(map[string]any)["electricityMeterPoints"].([]any)
|
|
||||||
for _, mp := range mps {
|
|
||||||
mpan, _ := mp.(map[string]any)["mpan"].(string)
|
|
||||||
meters, _ := mp.(map[string]any)["meters"].([]any)
|
|
||||||
for _, m := range meters {
|
|
||||||
serial, _ := m.(map[string]any)["serialNumber"].(string)
|
|
||||||
devices, _ := m.(map[string]any)["smartDevices"].([]any)
|
|
||||||
for _, d := range devices {
|
|
||||||
deviceID, _ := d.(map[string]any)["deviceId"].(string)
|
|
||||||
if deviceID != "" {
|
|
||||||
candidates = append(candidates, meterCandidate{
|
|
||||||
mpan: mpan,
|
|
||||||
serial: serial,
|
|
||||||
deviceID: deviceID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidates, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveDeviceID(token string) (string, error) {
|
|
||||||
wantDeviceID := os.Getenv("OCTOPUS_DEVICE_ID")
|
|
||||||
wantMPAN := os.Getenv("OCTOPUS_MPAN")
|
|
||||||
wantSerial := os.Getenv("OCTOPUS_SERIAL")
|
|
||||||
|
|
||||||
if wantDeviceID != "" && wantMPAN == "" && wantSerial == "" {
|
|
||||||
return wantDeviceID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("discovering meters from account...")
|
|
||||||
candidates, err := getMeters(token)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if len(candidates) == 0 {
|
|
||||||
return "", errors.New("no smart meters found on account")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range candidates {
|
|
||||||
if wantDeviceID != "" && c.deviceID != wantDeviceID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if wantMPAN != "" && c.mpan != wantMPAN {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if wantSerial != "" && c.serial != wantSerial {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Printf("using meter: MPAN=%s serial=%s deviceID=%s", c.mpan, c.serial, c.deviceID)
|
|
||||||
return c.deviceID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("no meter matched OCTOPUS_DEVICE_ID=%q OCTOPUS_MPAN=%q OCTOPUS_SERIAL=%q", wantDeviceID, wantMPAN, wantSerial)
|
|
||||||
}
|
|
||||||
|
|
||||||
type telemetryReading struct {
|
|
||||||
ReadAt string `json:"readAt"`
|
|
||||||
Consumption float64 `json:"consumption"`
|
|
||||||
Demand float64 `json:"demand"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var errTokenExpired = errors.New("token expired")
|
|
||||||
|
|
||||||
func getLiveConsumption(token, deviceID string) (*telemetryReading, error) {
|
|
||||||
result, err := doGraphQL(gqlRequest{
|
|
||||||
OperationName: "getSmartMeterTelemetry",
|
|
||||||
Variables: map[string]any{"meterDeviceId": deviceID},
|
|
||||||
Query: "query getSmartMeterTelemetry($meterDeviceId: String!, $start: DateTime, $end: DateTime, $grouping: TelemetryGrouping) {\n smartMeterTelemetry(deviceId: $meterDeviceId, start: $start, end: $end, grouping: $grouping) {\n readAt\n consumption\n demand\n __typename\n }\n}\n",
|
|
||||||
}, token)
|
|
||||||
if err != nil {
|
|
||||||
if err.Error() == "GraphQL error: Signature of the JWT has expired." {
|
|
||||||
return nil, errTokenExpired
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
telemetry, ok := result["data"].(map[string]any)["smartMeterTelemetry"].([]any)
|
|
||||||
if !ok || len(telemetry) == 0 {
|
|
||||||
return nil, errors.New("no data found")
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := json.Marshal(telemetry[0])
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var reading telemetryReading
|
|
||||||
if err := json.Unmarshal(raw, &reading); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &reading, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
liveConsumption := prometheus.NewGauge(prometheus.GaugeOpts{
|
token, err := getKrakenToken(apiKey)
|
||||||
Name: "octopus_live_consumption",
|
if err != nil {
|
||||||
Help: "Octopus Energy live consumption in watts",
|
log.Fatalf("failed to get initial token: %v", err)
|
||||||
})
|
}
|
||||||
lastRead := prometheus.NewGauge(prometheus.GaugeOpts{
|
|
||||||
Name: "octopus_live_consumption_last_read",
|
elecDeviceID, err := resolveDeviceID(token, electricity)
|
||||||
Help: "Octopus Energy live consumption last read in seconds since epoch",
|
if err != nil {
|
||||||
})
|
log.Fatalf("failed to resolve electricity meter: %v", err)
|
||||||
prometheus.MustRegister(liveConsumption, lastRead)
|
}
|
||||||
|
if elecDeviceID == "" {
|
||||||
|
log.Fatal("no electricity smart meter found on account")
|
||||||
|
}
|
||||||
|
|
||||||
|
gasDeviceID, err := resolveDeviceID(token, gas)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to resolve gas meter: %v", err)
|
||||||
|
}
|
||||||
|
if gasDeviceID == "" {
|
||||||
|
log.Println("no gas smart meter found — gas metrics disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Electricity telemetry
|
||||||
|
elecDemand := gauge("octopus_electricity_demand_watts", "Live electricity demand in watts")
|
||||||
|
elecLastRead := gauge("octopus_electricity_last_read_timestamp", "Unix timestamp of last electricity reading")
|
||||||
|
|
||||||
|
// Electricity tariff
|
||||||
|
elecUnitRate := gauge("octopus_electricity_unit_rate_pence", "Current electricity unit rate in pence per kWh")
|
||||||
|
elecStandingCharge := gauge("octopus_electricity_standing_charge_pence", "Current electricity standing charge in pence per day")
|
||||||
|
|
||||||
|
// Account
|
||||||
|
accountBalance := gauge("octopus_account_balance_pence", "Account balance in pence (positive = credit, negative = debit)")
|
||||||
|
|
||||||
|
toRegister := []prometheus.Collector{elecDemand, elecLastRead, elecUnitRate, elecStandingCharge, accountBalance}
|
||||||
|
|
||||||
|
var (
|
||||||
|
gasDemand prometheus.Gauge
|
||||||
|
gasLastRead prometheus.Gauge
|
||||||
|
gasUnitRate prometheus.Gauge
|
||||||
|
gasStandCharge prometheus.Gauge
|
||||||
|
)
|
||||||
|
if gasDeviceID != "" {
|
||||||
|
gasDemand = gauge("octopus_gas_demand_watts", "Live gas demand in watts")
|
||||||
|
gasLastRead = gauge("octopus_gas_last_read_timestamp", "Unix timestamp of last gas reading")
|
||||||
|
gasUnitRate = gauge("octopus_gas_unit_rate_pence", "Current gas unit rate in pence per kWh")
|
||||||
|
gasStandCharge = gauge("octopus_gas_standing_charge_pence", "Current gas standing charge in pence per day")
|
||||||
|
toRegister = append(toRegister, gasDemand, gasLastRead, gasUnitRate, gasStandCharge)
|
||||||
|
}
|
||||||
|
|
||||||
|
prometheus.MustRegister(toRegister...)
|
||||||
|
|
||||||
http.Handle("/metrics", promhttp.Handler())
|
http.Handle("/metrics", promhttp.Handler())
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -249,34 +93,55 @@ func main() {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
token, err := getKrakenToken(apiKey)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to get initial token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceID, err := resolveDeviceID(token)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to resolve device ID: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
reading, err := getLiveConsumption(token, deviceID)
|
// Electricity telemetry
|
||||||
|
reading, err := getLiveConsumption(token, elecDeviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("error fetching telemetry: %v", err)
|
log.Printf("electricity telemetry error: %v", err)
|
||||||
token, err = getKrakenToken(apiKey)
|
if token, err = getKrakenToken(apiKey); err != nil {
|
||||||
if err != nil {
|
log.Printf("token refresh failed: %v", err)
|
||||||
log.Printf("failed to refresh token: %v", err)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
liveConsumption.Set(reading.Demand)
|
elecDemand.Set(float64(reading.Demand))
|
||||||
|
if t, err := time.Parse("2006-01-02T15:04:05+00:00", reading.ReadAt); err == nil {
|
||||||
|
elecLastRead.Set(float64(t.Unix()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
t, err := time.Parse("2006-01-02T15:04:05+00:00", reading.ReadAt)
|
// Gas telemetry
|
||||||
|
if gasDeviceID != "" {
|
||||||
|
reading, err := getLiveConsumption(token, gasDeviceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("failed to parse readAt %q: %v", reading.ReadAt, err)
|
log.Printf("gas telemetry error: %v", err)
|
||||||
} else {
|
} else {
|
||||||
lastRead.Set(float64(t.Unix()))
|
gasDemand.Set(float64(reading.Demand))
|
||||||
|
if t, err := time.Parse("2006-01-02T15:04:05+00:00", reading.ReadAt); err == nil {
|
||||||
|
gasLastRead.Set(float64(t.Unix()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rates
|
||||||
|
rates, err := getRates(token)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("rates error: %v", err)
|
||||||
|
} else {
|
||||||
|
elecUnitRate.Set(rates.ElectricityUnitRate)
|
||||||
|
elecStandingCharge.Set(rates.ElectricityStandingCharge)
|
||||||
|
if gasDeviceID != "" {
|
||||||
|
gasUnitRate.Set(rates.GasUnitRate)
|
||||||
|
gasStandCharge.Set(rates.GasStandingCharge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account balance
|
||||||
|
balance, err := getAccountBalance(token)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("account balance error: %v", err)
|
||||||
|
} else {
|
||||||
|
accountBalance.Set(balance)
|
||||||
|
}
|
||||||
|
|
||||||
time.Sleep(60 * time.Second)
|
time.Sleep(60 * time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
154
cmd/octopus_exporter/meters.go
Normal file
154
cmd/octopus_exporter/meters.go
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type meterKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
electricity meterKind = "electricity"
|
||||||
|
gas meterKind = "gas"
|
||||||
|
)
|
||||||
|
|
||||||
|
type meterCandidate struct {
|
||||||
|
kind meterKind
|
||||||
|
mpan string
|
||||||
|
mprn string
|
||||||
|
serial string
|
||||||
|
deviceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMeters(token string) ([]meterCandidate, error) {
|
||||||
|
result, err := doGraphQL(gqlRequest{
|
||||||
|
Query: `{ viewer { accounts { ... on AccountType { properties {
|
||||||
|
electricityMeterPoints {
|
||||||
|
mpan
|
||||||
|
meters {
|
||||||
|
serialNumber
|
||||||
|
smartDevices { deviceId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gasMeterPoints {
|
||||||
|
mprn
|
||||||
|
meters {
|
||||||
|
serialNumber
|
||||||
|
smartDevices { deviceId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} } } } }`,
|
||||||
|
}, token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates []meterCandidate
|
||||||
|
|
||||||
|
accounts, _ := result["data"].(map[string]any)["viewer"].(map[string]any)["accounts"].([]any)
|
||||||
|
for _, a := range accounts {
|
||||||
|
props, _ := a.(map[string]any)["properties"].([]any)
|
||||||
|
for _, p := range props {
|
||||||
|
pm := p.(map[string]any)
|
||||||
|
|
||||||
|
for _, mp := range toSlice(pm["electricityMeterPoints"]) {
|
||||||
|
mpan, _ := mp.(map[string]any)["mpan"].(string)
|
||||||
|
for _, m := range toSlice(mp.(map[string]any)["meters"]) {
|
||||||
|
serial, _ := m.(map[string]any)["serialNumber"].(string)
|
||||||
|
for _, d := range toSlice(m.(map[string]any)["smartDevices"]) {
|
||||||
|
deviceID, _ := d.(map[string]any)["deviceId"].(string)
|
||||||
|
if deviceID != "" {
|
||||||
|
candidates = append(candidates, meterCandidate{kind: electricity, mpan: mpan, serial: serial, deviceID: deviceID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mp := range toSlice(pm["gasMeterPoints"]) {
|
||||||
|
mprn, _ := mp.(map[string]any)["mprn"].(string)
|
||||||
|
for _, m := range toSlice(mp.(map[string]any)["meters"]) {
|
||||||
|
serial, _ := m.(map[string]any)["serialNumber"].(string)
|
||||||
|
for _, d := range toSlice(m.(map[string]any)["smartDevices"]) {
|
||||||
|
deviceID, _ := d.(map[string]any)["deviceId"].(string)
|
||||||
|
if deviceID != "" {
|
||||||
|
candidates = append(candidates, meterCandidate{kind: gas, mprn: mprn, serial: serial, deviceID: deviceID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDeviceID finds the device ID for the given meter kind using environment
|
||||||
|
// variable filters. Returns ("", nil) if no meter of that kind exists on the account.
|
||||||
|
func resolveDeviceID(token string, kind meterKind) (string, error) {
|
||||||
|
var wantDeviceID, wantID, wantSerial string
|
||||||
|
switch kind {
|
||||||
|
case electricity:
|
||||||
|
wantDeviceID = os.Getenv("OCTOPUS_DEVICE_ID")
|
||||||
|
wantID = os.Getenv("OCTOPUS_MPAN")
|
||||||
|
wantSerial = os.Getenv("OCTOPUS_SERIAL")
|
||||||
|
case gas:
|
||||||
|
wantDeviceID = os.Getenv("OCTOPUS_GAS_DEVICE_ID")
|
||||||
|
wantID = os.Getenv("OCTOPUS_GAS_MPRN")
|
||||||
|
wantSerial = os.Getenv("OCTOPUS_GAS_SERIAL")
|
||||||
|
}
|
||||||
|
|
||||||
|
if wantDeviceID != "" && wantID == "" && wantSerial == "" {
|
||||||
|
return wantDeviceID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("discovering %s meters from account...", kind)
|
||||||
|
candidates, err := getMeters(token)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c.kind != kind {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if wantDeviceID != "" && c.deviceID != wantDeviceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if wantID != "" {
|
||||||
|
if kind == electricity && c.mpan != wantID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if kind == gas && c.mprn != wantID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wantSerial != "" && c.serial != wantSerial {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch kind {
|
||||||
|
case electricity:
|
||||||
|
log.Printf("using electricity meter: MPAN=%s serial=%s deviceID=%s", c.mpan, c.serial, c.deviceID)
|
||||||
|
case gas:
|
||||||
|
log.Printf("using gas meter: MPRN=%s serial=%s deviceID=%s", c.mprn, c.serial, c.deviceID)
|
||||||
|
}
|
||||||
|
return c.deviceID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if wantDeviceID != "" || wantID != "" || wantSerial != "" {
|
||||||
|
return "", fmt.Errorf("no %s meter matched the specified filters", kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No filters set and no meter found — this kind may not be on the account.
|
||||||
|
filtered := 0
|
||||||
|
for _, c := range candidates {
|
||||||
|
if c.kind == kind {
|
||||||
|
filtered++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if filtered == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", errors.New("unexpected: meters found but none selected")
|
||||||
|
}
|
||||||
69
cmd/octopus_exporter/rates.go
Normal file
69
cmd/octopus_exporter/rates.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
type tariffRates struct {
|
||||||
|
ElectricityUnitRate float64
|
||||||
|
ElectricityStandingCharge float64
|
||||||
|
GasUnitRate float64
|
||||||
|
GasStandingCharge float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// electricityTariffFragments covers all known electricity tariff union types.
|
||||||
|
const electricityTariffFragments = `
|
||||||
|
... on StandardTariff { unitRate standingCharge }
|
||||||
|
... on HalfHourlyTariff { standingCharge }
|
||||||
|
... on PrepayTariff { unitRate standingCharge }
|
||||||
|
`
|
||||||
|
|
||||||
|
func getRates(token string) (*tariffRates, error) {
|
||||||
|
result, err := doGraphQL(gqlRequest{
|
||||||
|
Query: `{ viewer { accounts { ... on AccountType { properties {
|
||||||
|
electricityMeterPoints {
|
||||||
|
agreements { validTo tariff {` + electricityTariffFragments + `} }
|
||||||
|
}
|
||||||
|
gasMeterPoints {
|
||||||
|
agreements { validTo tariff { unitRate standingCharge } }
|
||||||
|
}
|
||||||
|
} } } } }`,
|
||||||
|
}, token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rates := &tariffRates{}
|
||||||
|
|
||||||
|
accounts, _ := result["data"].(map[string]any)["viewer"].(map[string]any)["accounts"].([]any)
|
||||||
|
for _, a := range accounts {
|
||||||
|
props, _ := a.(map[string]any)["properties"].([]any)
|
||||||
|
for _, p := range props {
|
||||||
|
pm := p.(map[string]any)
|
||||||
|
|
||||||
|
for _, mp := range toSlice(pm["electricityMeterPoints"]) {
|
||||||
|
if tariff := activeAgreementTariff(mp); tariff != nil {
|
||||||
|
rates.ElectricityUnitRate, _ = tariff["unitRate"].(float64)
|
||||||
|
rates.ElectricityStandingCharge, _ = tariff["standingCharge"].(float64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mp := range toSlice(pm["gasMeterPoints"]) {
|
||||||
|
if tariff := activeAgreementTariff(mp); tariff != nil {
|
||||||
|
rates.GasUnitRate, _ = tariff["unitRate"].(float64)
|
||||||
|
rates.GasStandingCharge, _ = tariff["standingCharge"].(float64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeAgreementTariff returns the tariff map for the agreement with validTo == null.
|
||||||
|
func activeAgreementTariff(meterPoint any) map[string]any {
|
||||||
|
for _, ag := range toSlice(meterPoint.(map[string]any)["agreements"]) {
|
||||||
|
agm := ag.(map[string]any)
|
||||||
|
if agm["validTo"] == nil {
|
||||||
|
tariff, _ := agm["tariff"].(map[string]any)
|
||||||
|
return tariff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
41
cmd/octopus_exporter/telemetry.go
Normal file
41
cmd/octopus_exporter/telemetry.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type telemetryReading struct {
|
||||||
|
ReadAt string `json:"readAt"`
|
||||||
|
Consumption jsonFloat `json:"consumption"`
|
||||||
|
Demand jsonFloat `json:"demand"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLiveConsumption(token, deviceID string) (*telemetryReading, error) {
|
||||||
|
result, err := doGraphQL(gqlRequest{
|
||||||
|
OperationName: "getSmartMeterTelemetry",
|
||||||
|
Variables: map[string]any{"meterDeviceId": deviceID},
|
||||||
|
Query: "query getSmartMeterTelemetry($meterDeviceId: String!, $start: DateTime, $end: DateTime, $grouping: TelemetryGrouping) {\n smartMeterTelemetry(deviceId: $meterDeviceId, start: $start, end: $end, grouping: $grouping) {\n readAt\n consumption\n demand\n __typename\n }\n}\n",
|
||||||
|
}, token)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "GraphQL error: Signature of the JWT has expired." {
|
||||||
|
return nil, errTokenExpired
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
telemetry := toSlice(result["data"].(map[string]any)["smartMeterTelemetry"])
|
||||||
|
if len(telemetry) == 0 {
|
||||||
|
return nil, errors.New("no telemetry data returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(telemetry[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var reading telemetryReading
|
||||||
|
if err := json.Unmarshal(raw, &reading); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &reading, nil
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue