Octopusenergy: support API keys for tariff data lookup (#13637)

This commit is contained in:
duck 2024-04-30 07:05:01 +01:00 • committed by GitHub
parent 090b0a700e
commit 33aa8841bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 373 additions and 53 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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