-
-
Notifications
You must be signed in to change notification settings - Fork 702
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
duckfullstop
wants to merge
4
commits into
evcc-io:master
from
duckfullstop:feature-tariff-octopus-graphql
Closed
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)"` | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 🙏
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ping ;)
There was a problem hiding this comment.
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.