Add Octopus Germany "Dynamic Octopus" tariff (#26102)

This commit is contained in:
Johannes Rudolph 2025-12-20 11:17:01 +01:00 • committed by GitHub
parent cf7f501126
commit 1506de71c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 482 additions and 0 deletions

139
tariff/octopusde.go Normal file
View file

@ -0,0 +1,139 @@
package tariff
import (
"errors"
"slices"
"sync"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/evcc-io/evcc/api"
octoDeGql "github.com/evcc-io/evcc/tariff/octopusde/graphql"
"github.com/evcc-io/evcc/util"
)
type OctopusDe struct {
log *util.Logger
email string
password string
accountNumber string
data *util.Monitor[api.Rates]
}
var _ api.Tariff = (*OctopusDe)(nil)
func init() {
registry.Add("octopus-de", NewOctopusDeFromConfig)
}
// NewOctopusDeFromConfig creates the tariff provider from the given config map, and runs it.
func NewOctopusDeFromConfig(other map[string]any) (api.Tariff, error) {
t, err := buildOctopusDeFromConfig(other)
if err != nil {
return nil, err
}
return runOrError(t)
}
// buildOctopusDeFromConfig creates the Tariff provider from the given config map.
// Split out to allow for testing.
func buildOctopusDeFromConfig(other map[string]any) (*OctopusDe, error) {
var cc struct {
Email string
Password string
AccountNumber string
}
logger := util.NewLogger("octopus-de")
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if cc.Email == "" {
return nil, errors.New("missing email")
}
if cc.Password == "" {
return nil, errors.New("missing password")
}
if cc.AccountNumber == "" {
return nil, errors.New("missing account number")
}
t := &OctopusDe{
log: logger,
email: cc.Email,
password: cc.Password,
accountNumber: cc.AccountNumber,
data: util.NewMonitor[api.Rates](2 * time.Hour),
}
return t, nil
}
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()
return backoffPermanentError(err)
}, bo()); err != nil {
once.Do(func() { done <- err })
t.log.ERROR.Printf("failed to fetch unit rate forecast: %v", err)
continue
}
data := make(api.Rates, 0, len(rates))
for _, r := range rates {
// ValidTo can be zero which means the rate has no expected end
// Set it to a date far in the future in this case
rateEnd := r.ValidTo
if rateEnd.IsZero() {
t.log.TRACE.Printf("handling rate with indefinite length: %v", r.ValidFrom)
// Add a year from the start date
rateEnd = r.ValidFrom.AddDate(1, 0, 0)
}
ar := api.Rate{
Start: r.ValidFrom,
End: rateEnd,
// Convert from cents per kWh to price per kWh (divide by 100)
// Use gross price (including tax) as that's what the customer pays
Value: r.LatestGrossUnitRateCentsPerKwh / 100,
}
data = append(data, ar)
}
mergeRates(t.data, data)
once.Do(func() { close(done) })
}
}
// Rates implements the api.Tariff interface
func (t *OctopusDe) Rates() (api.Rates, error) {
var res api.Rates
err := t.data.GetFunc(func(val api.Rates) {
res = slices.Clone(val)
})
return res, err
}
// Type implements the api.Tariff interface
func (t *OctopusDe) Type() api.TariffType {
return api.TariffTypePriceForecast
}

View file

@ -0,0 +1,190 @@
package graphql
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"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 Germany's Kraken API root.
// The implementation in this file follows the published example at https://octopusenergy.de/blog/wohnen/dynamisch-sparen-per-api
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
accountNumber string
}
// NewClient returns a new, authenticated instance of OctopusDeGraphQLClient.
func NewClient(log *util.Logger, email, password, accountNumber string) (*OctopusDeGraphQLClient, error) {
cli := request.NewClient(log)
gq := &OctopusDeGraphQLClient{
Client: graphql.NewClient(BaseURI, cli),
log: log,
email: email,
password: password,
accountNumber: 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()
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()
var q getDayAheadPrices
if err := c.Client.Query(ctx, &q, map[string]any{
"accountNumber": c.accountNumber,
}); err != nil {
return nil, err
}
// Extract rates from the query result
if len(q.Account.Properties) == 0 {
return nil, errors.New("no properties found")
}
// Find the active agreement across all properties
var unitRateForecast []unitRateForecast
for _, property := range q.Account.Properties {
for _, malo := range property.ElectricityMalos {
for _, agreement := range malo.Agreements {
if agreement.IsActive {
unitRateForecast = agreement.UnitRateForecast
break
}
}
if unitRateForecast != nil {
break
}
}
if unitRateForecast != nil {
break
}
}
if unitRateForecast == nil {
return nil, errors.New("no active agreement found")
}
// Convert to RatePeriod slice
var rates []RatePeriod
for _, forecast := range unitRateForecast {
// Extract the rate from the union type
if forecast.UnitRateInformation.TimeOfUseProductUnitRateInformation.Rates != nil {
for _, rate := range forecast.UnitRateInformation.TimeOfUseProductUnitRateInformation.Rates {
// 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)
}
rates = append(rates, RatePeriod{
ValidFrom: forecast.ValidFrom,
ValidTo: forecast.ValidTo,
LatestGrossUnitRateCentsPerKwh: grossRate,
NetUnitRateCentsPerKwh: netRate,
})
}
}
}
if len(rates) == 0 {
return nil, errors.New("no rate forecast available")
}
c.log.TRACE.Printf("GraphQL: retrieved %d rate periods", len(rates))
return rates, nil
}
// parseFloat parses a string to float64, handling the specific format used by Octopus API
func parseFloat(s string) (float64, error) {
return strconv.ParseFloat(s, 64)
}

