evcc-io/tariff/octopus/graphql/api.go
2025-10-31 15:25:53 +00:00

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
}