feat: solar export metrics (#16)
Some checks failed
Release / release (push) Has been cancelled

This commit is contained in:
Rasmus Wejlgaard 2026-05-25 11:56:21 +01:00 committed by GitHub
parent 00f1b29ab0
commit dbcf50eb13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 260 additions and 22 deletions

View file

@ -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`) |

View file

@ -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
// 2448h behind — so use a wide window and take the latest entry. The interval
// timestamp metric tells consumers how stale the latest reading actually is.
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)

View file

@ -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() {

View file

@ -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
} }

View file

@ -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":[{

View file

@ -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
} }

View file

@ -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":[{

View file

@ -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