diff --git a/tariff/octopusde.go b/tariff/octopusde.go new file mode 100644 index 000000000..aa8e80699 --- /dev/null +++ b/tariff/octopusde.go @@ -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 +} diff --git a/tariff/octopusde/graphql/api.go b/tariff/octopusde/graphql/api.go new file mode 100644 index 000000000..2f82c8f2b --- /dev/null +++ b/tariff/octopusde/graphql/api.go @@ -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) +} diff --git a/tariff/octopusde/graphql/types.go b/tariff/octopusde/graphql/types.go new file mode 100644 index 000000000..c5e4d4dbd --- /dev/null +++ b/tariff/octopusde/graphql/types.go @@ -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 +} diff --git a/tariff/octopusde_test.go b/tariff/octopusde_test.go new file mode 100644 index 000000000..7d143cf01 --- /dev/null +++ b/tariff/octopusde_test.go @@ -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") +} diff --git a/templates/definition/tariff/octopus-de.yaml b/templates/definition/tariff/octopus-de.yaml new file mode 100644 index 000000000..b88f58d79 --- /dev/null +++ b/templates/definition/tariff/octopus-de.yaml @@ -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 }}