Add Octopus Energy Italy tariff (#31505)
This commit is contained in:
parent
8a771d5fb3
commit
de976b7aa2
13 changed files with 360 additions and 67 deletions
|
|
@ -10,17 +10,17 @@ import (
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
octoDeGql "github.com/evcc-io/evcc/tariff/octopusde/graphql"
|
krakengql "github.com/evcc-io/evcc/tariff/octopuskraken/graphql"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
"github.com/jinzhu/now"
|
"github.com/jinzhu/now"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrAuthFailed re-exports the GraphQL auth-failure sentinel for use in tests.
|
// ErrAuthFailed re-exports the GraphQL auth-failure sentinel for use in tests.
|
||||||
var ErrAuthFailed = octoDeGql.ErrAuthFailed
|
var ErrAuthFailed = krakengql.ErrAuthFailed
|
||||||
|
|
||||||
type OctopusDe struct {
|
type OctopusDe struct {
|
||||||
log *util.Logger
|
log *util.Logger
|
||||||
gqlClient *octoDeGql.OctopusDeGraphQLClient
|
gqlClient *krakengql.Client
|
||||||
data *util.Monitor[api.Rates]
|
data *util.Monitor[api.Rates]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,7 +73,7 @@ func buildOctopusDeFromConfig(other map[string]any) (*OctopusDe, error) {
|
||||||
log := util.NewLogger("octopus-de")
|
log := util.NewLogger("octopus-de")
|
||||||
|
|
||||||
// Create GraphQL client
|
// Create GraphQL client
|
||||||
gqlClient, err := octoDeGql.NewClient(log, cc.Email, cc.Password, cc.AccountNumber)
|
gqlClient, err := krakengql.NewClient(log, krakengql.BaseURI, cc.Email, cc.Password, cc.AccountNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -96,7 +96,7 @@ func (t *OctopusDe) run(done chan error) {
|
||||||
if err := backoff.Retry(func() error {
|
if err := backoff.Retry(func() error {
|
||||||
agr, err := t.gqlClient.ActiveAgreement()
|
agr, err := t.gqlClient.ActiveAgreement()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, octoDeGql.ErrAuthFailed) {
|
if errors.Is(err, krakengql.ErrAuthFailed) {
|
||||||
return backoff.Permanent(err)
|
return backoff.Permanent(err)
|
||||||
}
|
}
|
||||||
return backoffPermanentError(err)
|
return backoffPermanentError(err)
|
||||||
|
|
@ -157,7 +157,7 @@ type RatePeriod struct {
|
||||||
// ratesForAgreement determines the tariff type of agr and returns the corresponding
|
// ratesForAgreement determines the tariff type of agr and returns the corresponding
|
||||||
// rate periods. It supports Dynamic, Simple, and Time-of-Use tariffs.
|
// rate periods. It supports Dynamic, Simple, and Time-of-Use tariffs.
|
||||||
// now is used as the reference time for horizon computation and ToU rate generation.
|
// now is used as the reference time for horizon computation and ToU rate generation.
|
||||||
func ratesForAgreement(agr octoDeGql.Agreement, now time.Time) ([]RatePeriod, error) {
|
func ratesForAgreement(agr krakengql.Agreement, now time.Time) ([]RatePeriod, error) {
|
||||||
horizon, err := computeHorizon(now, agr, planDays)
|
horizon, err := computeHorizon(now, agr, planDays)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -188,7 +188,7 @@ func ratesForAgreement(agr octoDeGql.Agreement, now time.Time) ([]RatePeriod, er
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractForecastRates converts dynamic-tariff UnitRateForecast entries into RatePeriod values.
|
// extractForecastRates converts dynamic-tariff UnitRateForecast entries into RatePeriod values.
|
||||||
func extractForecastRates(forecasts []octoDeGql.UnitRateForecast, horizon planningHorizon) ([]RatePeriod, error) {
|
func extractForecastRates(forecasts []krakengql.UnitRateForecast, horizon planningHorizon) ([]RatePeriod, error) {
|
||||||
var rates []RatePeriod
|
var rates []RatePeriod
|
||||||
for _, forecast := range forecasts {
|
for _, forecast := range forecasts {
|
||||||
info := forecast.UnitRateInformation
|
info := forecast.UnitRateInformation
|
||||||
|
|
@ -229,7 +229,7 @@ func extractForecastRates(forecasts []octoDeGql.UnitRateForecast, horizon planni
|
||||||
|
|
||||||
// simpleRates converts a SimpleProductUnitRateInformation into a single RatePeriod
|
// simpleRates converts a SimpleProductUnitRateInformation into a single RatePeriod
|
||||||
// ending at horizon, the pre-computed planning horizon.
|
// ending at horizon, the pre-computed planning horizon.
|
||||||
func simpleRates(info octoDeGql.SimpleProductUnitRateInformation, horizon planningHorizon) ([]RatePeriod, error) {
|
func simpleRates(info krakengql.SimpleProductUnitRateInformation, horizon planningHorizon) ([]RatePeriod, error) {
|
||||||
netRate, err := parseFloat(info.NetUnitRateCentsPerKwh)
|
netRate, err := parseFloat(info.NetUnitRateCentsPerKwh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse net unit rate: %w", err)
|
return nil, fmt.Errorf("failed to parse net unit rate: %w", err)
|
||||||
|
|
@ -247,7 +247,7 @@ func simpleRates(info octoDeGql.SimpleProductUnitRateInformation, horizon planni
|
||||||
}
|
}
|
||||||
|
|
||||||
// computeHorizon returns the planning window, capped by the validity of the agreement.
|
// computeHorizon returns the planning window, capped by the validity of the agreement.
|
||||||
func computeHorizon(now time.Time, agreement octoDeGql.Agreement, planDays int) (planningHorizon, error) {
|
func computeHorizon(now time.Time, agreement krakengql.Agreement, planDays int) (planningHorizon, error) {
|
||||||
start := now
|
start := now
|
||||||
end := now.AddDate(0, 0, planDays)
|
end := now.AddDate(0, 0, planDays)
|
||||||
|
|
||||||
|
|
@ -289,7 +289,7 @@ func computePeriod(day time.Time, fromOffset, toOffset time.Duration) (time.Time
|
||||||
|
|
||||||
// ratePeriodsForDay expands one TouRate slot for a single day into RatePeriods,
|
// ratePeriodsForDay expands one TouRate slot for a single day into RatePeriods,
|
||||||
// filtered to the window [now, horizon].
|
// filtered to the window [now, horizon].
|
||||||
func ratePeriodsForDay(day time.Time, horizon planningHorizon, r octoDeGql.TouRate) ([]RatePeriod, error) {
|
func ratePeriodsForDay(day time.Time, horizon planningHorizon, r krakengql.TouRate) ([]RatePeriod, error) {
|
||||||
grossRate, err := parseFloat(r.LatestGrossUnitRateCentsPerKwh)
|
grossRate, err := parseFloat(r.LatestGrossUnitRateCentsPerKwh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse gross unit rate for slot %q: %w", r.TimeslotName, err)
|
return nil, fmt.Errorf("failed to parse gross unit rate for slot %q: %w", r.TimeslotName, err)
|
||||||
|
|
@ -328,7 +328,7 @@ func ratePeriodsForDay(day time.Time, horizon planningHorizon, r octoDeGql.TouRa
|
||||||
// generateTouRates produces rate periods for a Time of Use tariff
|
// generateTouRates produces rate periods for a Time of Use tariff
|
||||||
// by repeating each timeslot's activation window for each day in the planning horizon.
|
// by repeating each timeslot's activation window for each day in the planning horizon.
|
||||||
// now is the reference time for filtering past periods; horizon is the pre-computed end of the window.
|
// now is the reference time for filtering past periods; horizon is the pre-computed end of the window.
|
||||||
func generateTouRates(rates []octoDeGql.TouRate, horizon planningHorizon) ([]RatePeriod, error) {
|
func generateTouRates(rates []krakengql.TouRate, horizon planningHorizon) ([]RatePeriod, error) {
|
||||||
startDay := now.With(horizon.start).BeginningOfDay()
|
startDay := now.With(horizon.start).BeginningOfDay()
|
||||||
|
|
||||||
var result []RatePeriod
|
var result []RatePeriod
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
octoDeGql "github.com/evcc-io/evcc/tariff/octopusde/graphql"
|
krakengql "github.com/evcc-io/evcc/tariff/octopuskraken/graphql"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -52,19 +52,19 @@ var t0 = time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||||
// dynamicAgreement builds an agreement that uses a dynamic tariff:
|
// dynamicAgreement builds an agreement that uses a dynamic tariff:
|
||||||
// the unitRateForecast field contains two half-hour forecast slots with
|
// the unitRateForecast field contains two half-hour forecast slots with
|
||||||
// per-slot prices stored in TimeOfUseProductUnitRateInformation.
|
// per-slot prices stored in TimeOfUseProductUnitRateInformation.
|
||||||
func dynamicAgreement() octoDeGql.Agreement {
|
func dynamicAgreement() krakengql.Agreement {
|
||||||
t1 := t0
|
t1 := t0
|
||||||
t2 := t0.Add(15 * time.Minute)
|
t2 := t0.Add(15 * time.Minute)
|
||||||
t3 := t2.Add(15 * time.Minute)
|
t3 := t2.Add(15 * time.Minute)
|
||||||
return octoDeGql.Agreement{
|
return krakengql.Agreement{
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
UnitRateForecast: []octoDeGql.UnitRateForecast{
|
UnitRateForecast: []krakengql.UnitRateForecast{
|
||||||
{
|
{
|
||||||
ValidFrom: t1,
|
ValidFrom: t1,
|
||||||
ValidTo: t2,
|
ValidTo: t2,
|
||||||
UnitRateInformation: octoDeGql.ForecastUnitRateInformation{
|
UnitRateInformation: krakengql.ForecastUnitRateInformation{
|
||||||
TimeOfUseProductUnitRateInformation: octoDeGql.TimeOfUseProductUnitRateInformation{
|
TimeOfUseProductUnitRateInformation: krakengql.TimeOfUseProductUnitRateInformation{
|
||||||
Rates: []octoDeGql.Rate{
|
Rates: []krakengql.Rate{
|
||||||
{NetUnitRateCentsPerKwh: "10.50", LatestGrossUnitRateCentsPerKwh: "12.495"},
|
{NetUnitRateCentsPerKwh: "10.50", LatestGrossUnitRateCentsPerKwh: "12.495"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -73,9 +73,9 @@ func dynamicAgreement() octoDeGql.Agreement {
|
||||||
{
|
{
|
||||||
ValidFrom: t2,
|
ValidFrom: t2,
|
||||||
ValidTo: t3,
|
ValidTo: t3,
|
||||||
UnitRateInformation: octoDeGql.ForecastUnitRateInformation{
|
UnitRateInformation: krakengql.ForecastUnitRateInformation{
|
||||||
TimeOfUseProductUnitRateInformation: octoDeGql.TimeOfUseProductUnitRateInformation{
|
TimeOfUseProductUnitRateInformation: krakengql.TimeOfUseProductUnitRateInformation{
|
||||||
Rates: []octoDeGql.Rate{
|
Rates: []krakengql.Rate{
|
||||||
{NetUnitRateCentsPerKwh: "8.00", LatestGrossUnitRateCentsPerKwh: "9.52"},
|
{NetUnitRateCentsPerKwh: "8.00", LatestGrossUnitRateCentsPerKwh: "9.52"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -86,13 +86,13 @@ func dynamicAgreement() octoDeGql.Agreement {
|
||||||
}
|
}
|
||||||
|
|
||||||
// simpleAgreement builds an agreement with a single fixed rate covering one year.
|
// simpleAgreement builds an agreement with a single fixed rate covering one year.
|
||||||
func simpleAgreement() octoDeGql.Agreement {
|
func simpleAgreement() krakengql.Agreement {
|
||||||
return octoDeGql.Agreement{
|
return krakengql.Agreement{
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
ValidFrom: t0,
|
ValidFrom: t0,
|
||||||
ValidTo: t0.AddDate(1, 0, 0),
|
ValidTo: t0.AddDate(1, 0, 0),
|
||||||
UnitRateInformation: octoDeGql.AgreementUnitRateInformation{
|
UnitRateInformation: krakengql.AgreementUnitRateInformation{
|
||||||
SimpleProductUnitRateInformation: octoDeGql.SimpleProductUnitRateInformation{
|
SimpleProductUnitRateInformation: krakengql.SimpleProductUnitRateInformation{
|
||||||
NetUnitRateCentsPerKwh: "25.00",
|
NetUnitRateCentsPerKwh: "25.00",
|
||||||
LatestGrossUnitRateCentsPerKwh: "29.75",
|
LatestGrossUnitRateCentsPerKwh: "29.75",
|
||||||
},
|
},
|
||||||
|
|
@ -103,17 +103,17 @@ func simpleAgreement() octoDeGql.Agreement {
|
||||||
// touAgreement builds an agreement with a two-slot time-of-use tariff:
|
// touAgreement builds an agreement with a two-slot time-of-use tariff:
|
||||||
// - Day rate 06:00–22:00
|
// - Day rate 06:00–22:00
|
||||||
// - Night rate 22:00–06:00 (wraps past midnight)
|
// - Night rate 22:00–06:00 (wraps past midnight)
|
||||||
func touAgreement() octoDeGql.Agreement {
|
func touAgreement() krakengql.Agreement {
|
||||||
return octoDeGql.Agreement{
|
return krakengql.Agreement{
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
UnitRateInformation: octoDeGql.AgreementUnitRateInformation{
|
UnitRateInformation: krakengql.AgreementUnitRateInformation{
|
||||||
TimeOfUseProductUnitRateInformation: octoDeGql.TouAgreementUnitRateInformation{
|
TimeOfUseProductUnitRateInformation: krakengql.TouAgreementUnitRateInformation{
|
||||||
Rates: []octoDeGql.TouRate{
|
Rates: []krakengql.TouRate{
|
||||||
{
|
{
|
||||||
TimeslotName: "Day",
|
TimeslotName: "Day",
|
||||||
NetUnitRateCentsPerKwh: "30.00",
|
NetUnitRateCentsPerKwh: "30.00",
|
||||||
LatestGrossUnitRateCentsPerKwh: "35.70",
|
LatestGrossUnitRateCentsPerKwh: "35.70",
|
||||||
TimeslotActivationRules: []octoDeGql.TimeslotActivationRule{
|
TimeslotActivationRules: []krakengql.TimeslotActivationRule{
|
||||||
{ActiveFromTime: "06:00:00", ActiveToTime: "22:00:00"},
|
{ActiveFromTime: "06:00:00", ActiveToTime: "22:00:00"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -121,7 +121,7 @@ func touAgreement() octoDeGql.Agreement {
|
||||||
TimeslotName: "Night",
|
TimeslotName: "Night",
|
||||||
NetUnitRateCentsPerKwh: "15.00",
|
NetUnitRateCentsPerKwh: "15.00",
|
||||||
LatestGrossUnitRateCentsPerKwh: "17.85",
|
LatestGrossUnitRateCentsPerKwh: "17.85",
|
||||||
TimeslotActivationRules: []octoDeGql.TimeslotActivationRule{
|
TimeslotActivationRules: []krakengql.TimeslotActivationRule{
|
||||||
// 22:00 → 06:00 wraps past midnight
|
// 22:00 → 06:00 wraps past midnight
|
||||||
{ActiveFromTime: "22:00:00", ActiveToTime: "06:00:00"},
|
{ActiveFromTime: "22:00:00", ActiveToTime: "06:00:00"},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
134
tariff/octopusit.go
Normal file
134
tariff/octopusit.go
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
package tariff
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/evcc-io/evcc/api"
|
||||||
|
krakengql "github.com/evcc-io/evcc/tariff/octopuskraken/graphql"
|
||||||
|
"github.com/evcc-io/evcc/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OctopusIt is an api.Tariff implementation for Octopus Energy Italy, reusing
|
||||||
|
// the Germany implementation's Kraken GraphQL client and rate computation.
|
||||||
|
type OctopusIt struct {
|
||||||
|
log *util.Logger
|
||||||
|
gqlClient *krakengql.Client
|
||||||
|
data *util.Monitor[api.Rates]
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ api.Tariff = (*OctopusIt)(nil)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registry.Add("octopus-it", NewOctopusItFromConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOctopusItFromConfig creates the tariff provider from the given config map, and runs it.
|
||||||
|
func NewOctopusItFromConfig(other map[string]any) (api.Tariff, error) {
|
||||||
|
t, err := buildOctopusItFromConfig(other)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return runOrError(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOctopusItFromConfig creates the Tariff provider from the given config map.
|
||||||
|
// Split out to allow for testing.
|
||||||
|
func buildOctopusItFromConfig(other map[string]any) (*OctopusIt, error) {
|
||||||
|
var cc struct {
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
AccountNumber string
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
log := util.NewLogger("octopus-it")
|
||||||
|
|
||||||
|
gqlClient, err := krakengql.NewClient(log, krakengql.ItBaseURI, cc.Email, cc.Password, cc.AccountNumber)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &OctopusIt{
|
||||||
|
log: log,
|
||||||
|
gqlClient: gqlClient,
|
||||||
|
data: util.NewMonitor[api.Rates](2 * time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *OctopusIt) run(done chan error) {
|
||||||
|
var once sync.Once
|
||||||
|
|
||||||
|
for tick := time.Tick(time.Hour); ; <-tick {
|
||||||
|
var rates []RatePeriod
|
||||||
|
|
||||||
|
if err := backoff.Retry(func() error {
|
||||||
|
agr, err := t.gqlClient.ActiveAgreement()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, krakengql.ErrAuthFailed) {
|
||||||
|
return backoff.Permanent(err)
|
||||||
|
}
|
||||||
|
return backoffPermanentError(err)
|
||||||
|
}
|
||||||
|
rates, err = ratesForAgreement(agr, time.Now())
|
||||||
|
return backoffPermanentError(err)
|
||||||
|
}, bo()); err != nil {
|
||||||
|
if reportError(&once, done, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.log.ERROR.Printf("failed to fetch unit rate forecast: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make(api.Rates, 0, len(rates))
|
||||||
|
for _, r := range rates {
|
||||||
|
ar := api.Rate{
|
||||||
|
Start: r.ValidFrom,
|
||||||
|
End: r.ValidTo,
|
||||||
|
// Convert from cents per kWh to € per kWh (divide by 100)
|
||||||
|
// Use gross price (including tax) as that's what the customer pays
|
||||||
|
Value: r.GrossUnitRateCentsPerKwh / 100,
|
||||||
|
}
|
||||||
|
data = append(data, ar)
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeRates(t.data, data)
|
||||||
|
once.Do(func() { close(done) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rates implements the api.Tariff interface
|
||||||
|
func (t *OctopusIt) 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 *OctopusIt) Type() api.TariffType {
|
||||||
|
return api.TariffTypePriceForecast
|
||||||
|
}
|
||||||
43
tariff/octopusit_test.go
Normal file
43
tariff/octopusit_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
package tariff
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOctopusItConfigParse(t *testing.T) {
|
||||||
|
validConfig := map[string]any{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "testpassword",
|
||||||
|
"accountNumber": "A-12345678",
|
||||||
|
}
|
||||||
|
|
||||||
|
tariff, err := buildOctopusItFromConfig(validConfig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, tariff)
|
||||||
|
|
||||||
|
missingEmailConfig := map[string]any{
|
||||||
|
"password": "testpassword",
|
||||||
|
"accountNumber": "A-12345678",
|
||||||
|
}
|
||||||
|
_, err = buildOctopusItFromConfig(missingEmailConfig)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "missing email")
|
||||||
|
|
||||||
|
missingPasswordConfig := map[string]any{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"accountNumber": "A-12345678",
|
||||||
|
}
|
||||||
|
_, err = buildOctopusItFromConfig(missingPasswordConfig)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "missing password")
|
||||||
|
|
||||||
|
missingAccountNumberConfig := map[string]any{
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "testpassword",
|
||||||
|
}
|
||||||
|
_, err = buildOctopusItFromConfig(missingAccountNumberConfig)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "missing account number")
|
||||||
|
}
|
||||||
|
|
@ -17,17 +17,22 @@ import (
|
||||||
// The implementation in this file follows the published example at https://octopusenergy.de/blog/wohnen/dynamisch-sparen-per-api
|
// 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/"
|
const BaseURI = "https://api.oeg-kraken.energy/v1/graphql/"
|
||||||
|
|
||||||
// OctopusDeGraphQLClient provides an interface for communicating with Octopus Energy Germany's Kraken platform.
|
// ItBaseURI is Octopus Energy Italy's Kraken API root, the same platform under its own domain.
|
||||||
type OctopusDeGraphQLClient struct {
|
const ItBaseURI = "https://api.oeit-kraken.energy/v1/graphql/"
|
||||||
|
|
||||||
|
// Client provides an interface for communicating with an Octopus Energy Kraken platform instance.
|
||||||
|
type Client struct {
|
||||||
log *util.Logger
|
log *util.Logger
|
||||||
*graphql.Client
|
*graphql.Client
|
||||||
accountNumber string
|
accountNumber string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient returns a new, authenticated instance of OctopusDeGraphQLClient.
|
// NewClient returns a new, authenticated instance for the given Kraken instance
|
||||||
func NewClient(log *util.Logger, email, password, accountNumber string) (*OctopusDeGraphQLClient, error) {
|
// (other regional Octopus companies run the same platform under their own baseURI).
|
||||||
|
func NewClient(log *util.Logger, baseURI, email, password, accountNumber string) (*Client, error) {
|
||||||
ts := oauth2.ReuseTokenSource(nil, &tokenSource{
|
ts := oauth2.ReuseTokenSource(nil, &tokenSource{
|
||||||
log: log,
|
log: log,
|
||||||
|
baseURI: baseURI,
|
||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
})
|
})
|
||||||
|
|
@ -46,17 +51,17 @@ func NewClient(log *util.Logger, email, password, accountNumber string) (*Octopu
|
||||||
Base: cli.Transport,
|
Base: cli.Transport,
|
||||||
}
|
}
|
||||||
|
|
||||||
gq := &OctopusDeGraphQLClient{
|
gq := &Client{
|
||||||
log: log,
|
log: log,
|
||||||
accountNumber: accountNumber,
|
accountNumber: accountNumber,
|
||||||
Client: graphql.NewClient(BaseURI, cli),
|
Client: graphql.NewClient(baseURI, cli),
|
||||||
}
|
}
|
||||||
|
|
||||||
return gq, nil
|
return gq, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActiveAgreement queries the Kraken API and returns the active electricity supply agreement.
|
// ActiveAgreement queries the Kraken API and returns the active electricity supply agreement.
|
||||||
func (c *OctopusDeGraphQLClient) ActiveAgreement() (Agreement, error) {
|
func (c *Client) ActiveAgreement() (Agreement, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|
@ -19,6 +19,7 @@ var ErrAuthFailed = errors.New("authentication failed")
|
||||||
|
|
||||||
type tokenSource struct {
|
type tokenSource struct {
|
||||||
log *util.Logger
|
log *util.Logger
|
||||||
|
baseURI string
|
||||||
email, password string
|
email, password string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,7 +33,7 @@ func (ts *tokenSource) Token() (*oauth2.Token, error) {
|
||||||
|
|
||||||
// Create a temporary client without authentication for the token request
|
// Create a temporary client without authentication for the token request
|
||||||
cli := request.NewClient(ts.log)
|
cli := request.NewClient(ts.log)
|
||||||
tempClient := graphql.NewClient(BaseURI, cli)
|
tempClient := graphql.NewClient(ts.baseURI, cli)
|
||||||
|
|
||||||
var q krakenTokenAuthentication
|
var q krakenTokenAuthentication
|
||||||
if err := tempClient.Mutate(ctx, &q, map[string]any{
|
if err := tempClient.Mutate(ctx, &q, map[string]any{
|
||||||
43
templates/definition/tariff/octopus-it.yaml
Normal file
43
templates/definition/tariff/octopus-it.yaml
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
template: octopus-it
|
||||||
|
products:
|
||||||
|
- brand: Octopus Energy
|
||||||
|
description:
|
||||||
|
de: Italien
|
||||||
|
en: Italy
|
||||||
|
requirements:
|
||||||
|
evcc: ["skiptest"]
|
||||||
|
countries: ["IT"]
|
||||||
|
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
|
||||||
|
example: "secret"
|
||||||
|
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-it
|
||||||
|
accountNumber: {{ .accountNumber }}
|
||||||
|
email: {{ .email }}
|
||||||
|
password: {{ .password }}
|
||||||
53
templates/definition/vehicle/octopus-it.yaml
Normal file
53
templates/definition/vehicle/octopus-it.yaml
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
template: octopus-it
|
||||||
|
products:
|
||||||
|
- brand: Octopus Energy
|
||||||
|
description:
|
||||||
|
de: Italien
|
||||||
|
en: Italy
|
||||||
|
countries: ["IT"]
|
||||||
|
group: generic
|
||||||
|
requirements:
|
||||||
|
evcc: ["skiptest"]
|
||||||
|
description:
|
||||||
|
de: |
|
||||||
|
Liest Fahrzeugdaten (Ladestand, Zielladestand) über die Octopus Energy Italy Kraken API.
|
||||||
|
Das Fahrzeug bzw. die Wallbox muss im Octopus-Konto (Intelligent Octopus) verbunden sein.
|
||||||
|
en: |
|
||||||
|
Reads vehicle data (state of charge, target soc) via the Octopus Energy Italy Kraken API.
|
||||||
|
The vehicle or charge point must be connected in the Octopus account (Intelligent Octopus).
|
||||||
|
params:
|
||||||
|
- preset: vehicle-common
|
||||||
|
- preset: vehicle-online
|
||||||
|
- name: welcomecharge
|
||||||
|
advanced: true
|
||||||
|
- name: email
|
||||||
|
required: true
|
||||||
|
- name: password
|
||||||
|
required: true
|
||||||
|
- name: accountnumber
|
||||||
|
example: "A-12345678"
|
||||||
|
description:
|
||||||
|
en: Account Number
|
||||||
|
de: Kundennummer
|
||||||
|
help:
|
||||||
|
en: Account when several are linked (e.g. A-12345678).
|
||||||
|
de: Konto, wenn mehrere verknüpft sind (z.B. A-12345678).
|
||||||
|
- name: device
|
||||||
|
description:
|
||||||
|
en: Vehicle
|
||||||
|
de: Fahrzeug
|
||||||
|
service: octopus-it/devices?email={email}&password={password}&accountnumber={accountnumber}
|
||||||
|
help:
|
||||||
|
en: Vehicle when the account has more than one.
|
||||||
|
de: Fahrzeug, wenn das Konto mehrere besitzt.
|
||||||
|
- name: cache
|
||||||
|
default: 15m
|
||||||
|
render: |
|
||||||
|
type: octopus-it
|
||||||
|
{{- include "vehicle-common" . }}
|
||||||
|
email: {{ .email }}
|
||||||
|
password: {{ .password }}
|
||||||
|
accountnumber: {{ .accountnumber }}
|
||||||
|
device: {{ .device }}
|
||||||
|
cache: {{ .cache }}
|
||||||
|
{{ include "vehicle-features" . }}
|
||||||
|
|
@ -7,17 +7,17 @@ import (
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
"github.com/evcc-io/evcc/vehicle/octopusde"
|
"github.com/evcc-io/evcc/vehicle/octopuskraken"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OctopusDe is an api.Vehicle implementation for the Octopus Energy Germany Kraken API
|
// OctopusDe is an api.Vehicle implementation for the Octopus Energy Germany Kraken API
|
||||||
type OctopusDe struct {
|
type OctopusDe struct {
|
||||||
*embed
|
*embed
|
||||||
*octopusde.API
|
*octopuskraken.API
|
||||||
account string
|
account string
|
||||||
device string
|
device string
|
||||||
deviceID string
|
deviceID string
|
||||||
dataG func() (octopusde.Device, error)
|
dataG func() (octopuskraken.Device, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -47,7 +47,7 @@ func NewOctopusDeFromConfig(other map[string]any) (api.Vehicle, error) {
|
||||||
|
|
||||||
log := util.NewLogger("octopus-de").Redact(cc.Email, cc.Password)
|
log := util.NewLogger("octopus-de").Redact(cc.Email, cc.Password)
|
||||||
|
|
||||||
api, err := octopusde.NewAPI(log, cc.Email, cc.Password)
|
api, err := octopuskraken.NewAPI(log, octopuskraken.BaseURI, cc.Email, cc.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -66,16 +66,16 @@ func NewOctopusDeFromConfig(other map[string]any) (api.Vehicle, error) {
|
||||||
|
|
||||||
// status fetches the live state of the configured device, resolving the account
|
// status fetches the live state of the configured device, resolving the account
|
||||||
// and matching device on first use.
|
// and matching device on first use.
|
||||||
func (v *OctopusDe) status() (octopusde.Device, error) {
|
func (v *OctopusDe) status() (octopuskraken.Device, error) {
|
||||||
account, err := v.Account(v.account)
|
account, err := v.Account(v.account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return octopusde.Device{}, err
|
return octopuskraken.Device{}, err
|
||||||
}
|
}
|
||||||
v.account = account
|
v.account = account
|
||||||
|
|
||||||
devices, err := v.Devices(account)
|
devices, err := v.Devices(account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return octopusde.Device{}, err
|
return octopuskraken.Device{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, d := range devices {
|
for _, d := range devices {
|
||||||
|
|
@ -94,9 +94,9 @@ func (v *OctopusDe) status() (octopusde.Device, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if v.device == "" {
|
if v.device == "" {
|
||||||
return octopusde.Device{}, api.ErrNotAvailable
|
return octopuskraken.Device{}, api.ErrNotAvailable
|
||||||
}
|
}
|
||||||
return octopusde.Device{}, fmt.Errorf("device not found: %s", v.device)
|
return octopuskraken.Device{}, fmt.Errorf("device not found: %s", v.device)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soc implements the api.Vehicle interface
|
// Soc implements the api.Vehicle interface
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package octopusde
|
package octopuskraken
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -6,25 +6,31 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
octoDeGql "github.com/evcc-io/evcc/tariff/octopusde/graphql"
|
krakengql "github.com/evcc-io/evcc/tariff/octopuskraken/graphql"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// API is the Octopus Energy Germany Kraken client for vehicle data. It reuses the
|
// BaseURI re-exports the tariff graphql package's Kraken API root.
|
||||||
// authenticated Kraken GraphQL client from the tariff implementation so the JWT
|
const BaseURI = krakengql.BaseURI
|
||||||
// token source and auth transport are not duplicated.
|
|
||||||
|
// ItBaseURI re-exports the tariff graphql package's Italian Kraken API root.
|
||||||
|
const ItBaseURI = krakengql.ItBaseURI
|
||||||
|
|
||||||
|
// API is the Octopus Energy Kraken client for vehicle data, reusing the
|
||||||
|
// authenticated Kraken GraphQL client from the tariff implementation.
|
||||||
type API struct {
|
type API struct {
|
||||||
*octoDeGql.OctopusDeGraphQLClient
|
*krakengql.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPI creates a Kraken API client authenticated via the given credentials.
|
// NewAPI creates a Kraken API client for the given Kraken instance (other
|
||||||
func NewAPI(log *util.Logger, email, password string) (*API, error) {
|
// regional Octopus companies run the same platform under their own baseURI).
|
||||||
|
func NewAPI(log *util.Logger, baseURI, email, password string) (*API, error) {
|
||||||
// the account number is discovered on demand and not needed for the shared client
|
// the account number is discovered on demand and not needed for the shared client
|
||||||
client, err := octoDeGql.NewClient(log, email, password, "")
|
client, err := krakengql.NewClient(log, baseURI, email, password, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &API{OctopusDeGraphQLClient: client}, nil
|
return &API{Client: client}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// krakenAccounts lists the accounts accessible to the authenticated user.
|
// krakenAccounts lists the accounts accessible to the authenticated user.
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package octopusde
|
package octopuskraken
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package octopusde
|
package octopuskraken
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -10,15 +10,23 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
mux := http.NewServeMux()
|
register("octopus-de", BaseURI)
|
||||||
mux.HandleFunc("GET /devices", getDevices)
|
register("octopus-it", ItBaseURI)
|
||||||
|
}
|
||||||
|
|
||||||
service.Register("octopus-de", mux)
|
// register wires the device-listing endpoint for a Kraken instance under the given service name.
|
||||||
|
func register(name, baseURI string) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /devices", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
getDevices(w, req, name, baseURI)
|
||||||
|
})
|
||||||
|
|
||||||
|
service.Register(name, mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDevices lists the ids of the SmartFlex devices in the account, driving
|
// getDevices lists the ids of the SmartFlex devices in the account, driving
|
||||||
// device selection in the template.
|
// device selection in the template.
|
||||||
func getDevices(w http.ResponseWriter, req *http.Request) {
|
func getDevices(w http.ResponseWriter, req *http.Request, name, baseURI string) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
q := req.URL.Query()
|
q := req.URL.Query()
|
||||||
|
|
@ -31,8 +39,8 @@ func getDevices(w http.ResponseWriter, req *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log := util.NewLogger("octopus-de").Redact(email, password)
|
log := util.NewLogger(name).Redact(email, password)
|
||||||
api, err := NewAPI(log, email, password)
|
api, err := NewAPI(log, baseURI, email, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.ERROR.Println(err)
|
log.ERROR.Println(err)
|
||||||
return
|
return
|
||||||
Loading…
Add table
Add a link
Reference in a new issue