Octopus DE: refactor refresh (#26110)
This commit is contained in:
parent
e8151aeb5e
commit
5253330aa3
4 changed files with 92 additions and 102 deletions
|
|
@ -13,11 +13,9 @@ import (
|
|||
)
|
||||
|
||||
type OctopusDe struct {
|
||||
log *util.Logger
|
||||
email string
|
||||
password string
|
||||
accountNumber string
|
||||
data *util.Monitor[api.Rates]
|
||||
log *util.Logger
|
||||
gqlClient *octoDeGql.OctopusDeGraphQLClient
|
||||
data *util.Monitor[api.Rates]
|
||||
}
|
||||
|
||||
var _ api.Tariff = (*OctopusDe)(nil)
|
||||
|
|
@ -45,8 +43,6 @@ func buildOctopusDeFromConfig(other map[string]any) (*OctopusDe, error) {
|
|||
AccountNumber string
|
||||
}
|
||||
|
||||
logger := util.NewLogger("octopus-de")
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -63,12 +59,18 @@ func buildOctopusDeFromConfig(other map[string]any) (*OctopusDe, error) {
|
|||
return nil, errors.New("missing account number")
|
||||
}
|
||||
|
||||
log := util.NewLogger("octopus-de")
|
||||
|
||||
// Create GraphQL client
|
||||
gqlClient, err := octoDeGql.NewClient(log, cc.Email, cc.Password, cc.AccountNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t := &OctopusDe{
|
||||
log: logger,
|
||||
email: cc.Email,
|
||||
password: cc.Password,
|
||||
accountNumber: cc.AccountNumber,
|
||||
data: util.NewMonitor[api.Rates](2 * time.Hour),
|
||||
log: log,
|
||||
gqlClient: gqlClient,
|
||||
data: util.NewMonitor[api.Rates](2 * time.Hour),
|
||||
}
|
||||
|
||||
return t, nil
|
||||
|
|
@ -77,20 +79,12 @@ func buildOctopusDeFromConfig(other map[string]any) (*OctopusDe, error) {
|
|||
func (t *OctopusDe) run(done chan error) {
|
||||
var once sync.Once
|
||||
|
||||
// Create GraphQL client
|
||||
gqlClient, err := octoDeGql.NewClient(t.log, t.email, t.password, t.accountNumber)
|
||||
if err != nil {
|
||||
once.Do(func() { done <- err })
|
||||
t.log.ERROR.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
for tick := time.Tick(time.Hour); ; <-tick {
|
||||
var rates []octoDeGql.RatePeriod
|
||||
|
||||
if err := backoff.Retry(func() error {
|
||||
var err error
|
||||
rates, err = gqlClient.UnitRateForecast()
|
||||
rates, err = t.gqlClient.UnitRateForecast()
|
||||
return backoffPermanentError(err)
|
||||
}, bo()); err != nil {
|
||||
once.Do(func() { done <- err })
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/oauth"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/evcc-io/evcc/util/transport"
|
||||
"github.com/hasura/go-graphql-client"
|
||||
)
|
||||
|
||||
|
|
@ -20,95 +21,44 @@ const BaseURI = "https://api.oeg-kraken.energy/v1/graphql/"
|
|||
|
||||
// OctopusDeGraphQLClient provides an interface for communicating with Octopus Energy Germany's Kraken platform.
|
||||
type OctopusDeGraphQLClient struct {
|
||||
*graphql.Client
|
||||
|
||||
// Local logging utility.
|
||||
log *util.Logger
|
||||
|
||||
// email is the Octopus Energy Germany account email
|
||||
email string
|
||||
|
||||
// password is the Octopus Energy Germany account password
|
||||
password string
|
||||
|
||||
// token is the GraphQL token used for communication with kraken
|
||||
token *string
|
||||
// tokenExpiration tracks the expiry of the acquired token
|
||||
tokenExpiration time.Time
|
||||
// tokenMtx should be held when requesting a new token
|
||||
tokenMtx sync.Mutex
|
||||
|
||||
// accountNumber is the Octopus Energy Germany account number
|
||||
*graphql.Client
|
||||
accountNumber string
|
||||
}
|
||||
|
||||
// NewClient returns a new, authenticated instance of OctopusDeGraphQLClient.
|
||||
func NewClient(log *util.Logger, email, password, accountNumber string) (*OctopusDeGraphQLClient, error) {
|
||||
ts := oauth.RefreshTokenSource(nil, &TokenSource{
|
||||
log: log,
|
||||
email: email,
|
||||
password: password,
|
||||
})
|
||||
|
||||
cli := request.NewClient(log)
|
||||
cli.Transport = &transport.Decorator{
|
||||
Decorator: func(req *http.Request) error {
|
||||
token, err := ts.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Kraken API requires Authorization header without "Bearer" prefix
|
||||
req.Header.Set("Authorization", token.AccessToken)
|
||||
return nil
|
||||
},
|
||||
Base: cli.Transport,
|
||||
}
|
||||
|
||||
gq := &OctopusDeGraphQLClient{
|
||||
Client: graphql.NewClient(BaseURI, cli),
|
||||
log: log,
|
||||
email: email,
|
||||
password: password,
|
||||
accountNumber: accountNumber,
|
||||
Client: graphql.NewClient(BaseURI, cli),
|
||||
}
|
||||
|
||||
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()
|
||||
if gq.token != nil {
|
||||
r.Header.Add("Authorization", *gq.token)
|
||||
}
|
||||
})
|
||||
|
||||
return gq, nil
|
||||
}
|
||||
|
||||
// refreshToken updates the GraphQL token from the email and password.
|
||||
// Basic caching is provided - it will not update the token if it hasn't expired yet.
|
||||
func (c *OctopusDeGraphQLClient) 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*10)
|
||||
defer cancel()
|
||||
|
||||
// Create a temporary client without authentication for the initial token request
|
||||
cli := request.NewClient(c.log)
|
||||
tempClient := graphql.NewClient(BaseURI, cli)
|
||||
|
||||
var q krakenTokenAuthentication
|
||||
if err := tempClient.Mutate(ctx, &q, map[string]any{
|
||||
"email": c.email,
|
||||
"password": c.password,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("authentication failed: %w", 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
|
||||
}
|
||||
|
||||
// UnitRateForecast queries the day-ahead price forecast for the account
|
||||
func (c *OctopusDeGraphQLClient) UnitRateForecast() ([]RatePeriod, error) {
|
||||
// Update refresh token (if necessary)
|
||||
if err := c.refreshToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
|
|
@ -156,13 +106,11 @@ func (c *OctopusDeGraphQLClient) UnitRateForecast() ([]RatePeriod, error) {
|
|||
// Parse string values to float64
|
||||
netRate, err := parseFloat(rate.NetUnitRateCentsPerKwh)
|
||||
if err != nil {
|
||||
c.log.DEBUG.Printf("failed to parse net unit rate '%s': %v", rate.NetUnitRateCentsPerKwh, err)
|
||||
return nil, fmt.Errorf("failed to parse net unit rate: %w", err)
|
||||
}
|
||||
|
||||
grossRate, err := parseFloat(rate.LatestGrossUnitRateCentsPerKwh)
|
||||
if err != nil {
|
||||
c.log.DEBUG.Printf("failed to parse gross unit rate '%s': %v", rate.LatestGrossUnitRateCentsPerKwh, err)
|
||||
return nil, fmt.Errorf("failed to parse gross unit rate: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -180,7 +128,6 @@ func (c *OctopusDeGraphQLClient) UnitRateForecast() ([]RatePeriod, error) {
|
|||
return nil, errors.New("no rate forecast available")
|
||||
}
|
||||
|
||||
c.log.TRACE.Printf("GraphQL: retrieved %d rate periods", len(rates))
|
||||
return rates, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
55
tariff/octopusde/graphql/tokensource.go
Normal file
55
tariff/octopusde/graphql/tokensource.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package graphql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/hasura/go-graphql-client"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type TokenSource struct {
|
||||
log *util.Logger
|
||||
email, password string
|
||||
}
|
||||
|
||||
// RefreshToken implements oauth.TokenRefresher to obtain a new JWT token.
|
||||
// It parses the JWT to extract the actual expiry time from the token claims.
|
||||
func (c *TokenSource) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||
defer cancel()
|
||||
|
||||
// Create a temporary client without authentication for the token request
|
||||
cli := request.NewClient(c.log)
|
||||
tempClient := graphql.NewClient(BaseURI, cli)
|
||||
|
||||
var q krakenTokenAuthentication
|
||||
if err := tempClient.Mutate(ctx, &q, map[string]any{
|
||||
"email": c.email,
|
||||
"password": c.password,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("authentication failed: %w", err)
|
||||
}
|
||||
|
||||
// Parse JWT to extract expiry time using RegisteredClaims
|
||||
// We use ParseUnverified since we don't have the signing key and trust the token from the API
|
||||
var claims jwt.RegisteredClaims
|
||||
if _, _, err := jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified(q.ObtainKrakenToken.Token, &claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JWT: %w", err)
|
||||
}
|
||||
|
||||
// Extract expiry from JWT claims
|
||||
expiry := time.Now().Add(time.Hour)
|
||||
if claims.ExpiresAt != nil {
|
||||
expiry = claims.ExpiresAt.Time
|
||||
}
|
||||
|
||||
return &oauth2.Token{
|
||||
AccessToken: q.ObtainKrakenToken.Token,
|
||||
Expiry: expiry,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -3,13 +3,10 @@ package tariff
|
|||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/evcc-io/evcc/util/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOctopusDeConfigParse(t *testing.T) {
|
||||
test.SkipCI(t)
|
||||
|
||||
validConfig := map[string]any{
|
||||
"email": "test@example.com",
|
||||
"password": "testpassword",
|
||||
|
|
@ -19,9 +16,6 @@ func TestOctopusDeConfigParse(t *testing.T) {
|
|||
tariff, err := buildOctopusDeFromConfig(validConfig)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tariff)
|
||||
require.Equal(t, "test@example.com", tariff.email)
|
||||
require.Equal(t, "testpassword", tariff.password)
|
||||
require.Equal(t, "A-12345678", tariff.accountNumber)
|
||||
|
||||
missingEmailConfig := map[string]any{
|
||||
"password": "testpassword",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue