Octopus DE: refactor refresh (#26110)

This commit is contained in:
Johannes Rudolph 2025-12-23 10:33:51 +01:00 • committed by GitHub
parent e8151aeb5e
commit 5253330aa3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 92 additions and 102 deletions

View file

@ -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 })

View file

@ -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
}

View 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
}

View file

@ -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",