Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tariff/octopusenergy: Refactor / Support API Keys for tariff data lookup #13637

Merged
merged 12 commits into from
Apr 30, 2024
83 changes: 65 additions & 18 deletions tariff/octopus.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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 })

Expand Down
35 changes: 0 additions & 35 deletions tariff/octopus/api.go

This file was deleted.

147 changes: 147 additions & 0 deletions tariff/octopus/graphql/api.go
Original file line number Diff line number Diff line change
@@ -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")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my only proviso on this wording is that it implies we will never support it / people should give up with the integration, whereas we want people to report it so that account selection functionality can be added if necessary - happy to go with breaking loudly though

}
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
}
79 changes: 79 additions & 0 deletions tariff/octopus/graphql/types.go
Original file line number Diff line number Diff line change
@@ -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)"`
}
Loading