Octopusenergy: support API keys for tariff data lookup (#13637)
This commit is contained in:
parent
090b0a700e
commit
33aa8841bb
7 changed files with 373 additions and 53 deletions
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
147
tariff/octopus/graphql/api.go
Normal file
147
tariff/octopus/graphql/api.go
Normal 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
|
||||
}
|
||||
79
tariff/octopus/graphql/types.go
Normal file
79
tariff/octopus/graphql/types.go
Normal 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)"`
|
||||
}
|
||||
48
tariff/octopus/rest/api.go
Normal file
48
tariff/octopus/rest/api.go
Normal 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"`
|
||||
}
|
||||
14
templates/definition/tariff/octopus-api.yaml
Normal file
14
templates/definition/tariff/octopus-api.yaml
Normal 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 }}
|
||||
20
templates/definition/tariff/octopus-productcode.yaml
Normal file
20
templates/definition/tariff/octopus-productcode.yaml
Normal 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 }}
|
||||
Loading…
Add table
Add a link
Reference in a new issue