View file

@ -0,0 +1,58 @@
package graphql
import "time"
// krakenTokenAuthentication is a representation of a GraphQL query for obtaining a Kraken API token.
type krakenTokenAuthentication struct {
ObtainKrakenToken struct {
Token string
} `graphql:"obtainKrakenToken(input: {email: $email, password: $password})"`
}
// getDayAheadPrices queries the day-ahead price forecast
type getDayAheadPrices struct {
Account struct {
Properties []struct {
ElectricityMalos []struct {
Agreements []struct {
IsActive bool
UnitRateForecast []unitRateForecast
Product product
}
}
}
} `graphql:"account(accountNumber: $accountNumber)"`
}
type product struct {
Code string
IsTimeOfUse bool
Term int
}
type unitRateForecast struct {
ValidFrom time.Time
ValidTo time.Time
UnitRateInformation unitRateInformation
}
type unitRateInformation struct {
TimeOfUseProductUnitRateInformation timeOfUseProductUnitRateInformation `graphql:"... on TimeOfUseProductUnitRateInformation"`
}
type timeOfUseProductUnitRateInformation struct {
Rates []rate
}
type rate struct {
NetUnitRateCentsPerKwh string `graphql:"netUnitRateCentsPerKwh"`
LatestGrossUnitRateCentsPerKwh string `graphql:"latestGrossUnitRateCentsPerKwh"`
}
// RatePeriod represents a rate period with pricing information
type RatePeriod struct {
ValidFrom time.Time
ValidTo time.Time
NetUnitRateCentsPerKwh float64
LatestGrossUnitRateCentsPerKwh float64
}

49
tariff/octopusde_test.go Normal file
View file

@ -0,0 +1,49 @@
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",
"accountNumber": "A-12345678",
}
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",
"accountNumber": "A-12345678",
}
_, err = buildOctopusDeFromConfig(missingEmailConfig)
require.Error(t, err)
require.Contains(t, err.Error(), "missing email")
missingPasswordConfig := map[string]any{
"email": "test@example.com",
"accountNumber": "A-12345678",
}
_, err = buildOctopusDeFromConfig(missingPasswordConfig)
require.Error(t, err)
require.Contains(t, err.Error(), "missing password")
missingAccountNumberConfig := map[string]any{
"email": "test@example.com",
"password": "testpassword",
}
_, err = buildOctopusDeFromConfig(missingAccountNumberConfig)
require.Error(t, err)
require.Contains(t, err.Error(), "missing account number")
}

View file

@ -0,0 +1,46 @@
template: octopus-de
products:
- brand: Octopus Energy Germany
description:
generic: Deutschland
requirements:
evcc: ["skiptest"]
countries: ["DE"]
group: price
params:
- name: email
type: string
required: true
example: "user@example.com"
description:
en: Email Address
de: E-Mail-Adresse
help:
de: "Die E-Mail-Adresse Ihres Octopus Energy Kontos."
en: "The email address of your Octopus Energy account."
- name: password
type: string
required: true
mask: true
example: "secret"
description:
en: Password
de: Passwort
help:
de: "Das Passwort Ihres Octopus Energy Kontos."
en: "The password of your Octopus Energy account."
- name: accountNumber
type: string
required: true
example: "A-XX345678"
description:
en: Account Number
de: Kundennummer
help:
de: "Ihre Octopus Energy Kundennummer (z.B. A-12345678)."
en: "Your Octopus Energy account number (e.g., A-12345678)."
render: |
type: octopus-de
accountNumber: {{ .accountNumber }}
email: {{ .email }}
password: {{ .password }}