208 lines
6.5 KiB
Go
208 lines
6.5 KiB
Go
package graphql
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"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
|
|
|
|
// Local logging utility.
|
|
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
|
|
|
|
// accountNumberDesire is an optional Octopus Energy account number to search for, if there are multiple accounts on the key.
|
|
accountNumberDesire string
|
|
}
|
|
|
|
// NewClient returns a new, unauthenticated instance of OctopusGraphQLClient.
|
|
func NewClient(log *util.Logger, apikey string, accountNumber string) (*OctopusGraphQLClient, error) {
|
|
cli := request.NewClient(log)
|
|
|
|
gq := &OctopusGraphQLClient{
|
|
Client: graphql.NewClient(URI, cli),
|
|
log: log,
|
|
apikey: apikey,
|
|
accountNumberDesire: accountNumber,
|
|
}
|
|
|
|
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]any{"apiKey": c.apikey}); err != nil {
|
|
return err
|
|
}
|
|
|
|
c.token = &q.ObtainKrakenToken.Token
|
|
c.tokenExpiration = time.Now().Add(time.Hour)
|
|
c.log.TRACE.Println("GraphQL: refreshed token, now expires", c.tokenExpiration)
|
|
return nil
|
|
}
|
|
|
|
// AccountNumber queries the Account Number assigned to the associated API key.
|
|
// Caching is provided.
|
|
// If more than one Account is bound to the API Key, this will search for AccountNumberDesire in the list of available accounts,
|
|
// and return an error if it cannot be found.
|
|
func (c *OctopusGraphQLClient) AccountNumber() (accountNumber string, err 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
|
|
}
|
|
|
|
c.accountNumber, err = filterAccount(q.Viewer.Accounts, c.accountNumberDesire)
|
|
if err != nil {
|
|
if errors.Is(err, ErrMultipleAccounts) {
|
|
c.log.ERROR.Println("There is more than one account associated with this Octopus API key.")
|
|
c.log.ERROR.Println("Please add one of the following accounts to your tariff configuration under the accountNumber key:")
|
|
for _, account := range q.Viewer.Accounts {
|
|
c.log.ERROR.Println(" - ", account.Number)
|
|
}
|
|
}
|
|
return "", err
|
|
}
|
|
|
|
c.log.TRACE.Println("GraphQL: using account number:", c.accountNumber)
|
|
return c.accountNumber, nil
|
|
}
|
|
|
|
// TariffCode queries the Tariff Code of the first valid Electricity Agreement active on the account that matches the given TariffDirection.
|
|
func (c *OctopusGraphQLClient) TariffCode(direction TariffDirection) (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 "", err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
|
defer cancel()
|
|
|
|
var q krakenAccountElectricityAgreements
|
|
if err := c.Client.Query(ctx, &q, map[string]any{"accountNumber": acc}); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if len(q.Account.ElectricityAgreements) == 0 {
|
|
return "", errors.New("no electricity agreements found")
|
|
}
|
|
|
|
// Filter out any inappropriate tariffs; select the first tariff that aligns with our configuration.
|
|
var tariffCode string
|
|
for _, agreement := range q.Account.ElectricityAgreements {
|
|
if agreement.Tariff.TariffDirection() != direction {
|
|
c.log.TRACE.Println("GraphQL: filtering tariff with incorrect import/export type:", agreement.Tariff.TariffCode())
|
|
continue
|
|
}
|
|
tariffCode = agreement.Tariff.TariffCode()
|
|
break
|
|
}
|
|
|
|
if tariffCode == "" {
|
|
return "", fmt.Errorf("no electricity agreement for type %s", direction)
|
|
}
|
|
|
|
c.log.TRACE.Println("GraphQL: tariff code found:", tariffCode)
|
|
|
|
return tariffCode, nil
|
|
}
|
|
|
|
// filterAccount searches the given accounts for one exactly matching the desire.
|
|
// If a desire is set, but cannot be found, it will return an error.
|
|
// If a desire is not set, but there is more than one account, it will return an error.
|
|
// If a desire is not set, but there is only one account, it will return the Number of that account.
|
|
func filterAccount(accounts []krakenAccount, desire string) (result string, err error) {
|
|
// Test for no available accounts.
|
|
if len(accounts) == 0 {
|
|
return "", ErrNoAccounts
|
|
}
|
|
|
|
// If a desired account number is set, let's try and bind to that first.
|
|
if desire != "" {
|
|
for _, account := range accounts {
|
|
if account.Number == desire {
|
|
return account.Number, nil
|
|
}
|
|
}
|
|
// A Desire was set, but we couldn't find it.
|
|
return "", ErrAccountNotFound
|
|
}
|
|
|
|
if len(accounts) == 1 {
|
|
// Only one possible result, filtration not enabled.
|
|
return accounts[0].Number, nil
|
|
}
|
|
|
|
// There is more than one account, and no filter is set. We need the user to intervene at this point, as we can't presume.
|
|
return "", ErrMultipleAccounts
|
|
}
|