mirror of
https://github.com/RWejlgaard/octopus_exporter.git
synced 2026-07-04 22:06:17 +00:00
feat: solar export metrics
This commit is contained in:
parent
00f1b29ab0
commit
552ed21883
8 changed files with 260 additions and 22 deletions
15
README.md
15
README.md
|
|
@ -28,6 +28,18 @@ Gas metrics are only exposed if a smart gas meter is found on the account.
|
||||||
| `octopus_gas_unit_rate_pence` | GraphQL | Current unit rate in pence per kWh |
|
| `octopus_gas_unit_rate_pence` | GraphQL | Current unit rate in pence per kWh |
|
||||||
| `octopus_gas_standing_charge_pence` | GraphQL | Current standing charge in pence per day |
|
| `octopus_gas_standing_charge_pence` | GraphQL | Current standing charge in pence per day |
|
||||||
|
|
||||||
|
### Solar / export
|
||||||
|
|
||||||
|
Solar metrics are only exposed if an electricity export meter point (direction `EXPORT`) is found on the account. Solar export usually shares the physical meter and smart device with the import meter, so live export watts come from the same telemetry call as electricity demand (no extra API request).
|
||||||
|
|
||||||
|
| Metric | Source | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `octopus_solar_export_watts` | GraphQL | Live electricity export to the grid in watts |
|
||||||
|
| `octopus_solar_last_read_timestamp` | GraphQL | Unix timestamp of last solar export reading |
|
||||||
|
| `octopus_solar_export_kwh` | REST | Latest half-hourly energy exported to the grid in kWh |
|
||||||
|
| `octopus_solar_export_interval_timestamp` | REST | Unix timestamp of the start of the latest export interval |
|
||||||
|
| `octopus_solar_export_rate_pence` | GraphQL | Current export (outgoing) unit rate in pence per kWh |
|
||||||
|
|
||||||
### Account
|
### Account
|
||||||
|
|
||||||
| Metric | Source | Description |
|
| Metric | Source | Description |
|
||||||
|
|
@ -58,6 +70,9 @@ Metrics are refreshed every `POLL_INTERVAL` (default 60 seconds).
|
||||||
| `OCTOPUS_GAS_MPRN` | No | Filter gas meter by MPRN |
|
| `OCTOPUS_GAS_MPRN` | No | Filter gas meter by MPRN |
|
||||||
| `OCTOPUS_GAS_SERIAL` | No | Filter gas meter by serial number |
|
| `OCTOPUS_GAS_SERIAL` | No | Filter gas meter by serial number |
|
||||||
| `OCTOPUS_GAS_DEVICE_ID` | No | Use a specific gas smart device ID directly |
|
| `OCTOPUS_GAS_DEVICE_ID` | No | Use a specific gas smart device ID directly |
|
||||||
|
| `OCTOPUS_SOLAR_MPAN` | No | Filter solar export meter by MPAN |
|
||||||
|
| `OCTOPUS_SOLAR_SERIAL` | No | Filter solar export meter by serial number |
|
||||||
|
| `OCTOPUS_SOLAR_DEVICE_ID` | No | Use a specific solar export smart device ID directly |
|
||||||
| `PORT` | No | Port to expose metrics on (default: `9359`) |
|
| `PORT` | No | Port to expose metrics on (default: `9359`) |
|
||||||
| `POLL_INTERVAL` | No | How often to poll Octopus APIs (Go duration, default: `60s`) |
|
| `POLL_INTERVAL` | No | How often to poll Octopus APIs (Go duration, default: `60s`) |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,11 @@ func getLatestConsumption(kind meterKind, id, serial, key string) (*consumptionR
|
||||||
path = fmt.Sprintf("/v1/gas-meter-points/%s/meters/%s/consumption/", id, serial)
|
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.
|
// Consumption data can lag significantly — meters that report once a day can be
|
||||||
|
// 24–48h behind — so use a wide window and take the latest entry. The interval
|
||||||
|
// timestamp metric tells consumers how stale the latest reading actually is.
|
||||||
result, err := doREST(path, url.Values{
|
result, err := doREST(path, url.Values{
|
||||||
"period_from": {time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)},
|
"period_from": {time.Now().UTC().Add(-7 * 24 * time.Hour).Format(time.RFC3339)},
|
||||||
"order_by": {"period"},
|
"order_by": {"period"},
|
||||||
}, key)
|
}, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -32,7 +34,7 @@ func getLatestConsumption(kind meterKind, id, serial, key string) (*consumptionR
|
||||||
|
|
||||||
results := toSlice(result["results"])
|
results := toSlice(result["results"])
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
return nil, errors.New("no consumption data in last 24h")
|
return nil, errors.New("no consumption data in last 7 days")
|
||||||
}
|
}
|
||||||
|
|
||||||
latest, ok := results[len(results)-1].(map[string]any)
|
latest, ok := results[len(results)-1].(map[string]any)
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,14 @@ func main() {
|
||||||
log.Println("no gas smart meter found — gas metrics disabled")
|
log.Println("no gas smart meter found — gas metrics disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
solarMeter, err := resolveMeter(candidates, solar)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to resolve solar export meter: %v", err)
|
||||||
|
}
|
||||||
|
if solarMeter == nil {
|
||||||
|
log.Println("no solar export meter found — solar metrics disabled")
|
||||||
|
}
|
||||||
|
|
||||||
// --- Metrics ---
|
// --- Metrics ---
|
||||||
|
|
||||||
// Electricity telemetry (live, from GraphQL)
|
// Electricity telemetry (live, from GraphQL)
|
||||||
|
|
@ -149,6 +157,22 @@ func main() {
|
||||||
toRegister = append(toRegister, gasDemand, gasLastRead, gasConsumption, gasConsumptionInterval, gasUnitRate, gasStandCharge)
|
toRegister = append(toRegister, gasDemand, gasLastRead, gasConsumption, gasConsumptionInterval, gasUnitRate, gasStandCharge)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
solarExportWatts prometheus.Gauge
|
||||||
|
solarLastRead prometheus.Gauge
|
||||||
|
solarExport prometheus.Gauge
|
||||||
|
solarExportInterval prometheus.Gauge
|
||||||
|
solarExportRate prometheus.Gauge
|
||||||
|
)
|
||||||
|
if solarMeter != nil {
|
||||||
|
solarExportWatts = gauge("octopus_solar_export_watts", "Live electricity export (solar) to the grid in watts")
|
||||||
|
solarLastRead = gauge("octopus_solar_last_read_timestamp", "Unix timestamp of last solar export reading")
|
||||||
|
solarExport = gauge("octopus_solar_export_kwh", "Half-hourly electricity exported to the grid in kWh")
|
||||||
|
solarExportInterval = gauge("octopus_solar_export_interval_timestamp", "Unix timestamp of the start of the latest solar export interval")
|
||||||
|
solarExportRate = gauge("octopus_solar_export_rate_pence", "Current export (outgoing) unit rate in pence per kWh")
|
||||||
|
toRegister = append(toRegister, solarExportWatts, solarLastRead, solarExport, solarExportInterval, solarExportRate)
|
||||||
|
}
|
||||||
|
|
||||||
prometheus.MustRegister(toRegister...)
|
prometheus.MustRegister(toRegister...)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
@ -229,6 +253,11 @@ func main() {
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Solar export shares the physical meter/device with the import meter on
|
||||||
|
// most installs, so we can read live export watts from the same telemetry
|
||||||
|
// call rather than spending a second request.
|
||||||
|
solarSharesDevice := solarMeter != nil && solarMeter.deviceID != "" && solarMeter.deviceID == elecMeter.deviceID
|
||||||
|
|
||||||
// Electricity telemetry (live demand)
|
// Electricity telemetry (live demand)
|
||||||
if elecMeter.deviceID != "" {
|
if elecMeter.deviceID != "" {
|
||||||
collect("electricity telemetry", func() error {
|
collect("electricity telemetry", func() error {
|
||||||
|
|
@ -241,6 +270,29 @@ func main() {
|
||||||
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
|
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
|
||||||
elecLastRead.Set(float64(ts.Unix()))
|
elecLastRead.Set(float64(ts.Unix()))
|
||||||
}
|
}
|
||||||
|
if solarSharesDevice {
|
||||||
|
solarExportWatts.Set(float64(reading.Export))
|
||||||
|
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
|
||||||
|
solarLastRead.Set(float64(ts.Unix()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solar telemetry on a distinct export device (rare).
|
||||||
|
if solarMeter != nil && solarMeter.deviceID != "" && !solarSharesDevice {
|
||||||
|
collect("solar telemetry", func() error {
|
||||||
|
return withToken(func(t string) error {
|
||||||
|
reading, err := getLiveConsumption(t, solarMeter.deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
solarExportWatts.Set(float64(reading.Export))
|
||||||
|
if ts, err := time.Parse(time.RFC3339, reading.ReadAt); err == nil {
|
||||||
|
solarLastRead.Set(float64(ts.Unix()))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -289,6 +341,19 @@ func main() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Solar half-hourly export (REST; uses the electricity meter-point path with the export MPAN)
|
||||||
|
if solarMeter != nil && solarMeter.mpan != "" && solarMeter.serial != "" {
|
||||||
|
collect("solar export", func() error {
|
||||||
|
c, err := getLatestConsumption(electricity, solarMeter.mpan, solarMeter.serial, apiKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
solarExport.Set(c.KWh)
|
||||||
|
solarExportInterval.Set(float64(c.IntervalStart.Unix()))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Tariff rates (result needed for optional agile lookup after wg.Wait)
|
// Tariff rates (result needed for optional agile lookup after wg.Wait)
|
||||||
var collectedRates *tariffRates
|
var collectedRates *tariffRates
|
||||||
collect("rates", func() error {
|
collect("rates", func() error {
|
||||||
|
|
@ -333,6 +398,9 @@ func main() {
|
||||||
gasUnitRate.Set(collectedRates.GasUnitRate)
|
gasUnitRate.Set(collectedRates.GasUnitRate)
|
||||||
gasStandCharge.Set(collectedRates.GasStandingCharge)
|
gasStandCharge.Set(collectedRates.GasStandingCharge)
|
||||||
}
|
}
|
||||||
|
if solarMeter != nil && collectedRates.SolarHasExport {
|
||||||
|
solarExportRate.Set(collectedRates.SolarExportRate)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if failedAny.Load() {
|
if failedAny.Load() {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ type meterKind string
|
||||||
const (
|
const (
|
||||||
electricity meterKind = "electricity"
|
electricity meterKind = "electricity"
|
||||||
gas meterKind = "gas"
|
gas meterKind = "gas"
|
||||||
|
// solar is an electricity export meter point (direction EXPORT) — a separate
|
||||||
|
// MPAN that usually shares the physical meter/device with the import meter.
|
||||||
|
solar meterKind = "solar"
|
||||||
)
|
)
|
||||||
|
|
||||||
type meterCandidate struct {
|
type meterCandidate struct {
|
||||||
|
|
@ -33,6 +36,7 @@ func getMeters(token string) ([]meterCandidate, error) {
|
||||||
Query: `{ viewer { accounts { ... on AccountType { properties {
|
Query: `{ viewer { accounts { ... on AccountType { properties {
|
||||||
electricityMeterPoints {
|
electricityMeterPoints {
|
||||||
mpan
|
mpan
|
||||||
|
direction
|
||||||
meters {
|
meters {
|
||||||
serialNumber
|
serialNumber
|
||||||
smartDevices { deviceId }
|
smartDevices { deviceId }
|
||||||
|
|
@ -70,6 +74,12 @@ func getMeters(token string) ([]meterCandidate, error) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mpan, _ := mpPoint["mpan"].(string)
|
mpan, _ := mpPoint["mpan"].(string)
|
||||||
|
// EXPORT meter points are solar generation export; everything else
|
||||||
|
// (IMPORT or unknown/legacy with no direction) is grid import.
|
||||||
|
kind := electricity
|
||||||
|
if dir, _ := mpPoint["direction"].(string); dir == "EXPORT" {
|
||||||
|
kind = solar
|
||||||
|
}
|
||||||
for _, m := range toSlice(mpPoint["meters"]) {
|
for _, m := range toSlice(mpPoint["meters"]) {
|
||||||
meterMap, ok := m.(map[string]any)
|
meterMap, ok := m.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -84,12 +94,12 @@ func getMeters(token string) ([]meterCandidate, error) {
|
||||||
}
|
}
|
||||||
deviceID, _ := dMap["deviceId"].(string)
|
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: kind, mpan: mpan, serial: serial, deviceID: deviceID})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Include meters without smart devices so we can still use the REST consumption endpoint.
|
// Include meters without smart devices so we can still use the REST consumption endpoint.
|
||||||
if len(devices) == 0 && serial != "" {
|
if len(devices) == 0 && serial != "" {
|
||||||
candidates = append(candidates, meterCandidate{kind: electricity, mpan: mpan, serial: serial})
|
candidates = append(candidates, meterCandidate{kind: kind, mpan: mpan, serial: serial})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -141,6 +151,10 @@ func resolveMeter(candidates []meterCandidate, kind meterKind) (*resolvedMeter,
|
||||||
wantDeviceID = os.Getenv("OCTOPUS_GAS_DEVICE_ID")
|
wantDeviceID = os.Getenv("OCTOPUS_GAS_DEVICE_ID")
|
||||||
wantID = os.Getenv("OCTOPUS_GAS_MPRN")
|
wantID = os.Getenv("OCTOPUS_GAS_MPRN")
|
||||||
wantSerial = os.Getenv("OCTOPUS_GAS_SERIAL")
|
wantSerial = os.Getenv("OCTOPUS_GAS_SERIAL")
|
||||||
|
case solar:
|
||||||
|
wantDeviceID = os.Getenv("OCTOPUS_SOLAR_DEVICE_ID")
|
||||||
|
wantID = os.Getenv("OCTOPUS_SOLAR_MPAN")
|
||||||
|
wantSerial = os.Getenv("OCTOPUS_SOLAR_SERIAL")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range candidates {
|
for _, c := range candidates {
|
||||||
|
|
@ -151,7 +165,7 @@ func resolveMeter(candidates []meterCandidate, kind meterKind) (*resolvedMeter,
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if wantID != "" {
|
if wantID != "" {
|
||||||
if kind == electricity && c.mpan != wantID {
|
if (kind == electricity || kind == solar) && c.mpan != wantID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if kind == gas && c.mprn != wantID {
|
if kind == gas && c.mprn != wantID {
|
||||||
|
|
@ -168,6 +182,8 @@ func resolveMeter(candidates []meterCandidate, kind meterKind) (*resolvedMeter,
|
||||||
log.Printf("using electricity meter: MPAN=%s serial=%s deviceID=%s", m.mpan, m.serial, m.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", m.mprn, m.serial, m.deviceID)
|
log.Printf("using gas meter: MPRN=%s serial=%s deviceID=%s", m.mprn, m.serial, m.deviceID)
|
||||||
|
case solar:
|
||||||
|
log.Printf("using solar export meter: MPAN=%s serial=%s deviceID=%s", m.mpan, m.serial, m.deviceID)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,56 @@ func TestGetMeters_MeterWithoutSmartDevice(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetMeters_ExportClassifiedAsSolar(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
|
||||||
|
"electricityMeterPoints":[
|
||||||
|
{"mpan":"1900000000001","direction":"EXPORT","meters":[{"serialNumber":"A001","smartDevices":[{"deviceId":"dev1"}]}]},
|
||||||
|
{"mpan":"1900000000002","direction":"IMPORT","meters":[{"serialNumber":"A001","smartDevices":[{"deviceId":"dev1"}]}]}
|
||||||
|
],
|
||||||
|
"gasMeterPoints":[]
|
||||||
|
}]}]}}}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
octopusGraphQL = srv.URL + "/"
|
||||||
|
|
||||||
|
candidates, err := getMeters("token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
byKind := map[meterKind]meterCandidate{}
|
||||||
|
for _, c := range candidates {
|
||||||
|
byKind[c.kind] = c
|
||||||
|
}
|
||||||
|
if byKind[electricity].mpan != "1900000000002" {
|
||||||
|
t.Errorf("electricity mpan: got %q, want 1900000000002", byKind[electricity].mpan)
|
||||||
|
}
|
||||||
|
if byKind[solar].mpan != "1900000000001" {
|
||||||
|
t.Errorf("solar mpan: got %q, want 1900000000001", byKind[solar].mpan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMeter_Solar(t *testing.T) {
|
||||||
|
t.Setenv("OCTOPUS_SOLAR_DEVICE_ID", "")
|
||||||
|
t.Setenv("OCTOPUS_SOLAR_MPAN", "")
|
||||||
|
t.Setenv("OCTOPUS_SOLAR_SERIAL", "")
|
||||||
|
|
||||||
|
candidates := []meterCandidate{
|
||||||
|
{kind: electricity, mpan: "1900000000002", serial: "A001", deviceID: "dev1"},
|
||||||
|
{kind: solar, mpan: "1900000000001", serial: "A001", deviceID: "dev1"},
|
||||||
|
}
|
||||||
|
m, err := resolveMeter(candidates, solar)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("expected solar meter, got nil")
|
||||||
|
}
|
||||||
|
if m.mpan != "1900000000001" {
|
||||||
|
t.Errorf("got mpan %q, want 1900000000001", m.mpan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetMeters_BothElectricityAndGas(t *testing.T) {
|
func TestGetMeters_BothElectricityAndGas(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
|
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
type tariffRates struct {
|
type tariffRates struct {
|
||||||
ElectricityUnitRate float64
|
ElectricityUnitRate float64
|
||||||
ElectricityStandingCharge float64
|
ElectricityStandingCharge float64
|
||||||
|
|
@ -8,20 +10,23 @@ type tariffRates struct {
|
||||||
ElectricityIsAgile bool
|
ElectricityIsAgile bool
|
||||||
GasUnitRate float64
|
GasUnitRate float64
|
||||||
GasStandingCharge float64
|
GasStandingCharge float64
|
||||||
|
SolarExportRate float64
|
||||||
|
SolarHasExport bool
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
direction
|
||||||
|
agreements { validFrom validTo tariff {
|
||||||
... on StandardTariff { unitRate standingCharge productCode tariffCode }
|
... on StandardTariff { unitRate standingCharge productCode tariffCode }
|
||||||
... on HalfHourlyTariff { standingCharge productCode tariffCode }
|
... on HalfHourlyTariff { standingCharge productCode tariffCode }
|
||||||
... on PrepayTariff { unitRate standingCharge productCode tariffCode }
|
... on PrepayTariff { unitRate standingCharge productCode tariffCode }
|
||||||
} }
|
} }
|
||||||
}
|
}
|
||||||
gasMeterPoints {
|
gasMeterPoints {
|
||||||
agreements { validTo tariff { unitRate standingCharge } }
|
agreements { validFrom validTo tariff { unitRate standingCharge } }
|
||||||
}
|
}
|
||||||
} } } } }`,
|
} } } } }`,
|
||||||
}, token)
|
}, token)
|
||||||
|
|
@ -43,15 +48,23 @@ func getRates(token string) (*tariffRates, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, mp := range toSlice(pm["electricityMeterPoints"]) {
|
for _, mp := range toSlice(pm["electricityMeterPoints"]) {
|
||||||
if tariff := activeAgreementTariff(mp); tariff != nil {
|
tariff := activeAgreementTariff(mp)
|
||||||
rates.ElectricityUnitRate, _ = tariff["unitRate"].(float64)
|
if tariff == nil {
|
||||||
rates.ElectricityStandingCharge, _ = tariff["standingCharge"].(float64)
|
continue
|
||||||
rates.ElectricityProductCode, _ = tariff["productCode"].(string)
|
}
|
||||||
rates.ElectricityTariffCode, _ = tariff["tariffCode"].(string)
|
mpm, _ := mp.(map[string]any)
|
||||||
// HalfHourlyTariff has no unitRate field — detect Agile by absence.
|
if dir, _ := mpm["direction"].(string); dir == "EXPORT" {
|
||||||
if _, hasUnit := tariff["unitRate"]; !hasUnit {
|
rates.SolarExportRate, _ = tariff["unitRate"].(float64)
|
||||||
rates.ElectricityIsAgile = true
|
rates.SolarHasExport = true
|
||||||
}
|
continue
|
||||||
|
}
|
||||||
|
rates.ElectricityUnitRate, _ = tariff["unitRate"].(float64)
|
||||||
|
rates.ElectricityStandingCharge, _ = tariff["standingCharge"].(float64)
|
||||||
|
rates.ElectricityProductCode, _ = tariff["productCode"].(string)
|
||||||
|
rates.ElectricityTariffCode, _ = tariff["tariffCode"].(string)
|
||||||
|
// HalfHourlyTariff has no unitRate field — detect Agile by absence.
|
||||||
|
if _, hasUnit := tariff["unitRate"]; !hasUnit {
|
||||||
|
rates.ElectricityIsAgile = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,21 +80,33 @@ func getRates(token string) (*tariffRates, error) {
|
||||||
return rates, nil
|
return rates, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// activeAgreementTariff returns the tariff map for the agreement with validTo == null.
|
// activeAgreementTariff returns the tariff for the agreement in effect now:
|
||||||
|
// validFrom in the past (or absent) and validTo null or in the future. Fixed-term
|
||||||
|
// tariffs carry a future validTo (their contract end), so matching only validTo==null
|
||||||
|
// would miss them.
|
||||||
func activeAgreementTariff(meterPoint any) map[string]any {
|
func activeAgreementTariff(meterPoint any) map[string]any {
|
||||||
mp, ok := meterPoint.(map[string]any)
|
mp, ok := meterPoint.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
now := time.Now()
|
||||||
for _, ag := range toSlice(mp["agreements"]) {
|
for _, ag := range toSlice(mp["agreements"]) {
|
||||||
agm, ok := ag.(map[string]any)
|
agm, ok := ag.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if agm["validTo"] == nil {
|
if from, ok := agm["validFrom"].(string); ok {
|
||||||
tariff, _ := agm["tariff"].(map[string]any)
|
if t, err := time.Parse(time.RFC3339, from); err == nil && t.After(now) {
|
||||||
return tariff
|
continue // not started yet
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if to, ok := agm["validTo"].(string); ok {
|
||||||
|
if t, err := time.Parse(time.RFC3339, to); err == nil && !t.After(now) {
|
||||||
|
continue // already ended
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tariff, _ := agm["tariff"].(map[string]any)
|
||||||
|
return tariff
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,33 @@ func TestActiveAgreementTariff_ActiveFound(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestActiveAgreementTariff_FixedTermFutureValidTo(t *testing.T) {
|
||||||
|
// Fixed-term tariffs carry a future validTo (contract end), not null.
|
||||||
|
mp := map[string]any{
|
||||||
|
"agreements": []any{
|
||||||
|
map[string]any{"validFrom": "2020-01-01T00:00:00Z", "validTo": "2099-01-01T00:00:00Z", "tariff": map[string]any{"unitRate": 23.37}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tariff := activeAgreementTariff(mp)
|
||||||
|
if tariff == nil {
|
||||||
|
t.Fatal("expected tariff for in-effect fixed-term agreement, got nil")
|
||||||
|
}
|
||||||
|
if tariff["unitRate"].(float64) != 23.37 {
|
||||||
|
t.Errorf("got unitRate %v, want 23.37", tariff["unitRate"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActiveAgreementTariff_NotYetStarted(t *testing.T) {
|
||||||
|
mp := map[string]any{
|
||||||
|
"agreements": []any{
|
||||||
|
map[string]any{"validFrom": "2099-01-01T00:00:00Z", "validTo": nil, "tariff": map[string]any{"unitRate": 99.0}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if tariff := activeAgreementTariff(mp); tariff != nil {
|
||||||
|
t.Errorf("expected nil for not-yet-started agreement, got %v", tariff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestActiveAgreementTariff_NoneActive(t *testing.T) {
|
func TestActiveAgreementTariff_NoneActive(t *testing.T) {
|
||||||
mp := map[string]any{
|
mp := map[string]any{
|
||||||
"agreements": []any{
|
"agreements": []any{
|
||||||
|
|
@ -95,6 +122,40 @@ func TestGetRates_AgileTariff(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetRates_SplitsImportAndExport(t *testing.T) {
|
||||||
|
// Export meter point listed first (as the live API returns it) must not
|
||||||
|
// clobber the import unit rate.
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
|
||||||
|
"electricityMeterPoints":[
|
||||||
|
{"direction":"EXPORT","agreements":[{"validTo":null,"tariff":{
|
||||||
|
"unitRate":12.0,"standingCharge":0.0,"productCode":"OUTGOING-VAR-24-10-26","tariffCode":"E-1R-OUTGOING-VAR-24-10-26-J"
|
||||||
|
}}]},
|
||||||
|
{"direction":"IMPORT","agreements":[{"validFrom":"2020-01-01T00:00:00Z","validTo":"2099-01-01T00:00:00Z","tariff":{
|
||||||
|
"unitRate":23.37,"standingCharge":46.63,"productCode":"OE-FIX-12M","tariffCode":"E-1R-OE-FIX-12M-J"
|
||||||
|
}}]}
|
||||||
|
],
|
||||||
|
"gasMeterPoints":[]
|
||||||
|
}]}]}}}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
octopusGraphQL = srv.URL + "/"
|
||||||
|
|
||||||
|
rates, err := getRates("token")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if rates.ElectricityUnitRate != 23.37 {
|
||||||
|
t.Errorf("import unit rate: got %v, want 23.37", rates.ElectricityUnitRate)
|
||||||
|
}
|
||||||
|
if !rates.SolarHasExport {
|
||||||
|
t.Error("expected SolarHasExport=true")
|
||||||
|
}
|
||||||
|
if rates.SolarExportRate != 12.0 {
|
||||||
|
t.Errorf("export rate: got %v, want 12.0", rates.SolarExportRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetRates_WithGas(t *testing.T) {
|
func TestGetRates_WithGas(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
|
fmt.Fprint(w, `{"data":{"viewer":{"accounts":[{"properties":[{
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,14 @@ type telemetryReading struct {
|
||||||
ReadAt string `json:"readAt"`
|
ReadAt string `json:"readAt"`
|
||||||
Consumption jsonFloat `json:"consumption"`
|
Consumption jsonFloat `json:"consumption"`
|
||||||
Demand jsonFloat `json:"demand"`
|
Demand jsonFloat `json:"demand"`
|
||||||
|
Export jsonFloat `json:"export"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLiveConsumption(token, deviceID string) (*telemetryReading, error) {
|
func getLiveConsumption(token, deviceID string) (*telemetryReading, error) {
|
||||||
result, err := doGraphQL(gqlRequest{
|
result, err := doGraphQL(gqlRequest{
|
||||||
OperationName: "getSmartMeterTelemetry",
|
OperationName: "getSmartMeterTelemetry",
|
||||||
Variables: map[string]any{"meterDeviceId": deviceID},
|
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",
|
Query: "query getSmartMeterTelemetry($meterDeviceId: String!, $start: DateTime, $end: DateTime, $grouping: TelemetryGrouping) {\n smartMeterTelemetry(deviceId: $meterDeviceId, start: $start, end: $end, grouping: $grouping) {\n readAt\n consumption\n demand\n export\n __typename\n }\n}\n",
|
||||||
}, token)
|
}, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue