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: Support API Keys for tariff data lookup #11555

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 53 additions & 11 deletions tariff/octopus.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +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
// Tariff is actually the Product Code
tariff string
apikey string
data *util.Monitor[api.Rates]
}

Expand All @@ -29,24 +33,38 @@ 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 {
// 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("invalid apikey format")
}
}

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

Expand All @@ -62,11 +80,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 != "" {
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't this part be moved to the tariff init as its a one-time setup efforts? Also saves storing the api key and client.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

On mobile but I'll inspect this in more detail once I'm in front of a laptop later 🙏

Copy link
Member

Choose a reason for hiding this comment

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

ping ;)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorry for the delay, life's been happening 😭

You've a fair point here - was trying to think how best to handle these two ways of setting up. Refactoring the rest module would probably be a better solution, generating the API URL and holding onto it in this execution loop seems a bit wrong.

The above being said, see my other comment on refresh tokens.

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

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

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

This file was deleted.

155 changes: 155 additions & 0 deletions tariff/octopus/graphql/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
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
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
// 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),
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
}

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
err := c.Client.Mutate(ctx, &q, map[string]interface{}{"apiKey": c.apikey})
if err != nil {
return err
}
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)
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
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
}

ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()

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