From b96cdb62b6630eaef274eeb9ebf882d68dd8345b Mon Sep 17 00:00:00 2001 From: "duck." Date: Sun, 7 Jan 2024 23:20:36 +0000 Subject: [PATCH 1/4] tariff/octopus: Support GraphQL / API Key mode Initial work, more to be done here, committing as I've been working on this most of the evening! Splits GraphQL and Rest out into separate packages. Rest is still required even with GraphQL being available as GraphQL has a lot of restricted endpoints. --- tariff/octopus.go | 56 ++++++++++--- tariff/octopus/api.go | 35 -------- tariff/octopus/graphql/api.go | 141 ++++++++++++++++++++++++++++++++ tariff/octopus/graphql/types.go | 78 ++++++++++++++++++ tariff/octopus/rest/api.go | 48 +++++++++++ 5 files changed, 312 insertions(+), 46 deletions(-) delete mode 100644 tariff/octopus/api.go create mode 100644 tariff/octopus/graphql/api.go create mode 100644 tariff/octopus/graphql/types.go create mode 100644 tariff/octopus/rest/api.go diff --git a/tariff/octopus.go b/tariff/octopus.go index 35d1c8bd9b..4dfbc1522a 100644 --- a/tariff/octopus.go +++ b/tariff/octopus.go @@ -2,21 +2,24 @@ package tariff import ( "errors" + octoGql "github.com/evcc-io/evcc/tariff/octopus/graphql" + octoRest "github.com/evcc-io/evcc/tariff/octopus/rest" "slices" "sync" "time" "github.com/cenkalti/backoff/v4" "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/tariff/octopus" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" ) type Octopus struct { log *util.Logger - uri string region string + // Tariff is actually the Product Code + tariff string + apikey string data *util.Monitor[api.Rates] } @@ -29,24 +32,32 @@ func init() { func NewOctopusFromConfig(other map[string]interface{}) (api.Tariff, error) { var cc struct { Region string + // Tariff is actually the Product Code Tariff string + ApiKey string } if err := util.DecodeOther(other, &cc); err != nil { return nil, err } - if cc.Region == "" { - return nil, errors.New("missing region") - } - if cc.Tariff == "" { - return nil, errors.New("missing tariff code") + // Allow ApiKey to be missing only if Region and Tariff are not. + if cc.ApiKey == "" { + if cc.Region == "" { + return nil, errors.New("missing region") + } + if cc.Tariff == "" { + return nil, errors.New("missing product / tariff code") + } + } else if cc.Region != "" || cc.Tariff != "" { + return nil, errors.New("cannot use apikey at same time as product / tariff code") } t := &Octopus{ log: util.NewLogger("octopus"), - uri: octopus.ConstructRatesAPI(cc.Tariff, cc.Region), - region: cc.Tariff, + region: cc.Region, + tariff: cc.Tariff, + apikey: cc.ApiKey, data: util.NewMonitor[api.Rates](2 * time.Hour), } @@ -62,11 +73,34 @@ func (t *Octopus) run(done chan error) { client := request.NewHelper(t.log) bo := newBackoff() + var restQueryUri string + + // If ApiKey is available, use GraphQL to get appropriate tariff code before entering execution loop. + if t.apikey != "" { + gqlCli, err := octoGql.NewClient(t.log, t.apikey) + if err != nil { + once.Do(func() { done <- err }) + t.log.ERROR.Println(err) + return + } + tariffCode, err := gqlCli.TariffCode() + if err != nil { + once.Do(func() { done <- err }) + t.log.ERROR.Println(err) + return + } + restQueryUri = octoRest.ConstructRatesAPIFromTariffCode(tariffCode) + } else { + // Construct Rest Query URI using tariff and region codes. + restQueryUri = octoRest.ConstructRatesAPIFromProductAndRegionCode(t.tariff, t.region) + } + + // TODO tick every 15 minutes if GraphQL is available to poll for Intelligent slots. for ; true; <-time.Tick(time.Hour) { - var res octopus.UnitRates + var res octoRest.UnitRates if err := backoff.Retry(func() error { - return client.GetJSON(t.uri, &res) + return client.GetJSON(restQueryUri, &res) }, bo); err != nil { once.Do(func() { done <- err }) diff --git a/tariff/octopus/api.go b/tariff/octopus/api.go deleted file mode 100644 index 1f96453a95..0000000000 --- a/tariff/octopus/api.go +++ /dev/null @@ -1,35 +0,0 @@ -package octopus - -import ( - "fmt" - "strings" - "time" -) - -// ProductURI defines the location of the tariff information page. Substitute %s with tariff name. -const ProductURI = "https://api.octopus.energy/v1/products/%s/" - -// RatesURI defines the location of the full tariff rates page, including speculation. -// Substitute first %s with tariff name, second with region code. -const RatesURI = ProductURI + "electricity-tariffs/E-1R-%s-%s/standard-unit-rates/" - -// ConstructRatesAPI returns a validly formatted, fully qualified URI to the unit rate information. -func ConstructRatesAPI(tariff string, region string) string { - t := strings.ToUpper(tariff) - r := strings.ToUpper(region) - return fmt.Sprintf(RatesURI, t, t, r) -} - -type UnitRates struct { - Count uint64 `json:"count"` - Next string `json:"next"` - Previous string `json:"previous"` - Results []Rate `json:"results"` -} - -type Rate struct { - ValidityStart time.Time `json:"valid_from"` - ValidityEnd time.Time `json:"valid_to"` - PriceInclusiveTax float64 `json:"value_inc_vat"` - PriceExclusiveTax float64 `json:"value_exc_vat"` -} diff --git a/tariff/octopus/graphql/api.go b/tariff/octopus/graphql/api.go new file mode 100644 index 0000000000..e09155f3a3 --- /dev/null +++ b/tariff/octopus/graphql/api.go @@ -0,0 +1,141 @@ +package graphql + +import ( + "context" + "errors" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/hasura/go-graphql-client" + "net/http" + "sync" + "time" +) + +type OctopusGraphQLClient struct { + *graphql.Client + log *util.Logger + + // apikey is the Octopus Energy API key (provided by user) + apikey string + // token is the GraphQL token used for communication with kraken (we get this ourselves with the apikey) + token *string + // accountNumber is the Octopus Energy account number associated with the given API key (queried ourselves via GraphQL) + accountNumber string + tokenExpiration time.Time + tokenMtx sync.Mutex +} + +func NewClient(log *util.Logger, apikey string) (*OctopusGraphQLClient, error) { + cli := request.NewClient(log) + + gq := &OctopusGraphQLClient{ + Client: graphql.NewClient(URI, cli), + log: log, + apikey: apikey, + } + + err := gq.refreshToken() + if err != nil { + return nil, err + } + // Future requests must have the appropriate Authorization header set. + reqMod := graphql.RequestModifier( + func(r *http.Request) { + r.Header.Add("Authorization", *gq.token) + }) + gq.Client = gq.Client.WithRequestModifier(reqMod) + + return gq, err +} + +// refreshToken updates the GraphQL token from the set apikey. +// Basic caching is provided - it will not update the token if it hasn't expired yet. +func (c *OctopusGraphQLClient) refreshToken() error { + now := time.Now() + if !c.tokenExpiration.IsZero() && c.tokenExpiration.After(now) { + c.log.TRACE.Print("using cached octopus token") + return nil + } + + // TODO is this a good use of background context? + ctx := context.Background() + // take a lock against the token mutex for the refresh + c.tokenMtx.Lock() + defer c.tokenMtx.Unlock() + + var q KrakenTokenAuthentication + err := c.Client.Mutate(ctx, &q, map[string]interface{}{"apiKey": c.apikey}) + if err != nil { + return err + } + c.log.INFO.Println("got GQL token from octopus") + c.token = &q.ObtainKrakenToken.Token + // Refresh in 55 minutes (the token lasts an hour, but just to be safe...) + c.tokenExpiration = time.Now().Add(time.Minute * 55) + return nil +} + +// AccountNumber queries the Account Number assigned to the associated API key. +// Caching is provided. +func (c *OctopusGraphQLClient) AccountNumber() (string, error) { + // Check cache + if c.accountNumber != "" { + return c.accountNumber, nil + } + + // Update refresh token (if necessary) + if err := c.refreshToken(); err != nil { + return "", err + } + + // TODO is this a good use of background context? + ctx := context.Background() + + var q KrakenAccountLookup + err := c.Client.Query(ctx, &q, nil) + if err != nil { + return "", err + } + + if len(q.Viewer.Accounts) == 0 { + return "", errors.New("no account associated with given octopus api key") + } + if len(q.Viewer.Accounts) > 1 { + c.log.WARN.Print("more than one octopus account on this api key - picking the first one. please file an issue!") + } + c.accountNumber = q.Viewer.Accounts[0].Number + return c.accountNumber, nil +} + +// TariffCode queries the Tariff Code of the first Electricity Agreement active on the account. +func (c *OctopusGraphQLClient) TariffCode() (string, error) { + // Update refresh token (if necessary) + if err := c.refreshToken(); err != nil { + return "", err + } + + // Get Account Number + acc, err := c.AccountNumber() + if err != nil { + return "", nil + } + + // TODO is this a good use of background context? + ctx := context.Background() + + var q KrakenAccountElectricityAgreements + err = c.Client.Query(ctx, &q, map[string]interface{}{"accountNumber": acc}) + if err != nil { + return "", err + } + + if len(q.Account.ElectricityAgreements) == 0 { + return "", errors.New("no electricity agreements found") + } + + // check type + //switch t := q.Account.ElectricityAgreements[0].Tariff.(type) { + // + //} + return q.Account.ElectricityAgreements[0].Tariff.StandardTariff.TariffCode, nil +} diff --git a/tariff/octopus/graphql/types.go b/tariff/octopus/graphql/types.go new file mode 100644 index 0000000000..dd8c8f8d84 --- /dev/null +++ b/tariff/octopus/graphql/types.go @@ -0,0 +1,78 @@ +package graphql + +// BaseURI is Octopus Energy's core API root. +const BaseURI = "https://api.octopus.energy" + +// URI is the GraphQL query endpoint for Octopus Energy. +const URI = BaseURI + "/v1/graphql/" + +// FIXME these don't need to be public + +// KrakenTokenAuthentication is a representation of a GraphQL query for obtaining a Kraken API token. +type KrakenTokenAuthentication struct { + ObtainKrakenToken struct { + Token string + } `graphql:"obtainKrakenToken(input: {APIKey: $apiKey})"` +} + +// KrakenAccountLookup is a representation of a GraphQL query for obtaining the Account Number associated with the +// credentials used to authorize the request. +type KrakenAccountLookup struct { + Viewer struct { + Accounts []struct { + Number string + } + } +} + +type tariffType struct { + Id string + DisplayName string + FullName string + ProductCode string + StandingCharge float32 + PreVatStandingCharge float32 +} + +type tariffTypeWithTariffCode struct { + tariffType + TariffCode string +} + +type StandardTariff struct { + tariffTypeWithTariffCode +} +type DayNightTariff struct { + tariffTypeWithTariffCode +} +type ThreeRateTariff struct { + tariffTypeWithTariffCode +} +type HalfHourlyTariff struct { + tariffTypeWithTariffCode +} +type PrepayTariff struct { + tariffTypeWithTariffCode +} + +type KrakenAccountElectricityAgreements struct { + Account struct { + ElectricityAgreements []struct { + Id int + Tariff struct { + // yukky but the best way I can think of to handle this + // access via any relevant tariff data entry (i.e. StandardTariff) + // TODO would appreciate peer review + StandardTariff `graphql:"... on StandardTariff"` + DayNightTariff `graphql:"... on DayNightTariff"` + ThreeRateTariff `graphql:"... on ThreeRateTariff"` + HalfHourlyTariff `graphql:"... on HalfHourlyTariff"` + PrepayTariff `graphql:"... on PrepayTariff"` + } + MeterPoint struct { + // Mpan is the serial number of the meter that this ElectricityAgreement is bound to. + Mpan string + } + } `graphql:"electricityAgreements(active: true)"` + } `graphql:"account(accountNumber: $accountNumber)"` +} diff --git a/tariff/octopus/rest/api.go b/tariff/octopus/rest/api.go new file mode 100644 index 0000000000..a1308edd3a --- /dev/null +++ b/tariff/octopus/rest/api.go @@ -0,0 +1,48 @@ +package rest + +import ( + "fmt" + "strings" + "time" +) + +// ProductURI defines the location of the tariff information page. Substitute %s with tariff name. +const ProductURI = "https://api.octopus.energy/v1/products/%s/" + +// RatesURI defines the location of the full tariff rates page, including speculation. +// Substitute first %s with product code, second with tariff code. +const RatesURI = ProductURI + "electricity-tariffs/%s/standard-unit-rates/" + +// ConstructRatesAPIFromProductAndRegionCode returns a validly formatted, fully qualified URI to the unit rate information +// derived from the given product code and region. +func ConstructRatesAPIFromProductAndRegionCode(product string, region string) string { + tCode := strings.ToUpper(fmt.Sprintf("E-1R-%s-%s", product, region)) + return fmt.Sprintf(RatesURI, product, tCode) +} + +// ConstructRatesAPIFromTariffCode returns a validly formatted, fully qualified URI to the unit rate information +// derived from the given Tariff Code. +func ConstructRatesAPIFromTariffCode(tariff string) string { + // Hacky bullshit, saves handling both the product and tariff codes in GQL mode. + // Hopefully Octopus don't change how this works otherwise we might have to do this properly :( + if len(tariff) < 7 { + // OOB check + return "" + } + pCode := tariff[5 : len(tariff)-2] + return fmt.Sprintf(RatesURI, pCode, tariff) +} + +type UnitRates struct { + Count uint64 `json:"count"` + Next string `json:"next"` + Previous string `json:"previous"` + Results []Rate `json:"results"` +} + +type Rate struct { + ValidityStart time.Time `json:"valid_from"` + ValidityEnd time.Time `json:"valid_to"` + PriceInclusiveTax float64 `json:"value_inc_vat"` + PriceExclusiveTax float64 `json:"value_exc_vat"` +} From 21022a803892488a51d8c8d26404d570989d11d4 Mon Sep 17 00:00:00 2001 From: "duck." Date: Sun, 7 Jan 2024 23:57:19 +0000 Subject: [PATCH 2/4] tariff/octopusenergy: linting --- tariff/octopus.go | 4 ++-- tariff/octopus/graphql/api.go | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tariff/octopus.go b/tariff/octopus.go index 4dfbc1522a..d007021b3b 100644 --- a/tariff/octopus.go +++ b/tariff/octopus.go @@ -2,14 +2,14 @@ package tariff import ( "errors" - octoGql "github.com/evcc-io/evcc/tariff/octopus/graphql" - octoRest "github.com/evcc-io/evcc/tariff/octopus/rest" "slices" "sync" "time" "github.com/cenkalti/backoff/v4" "github.com/evcc-io/evcc/api" + octoGql "github.com/evcc-io/evcc/tariff/octopus/graphql" + octoRest "github.com/evcc-io/evcc/tariff/octopus/rest" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" ) diff --git a/tariff/octopus/graphql/api.go b/tariff/octopus/graphql/api.go index e09155f3a3..4275a98e97 100644 --- a/tariff/octopus/graphql/api.go +++ b/tariff/octopus/graphql/api.go @@ -3,12 +3,13 @@ package graphql import ( "context" "errors" - "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/request" - "github.com/hasura/go-graphql-client" "net/http" "sync" "time" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/hasura/go-graphql-client" ) type OctopusGraphQLClient struct { From 6c377d419a474dc987c5c37c05a33a98c7151a6c Mon Sep 17 00:00:00 2001 From: "duck." Date: Sat, 20 Jan 2024 15:41:33 +0000 Subject: [PATCH 3/4] tariff/octopus: tidy GraphQL module up a bit validators, etc --- tariff/octopus.go | 14 ++++++-- tariff/octopus/graphql/api.go | 41 +++++++++++++++-------- tariff/octopus/graphql/types.go | 59 +++++++++++++++++---------------- 3 files changed, 68 insertions(+), 46 deletions(-) diff --git a/tariff/octopus.go b/tariff/octopus.go index d007021b3b..a0f2df36ec 100644 --- a/tariff/octopus.go +++ b/tariff/octopus.go @@ -3,6 +3,7 @@ package tariff import ( "errors" "slices" + "strings" "sync" "time" @@ -49,8 +50,14 @@ func NewOctopusFromConfig(other map[string]interface{}) (api.Tariff, error) { if cc.Tariff == "" { return nil, errors.New("missing product / tariff code") } - } else if cc.Region != "" || cc.Tariff != "" { - return nil, errors.New("cannot use apikey at same time as product / tariff code") + } else { + // ApiKey validators + if cc.Region != "" || cc.Tariff != "" { + return nil, errors.New("cannot use apikey at same time as product / tariff code") + } + if len(cc.ApiKey) != 32 || !strings.HasPrefix(cc.ApiKey, "sk_live_") { + return nil, errors.New("apikey of invalid or unexpected format, please check for errors") + } } t := &Octopus{ @@ -95,7 +102,8 @@ func (t *Octopus) run(done chan error) { restQueryUri = octoRest.ConstructRatesAPIFromProductAndRegionCode(t.tariff, t.region) } - // TODO tick every 15 minutes if GraphQL is available to poll for Intelligent slots. + // When we eventually get around to writing Intelligent support, + // we'll want to tick every 15 minutes if GraphQL is available to poll for Intelligent slots. for ; true; <-time.Tick(time.Hour) { var res octoRest.UnitRates diff --git a/tariff/octopus/graphql/api.go b/tariff/octopus/graphql/api.go index 4275a98e97..0de39e3e12 100644 --- a/tariff/octopus/graphql/api.go +++ b/tariff/octopus/graphql/api.go @@ -12,20 +12,32 @@ import ( "github.com/hasura/go-graphql-client" ) +// BaseURI is Octopus Energy's core API root. +const BaseURI = "https://api.octopus.energy" + +// URI is the GraphQL query endpoint for Octopus Energy. +const URI = BaseURI + "/v1/graphql/" + +// OctopusGraphQLClient provides an interface for communicating with Octopus Energy's Kraken platform. type OctopusGraphQLClient struct { *graphql.Client log *util.Logger // apikey is the Octopus Energy API key (provided by user) apikey string + // token is the GraphQL token used for communication with kraken (we get this ourselves with the apikey) token *string - // accountNumber is the Octopus Energy account number associated with the given API key (queried ourselves via GraphQL) - accountNumber string + // tokenExpiration tracks the expiry of the acquired token. A new Token should be obtained if this time is passed. tokenExpiration time.Time - tokenMtx sync.Mutex + // tokenMtx should be held when requesting a new token. + tokenMtx sync.Mutex + + // accountNumber is the Octopus Energy account number associated with the given API key (queried ourselves via GraphQL) + accountNumber string } +// NewClient returns a new, unauthenticated instance of OctopusGraphQLClient. func NewClient(log *util.Logger, apikey string) (*OctopusGraphQLClient, error) { cli := request.NewClient(log) @@ -58,18 +70,19 @@ func (c *OctopusGraphQLClient) refreshToken() error { return nil } - // TODO is this a good use of background context? - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + // take a lock against the token mutex for the refresh c.tokenMtx.Lock() defer c.tokenMtx.Unlock() - var q KrakenTokenAuthentication + var q krakenTokenAuthentication err := c.Client.Mutate(ctx, &q, map[string]interface{}{"apiKey": c.apikey}) if err != nil { return err } - c.log.INFO.Println("got GQL token from octopus") + c.log.TRACE.Println("got GQL token from octopus") c.token = &q.ObtainKrakenToken.Token // Refresh in 55 minutes (the token lasts an hour, but just to be safe...) c.tokenExpiration = time.Now().Add(time.Minute * 55) @@ -89,10 +102,10 @@ func (c *OctopusGraphQLClient) AccountNumber() (string, error) { return "", err } - // TODO is this a good use of background context? - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() - var q KrakenAccountLookup + var q krakenAccountLookup err := c.Client.Query(ctx, &q, nil) if err != nil { return "", err @@ -121,10 +134,10 @@ func (c *OctopusGraphQLClient) TariffCode() (string, error) { return "", nil } - // TODO is this a good use of background context? - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() - var q KrakenAccountElectricityAgreements + var q krakenAccountElectricityAgreements err = c.Client.Query(ctx, &q, map[string]interface{}{"accountNumber": acc}) if err != nil { return "", err @@ -138,5 +151,5 @@ func (c *OctopusGraphQLClient) TariffCode() (string, error) { //switch t := q.Account.ElectricityAgreements[0].Tariff.(type) { // //} - return q.Account.ElectricityAgreements[0].Tariff.StandardTariff.TariffCode, nil + return q.Account.ElectricityAgreements[0].Tariff.TariffCode(), nil } diff --git a/tariff/octopus/graphql/types.go b/tariff/octopus/graphql/types.go index dd8c8f8d84..0a9b2f129c 100644 --- a/tariff/octopus/graphql/types.go +++ b/tariff/octopus/graphql/types.go @@ -1,23 +1,15 @@ package graphql -// BaseURI is Octopus Energy's core API root. -const BaseURI = "https://api.octopus.energy" - -// URI is the GraphQL query endpoint for Octopus Energy. -const URI = BaseURI + "/v1/graphql/" - -// FIXME these don't need to be public - -// KrakenTokenAuthentication is a representation of a GraphQL query for obtaining a Kraken API token. -type KrakenTokenAuthentication struct { +// krakenTokenAuthentication is a representation of a GraphQL query for obtaining a Kraken API token. +type krakenTokenAuthentication struct { ObtainKrakenToken struct { Token string } `graphql:"obtainKrakenToken(input: {APIKey: $apiKey})"` } -// KrakenAccountLookup is a representation of a GraphQL query for obtaining the Account Number associated with the +// krakenAccountLookup is a representation of a GraphQL query for obtaining the Account Number associated with the // credentials used to authorize the request. -type KrakenAccountLookup struct { +type krakenAccountLookup struct { Viewer struct { Accounts []struct { Number string @@ -25,6 +17,24 @@ type KrakenAccountLookup struct { } } +type tariffData struct { + // yukky but the best way I can think of to handle this + // access via any relevant tariff data entry (i.e. standardTariff) + standardTariff `graphql:"... on StandardTariff"` + dayNightTariff `graphql:"... on DayNightTariff"` + threeRateTariff `graphql:"... on ThreeRateTariff"` + halfHourlyTariff `graphql:"... on HalfHourlyTariff"` + prepayTariff `graphql:"... on PrepayTariff"` +} + +// TariffCode is a shortcut function to obtaining the Tariff Code of the given tariff, regardless of tariff type. +// Developer Note: GraphQL query returns the same element keys regardless of type, +// so it should always be decoded as standardTariff at least. +// We are unlikely to use the other Tariff types for data access (?). +func (d *tariffData) TariffCode() string { + return d.standardTariff.TariffCode +} + type tariffType struct { Id string DisplayName string @@ -39,36 +49,27 @@ type tariffTypeWithTariffCode struct { TariffCode string } -type StandardTariff struct { +type standardTariff struct { tariffTypeWithTariffCode } -type DayNightTariff struct { +type dayNightTariff struct { tariffTypeWithTariffCode } -type ThreeRateTariff struct { +type threeRateTariff struct { tariffTypeWithTariffCode } -type HalfHourlyTariff struct { +type halfHourlyTariff struct { tariffTypeWithTariffCode } -type PrepayTariff struct { +type prepayTariff struct { tariffTypeWithTariffCode } -type KrakenAccountElectricityAgreements struct { +type krakenAccountElectricityAgreements struct { Account struct { ElectricityAgreements []struct { - Id int - Tariff struct { - // yukky but the best way I can think of to handle this - // access via any relevant tariff data entry (i.e. StandardTariff) - // TODO would appreciate peer review - StandardTariff `graphql:"... on StandardTariff"` - DayNightTariff `graphql:"... on DayNightTariff"` - ThreeRateTariff `graphql:"... on ThreeRateTariff"` - HalfHourlyTariff `graphql:"... on HalfHourlyTariff"` - PrepayTariff `graphql:"... on PrepayTariff"` - } + Id int + Tariff tariffData MeterPoint struct { // Mpan is the serial number of the meter that this ElectricityAgreement is bound to. Mpan string From f396ab758c82747b9635ea0141568cb237e245e7 Mon Sep 17 00:00:00 2001 From: duck Date: Tue, 23 Jan 2024 12:38:59 +0000 Subject: [PATCH 4/4] tariff/octopus: Improve error message Co-authored-by: andig --- tariff/octopus.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tariff/octopus.go b/tariff/octopus.go index a0f2df36ec..d72e92bc6b 100644 --- a/tariff/octopus.go +++ b/tariff/octopus.go @@ -56,7 +56,7 @@ func NewOctopusFromConfig(other map[string]interface{}) (api.Tariff, error) { return nil, errors.New("cannot use apikey at same time as product / tariff code") } if len(cc.ApiKey) != 32 || !strings.HasPrefix(cc.ApiKey, "sk_live_") { - return nil, errors.New("apikey of invalid or unexpected format, please check for errors") + return nil, errors.New("invalid apikey format") } }