Skip to content

Commit

Permalink
Octopusenergy: support API keys for tariff data lookup (#13637)
Browse files Browse the repository at this point in the history
  • Loading branch information
duckfullstop committed Apr 30, 2024
1 parent 090b0a7 commit 33aa884
Show file tree
Hide file tree
Showing 7 changed files with 373 additions and 53 deletions.
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")
}
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)"`
}

0 comments on commit 33aa884

Please sign in to comment.