Add Octopus Germany "Dynamic Octopus" tariff (#26102)
This commit is contained in:
parent
cf7f501126
commit
1506de71c8
5 changed files with 482 additions and 0 deletions
139
tariff/octopusde.go
Normal file
139
tariff/octopusde.go
Normal 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
|
||||
}
|
||||
190
tariff/octopusde/graphql/api.go
Normal file
190
tariff/octopusde/graphql/api.go
Normal 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)
|
||||
}
|
||||
58
tariff/octopusde/graphql/types.go
Normal file
58
tariff/octopusde/graphql/types.go
Normal 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
49
tariff/octopusde_test.go
Normal 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")
|
||||
}
|
||||
46
templates/definition/tariff/octopus-de.yaml
Normal file
46
templates/definition/tariff/octopus-de.yaml
Normal 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 }}
|
||||
Loading…
Add table
Add a link
Reference in a new issue