From 33aa8841bb18b008e0b7e5697c2fcb71241bc69a Mon Sep 17 00:00:00 2001 From: duck Date: Tue, 30 Apr 2024 07:05:01 +0100 Subject: [PATCH] Octopusenergy: support API keys for tariff data lookup (#13637) --- tariff/octopus.go | 83 +++++++--- tariff/octopus/api.go | 35 ----- tariff/octopus/graphql/api.go | 147 ++++++++++++++++++ tariff/octopus/graphql/types.go | 79 ++++++++++ tariff/octopus/rest/api.go | 48 ++++++ templates/definition/tariff/octopus-api.yaml | 14 ++ .../tariff/octopus-productcode.yaml | 20 +++ 7 files changed, 373 insertions(+), 53 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 create mode 100644 templates/definition/tariff/octopus-api.yaml create mode 100644 templates/definition/tariff/octopus-productcode.yaml diff --git a/tariff/octopus.go b/tariff/octopus.go index b79bf947d6..1a496573ad 100644 --- a/tariff/octopus.go +++ b/tariff/octopus.go @@ -3,21 +3,24 @@ package tariff import ( "errors" "slices" + "strings" "sync" "time" "github.com/cenkalti/backoff/v4" "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/tariff/octopus" + 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" ) type Octopus struct { - log *util.Logger - uri string - region string - data *util.Monitor[api.Rates] + log *util.Logger + region string + productCode string + apikey string + data *util.Monitor[api.Rates] } var _ api.Tariff = (*Octopus)(nil) @@ -28,26 +31,47 @@ func init() { func NewOctopusFromConfig(other map[string]interface{}) (api.Tariff, error) { var cc struct { - Region string - Tariff string + Region string + Tariff string // DEPRECATED: use ProductCode + ProductCode string + ApiKey string } + logger := util.NewLogger("octopus") + 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 == "" { + // deprecated - copy to correct slot and WARN + logger.WARN.Print("'tariff' is deprecated and will break in a future version - use 'productCode' instead") + cc.ProductCode = cc.Tariff + } + if cc.ProductCode == "" { + return nil, errors.New("missing product code") + } + } else { + // ApiKey validators + if cc.Region != "" || cc.Tariff != "" { + return nil, errors.New("cannot use apikey at same time as product code") + } + if len(cc.ApiKey) != 32 || !strings.HasPrefix(cc.ApiKey, "sk_live_") { + return nil, errors.New("invalid apikey format") + } } t := &Octopus{ - log: util.NewLogger("octopus"), - uri: octopus.ConstructRatesAPI(cc.Tariff, cc.Region), - region: cc.Tariff, - data: util.NewMonitor[api.Rates](2 * time.Hour), + log: logger, + region: cc.Region, + productCode: cc.ProductCode, + apikey: cc.ApiKey, + data: util.NewMonitor[api.Rates](2 * time.Hour), } done := make(chan error) @@ -62,12 +86,35 @@ 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.productCode, t.region) + } + + // TODO tick every 15 minutes if GraphQL is available to poll for Intelligent slots. tick := time.NewTicker(time.Hour) for ; true; <-tick.C { - var res octopus.UnitRates + var res octoRest.UnitRates if err := backoff.Retry(func() error { - return backoffPermanentError(client.GetJSON(t.uri, &res)) + return backoffPermanentError(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..0bb24e8725 --- /dev/null +++ b/tariff/octopus/graphql/api.go @@ -0,0 +1,147 @@ +package graphql + +import ( + "context" + "errors" + "net/http" + "sync" + "time" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "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 + + // 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 + // tokenExpiration tracks the expiry of the acquired token. A new Token should be obtained if this time is passed. + tokenExpiration time.Time + // 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) + + gq := &OctopusGraphQLClient{ + Client: graphql.NewClient(URI, cli), + apikey: apikey, + } + + if err := gq.refreshToken(); err != nil { + return nil, err + } + + // Future requests must have the appropriate Authorization header set. + gq.Client = gq.Client.WithRequestModifier(func(r *http.Request) { + gq.tokenMtx.Lock() + defer gq.tokenMtx.Unlock() + r.Header.Add("Authorization", *gq.token) + }) + + return gq, nil +} + +// 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 { + // take a lock against the token mutex for the refresh + c.tokenMtx.Lock() + defer c.tokenMtx.Unlock() + + if time.Until(c.tokenExpiration) > 5*time.Minute { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + var q krakenTokenAuthentication + if err := c.Client.Mutate(ctx, &q, map[string]interface{}{"apiKey": c.apikey}); err != nil { + return err + } + + c.token = &q.ObtainKrakenToken.Token + c.tokenExpiration = time.Now().Add(time.Hour) + 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 + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + var q krakenAccountLookup + if err := c.Client.Query(ctx, &q, nil); 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 { + return "", errors.New("more than one octopus account on this api key not supported") + } + 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 + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + var q krakenAccountElectricityAgreements + if err := c.Client.Query(ctx, &q, map[string]interface{}{"accountNumber": acc}); 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.TariffCode(), nil +} diff --git a/tariff/octopus/graphql/types.go b/tariff/octopus/graphql/types.go new file mode 100644 index 0000000000..0a9b2f129c --- /dev/null +++ b/tariff/octopus/graphql/types.go @@ -0,0 +1,79 @@ +package graphql + +// 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 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 + 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 tariffData + 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"` +} diff --git a/templates/definition/tariff/octopus-api.yaml b/templates/definition/tariff/octopus-api.yaml new file mode 100644 index 0000000000..6121f7b8af --- /dev/null +++ b/templates/definition/tariff/octopus-api.yaml @@ -0,0 +1,14 @@ +template: octopus-api +products: + - brand: Octopus Energy + description: + en: Octopus Energy - API +params: + - name: apiKey + type: string + required: true + description: + en: "Your Octopus Energy API Key. You can find it here: https://octopus.energy/dashboard/new/accounts/personal-details/api-access" +render: | + type: octopusenergy + apikey: {{ .apikey }} diff --git a/templates/definition/tariff/octopus-productcode.yaml b/templates/definition/tariff/octopus-productcode.yaml new file mode 100644 index 0000000000..0dfcd3edcd --- /dev/null +++ b/templates/definition/tariff/octopus-productcode.yaml @@ -0,0 +1,20 @@ +template: octopus-productcode +products: + - brand: Octopus Energy + description: + en: Octopus Energy - Product Code +params: + - name: productCode + type: string + required: true + description: + en: "The tariff code for your energy contract. Make sure this is set to your import tariff code. It'll look something like this: AGILE-FLEX-22-11-25" + - name: region + type: string + required: true + description: + en: "The DNO region you are located in. More information: https://www.energy-stats.uk/dno-region-codes-explained/" +render: | + type: octopusenergy + productCode: {{ .productCode }} + region: {{ .region }}