From de976b7aa2932a1a4e249e0f6612e9d13156fb61 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 7 Jul 2026 12:06:08 +0200 Subject: [PATCH] Add Octopus Energy Italy tariff (#31505) --- tariff/octopusde.go | 22 +-- tariff/octopusde_test.go | 42 +++--- tariff/octopusit.go | 134 ++++++++++++++++++ tariff/octopusit_test.go | 43 ++++++ .../graphql/api.go | 19 ++- .../graphql/tokensource.go | 3 +- .../graphql/types.go | 0 templates/definition/tariff/octopus-it.yaml | 43 ++++++ templates/definition/vehicle/octopus-it.yaml | 53 +++++++ vehicle/octopusde.go | 18 +-- vehicle/{octopusde => octopuskraken}/api.go | 26 ++-- .../{octopusde => octopuskraken}/api_test.go | 2 +- .../{octopusde => octopuskraken}/service.go | 22 ++- 13 files changed, 360 insertions(+), 67 deletions(-) create mode 100644 tariff/octopusit.go create mode 100644 tariff/octopusit_test.go rename tariff/{octopusde => octopuskraken}/graphql/api.go (74%) rename tariff/{octopusde => octopuskraken}/graphql/tokensource.go (96%) rename tariff/{octopusde => octopuskraken}/graphql/types.go (100%) create mode 100644 templates/definition/tariff/octopus-it.yaml create mode 100644 templates/definition/vehicle/octopus-it.yaml rename vehicle/{octopusde => octopuskraken}/api.go (82%) rename vehicle/{octopusde => octopuskraken}/api_test.go (98%) rename vehicle/{octopusde => octopuskraken}/service.go (61%) diff --git a/tariff/octopusde.go b/tariff/octopusde.go index 57a98b15a..0e2fc898b 100644 --- a/tariff/octopusde.go +++ b/tariff/octopusde.go @@ -10,17 +10,17 @@ import ( "github.com/cenkalti/backoff/v4" "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/jinzhu/now" ) // ErrAuthFailed re-exports the GraphQL auth-failure sentinel for use in tests. -var ErrAuthFailed = octoDeGql.ErrAuthFailed +var ErrAuthFailed = krakengql.ErrAuthFailed type OctopusDe struct { log *util.Logger - gqlClient *octoDeGql.OctopusDeGraphQLClient + gqlClient *krakengql.Client data *util.Monitor[api.Rates] } @@ -73,7 +73,7 @@ func buildOctopusDeFromConfig(other map[string]any) (*OctopusDe, error) { log := util.NewLogger("octopus-de") // 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 { return nil, err } @@ -96,7 +96,7 @@ func (t *OctopusDe) run(done chan error) { if err := backoff.Retry(func() error { agr, err := t.gqlClient.ActiveAgreement() if err != nil { - if errors.Is(err, octoDeGql.ErrAuthFailed) { + if errors.Is(err, krakengql.ErrAuthFailed) { return backoff.Permanent(err) } return backoffPermanentError(err) @@ -157,7 +157,7 @@ type RatePeriod struct { // ratesForAgreement determines the tariff type of agr and returns the corresponding // 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. -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) if err != nil { 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. -func extractForecastRates(forecasts []octoDeGql.UnitRateForecast, horizon planningHorizon) ([]RatePeriod, error) { +func extractForecastRates(forecasts []krakengql.UnitRateForecast, horizon planningHorizon) ([]RatePeriod, error) { var rates []RatePeriod for _, forecast := range forecasts { info := forecast.UnitRateInformation @@ -229,7 +229,7 @@ func extractForecastRates(forecasts []octoDeGql.UnitRateForecast, horizon planni // simpleRates converts a SimpleProductUnitRateInformation into a single RatePeriod // 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) if err != nil { 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. -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 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, // 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) if err != nil { 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 // 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. -func generateTouRates(rates []octoDeGql.TouRate, horizon planningHorizon) ([]RatePeriod, error) { +func generateTouRates(rates []krakengql.TouRate, horizon planningHorizon) ([]RatePeriod, error) { startDay := now.With(horizon.start).BeginningOfDay() var result []RatePeriod diff --git a/tariff/octopusde_test.go b/tariff/octopusde_test.go index 598f48c60..66de51c2a 100644 --- a/tariff/octopusde_test.go +++ b/tariff/octopusde_test.go @@ -4,7 +4,7 @@ import ( "testing" "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/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: // the unitRateForecast field contains two half-hour forecast slots with // per-slot prices stored in TimeOfUseProductUnitRateInformation. -func dynamicAgreement() octoDeGql.Agreement { +func dynamicAgreement() krakengql.Agreement { t1 := t0 t2 := t0.Add(15 * time.Minute) t3 := t2.Add(15 * time.Minute) - return octoDeGql.Agreement{ + return krakengql.Agreement{ IsActive: true, - UnitRateForecast: []octoDeGql.UnitRateForecast{ + UnitRateForecast: []krakengql.UnitRateForecast{ { ValidFrom: t1, ValidTo: t2, - UnitRateInformation: octoDeGql.ForecastUnitRateInformation{ - TimeOfUseProductUnitRateInformation: octoDeGql.TimeOfUseProductUnitRateInformation{ - Rates: []octoDeGql.Rate{ + UnitRateInformation: krakengql.ForecastUnitRateInformation{ + TimeOfUseProductUnitRateInformation: krakengql.TimeOfUseProductUnitRateInformation{ + Rates: []krakengql.Rate{ {NetUnitRateCentsPerKwh: "10.50", LatestGrossUnitRateCentsPerKwh: "12.495"}, }, }, @@ -73,9 +73,9 @@ func dynamicAgreement() octoDeGql.Agreement { { ValidFrom: t2, ValidTo: t3, - UnitRateInformation: octoDeGql.ForecastUnitRateInformation{ - TimeOfUseProductUnitRateInformation: octoDeGql.TimeOfUseProductUnitRateInformation{ - Rates: []octoDeGql.Rate{ + UnitRateInformation: krakengql.ForecastUnitRateInformation{ + TimeOfUseProductUnitRateInformation: krakengql.TimeOfUseProductUnitRateInformation{ + Rates: []krakengql.Rate{ {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. -func simpleAgreement() octoDeGql.Agreement { - return octoDeGql.Agreement{ +func simpleAgreement() krakengql.Agreement { + return krakengql.Agreement{ IsActive: true, ValidFrom: t0, ValidTo: t0.AddDate(1, 0, 0), - UnitRateInformation: octoDeGql.AgreementUnitRateInformation{ - SimpleProductUnitRateInformation: octoDeGql.SimpleProductUnitRateInformation{ + UnitRateInformation: krakengql.AgreementUnitRateInformation{ + SimpleProductUnitRateInformation: krakengql.SimpleProductUnitRateInformation{ NetUnitRateCentsPerKwh: "25.00", LatestGrossUnitRateCentsPerKwh: "29.75", }, @@ -103,17 +103,17 @@ func simpleAgreement() octoDeGql.Agreement { // touAgreement builds an agreement with a two-slot time-of-use tariff: // - Day rate 06:00–22:00 // - Night rate 22:00–06:00 (wraps past midnight) -func touAgreement() octoDeGql.Agreement { - return octoDeGql.Agreement{ +func touAgreement() krakengql.Agreement { + return krakengql.Agreement{ IsActive: true, - UnitRateInformation: octoDeGql.AgreementUnitRateInformation{ - TimeOfUseProductUnitRateInformation: octoDeGql.TouAgreementUnitRateInformation{ - Rates: []octoDeGql.TouRate{ + UnitRateInformation: krakengql.AgreementUnitRateInformation{ + TimeOfUseProductUnitRateInformation: krakengql.TouAgreementUnitRateInformation{ + Rates: []krakengql.TouRate{ { TimeslotName: "Day", NetUnitRateCentsPerKwh: "30.00", LatestGrossUnitRateCentsPerKwh: "35.70", - TimeslotActivationRules: []octoDeGql.TimeslotActivationRule{ + TimeslotActivationRules: []krakengql.TimeslotActivationRule{ {ActiveFromTime: "06:00:00", ActiveToTime: "22:00:00"}, }, }, @@ -121,7 +121,7 @@ func touAgreement() octoDeGql.Agreement { TimeslotName: "Night", NetUnitRateCentsPerKwh: "15.00", LatestGrossUnitRateCentsPerKwh: "17.85", - TimeslotActivationRules: []octoDeGql.TimeslotActivationRule{ + TimeslotActivationRules: []krakengql.TimeslotActivationRule{ // 22:00 → 06:00 wraps past midnight {ActiveFromTime: "22:00:00", ActiveToTime: "06:00:00"}, }, diff --git a/tariff/octopusit.go b/tariff/octopusit.go new file mode 100644 index 000000000..190dd74a6 --- /dev/null +++ b/tariff/octopusit.go @@ -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 +} diff --git a/tariff/octopusit_test.go b/tariff/octopusit_test.go new file mode 100644 index 000000000..d8b27a3e8 --- /dev/null +++ b/tariff/octopusit_test.go @@ -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") +} diff --git a/tariff/octopusde/graphql/api.go b/tariff/octopuskraken/graphql/api.go similarity index 74% rename from tariff/octopusde/graphql/api.go rename to tariff/octopuskraken/graphql/api.go index 3b3a7d964..713df4581 100644 --- a/tariff/octopusde/graphql/api.go +++ b/tariff/octopuskraken/graphql/api.go @@ -17,17 +17,22 @@ import ( // 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 { +// ItBaseURI is Octopus Energy Italy's Kraken API root, the same platform under its own domain. +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 *graphql.Client accountNumber string } -// NewClient returns a new, authenticated instance of OctopusDeGraphQLClient. -func NewClient(log *util.Logger, email, password, accountNumber string) (*OctopusDeGraphQLClient, error) { +// NewClient returns a new, authenticated instance for the given Kraken instance +// (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{ log: log, + baseURI: baseURI, email: email, password: password, }) @@ -46,17 +51,17 @@ func NewClient(log *util.Logger, email, password, accountNumber string) (*Octopu Base: cli.Transport, } - gq := &OctopusDeGraphQLClient{ + gq := &Client{ log: log, accountNumber: accountNumber, - Client: graphql.NewClient(BaseURI, cli), + Client: graphql.NewClient(baseURI, cli), } return gq, nil } // 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) defer cancel() diff --git a/tariff/octopusde/graphql/tokensource.go b/tariff/octopuskraken/graphql/tokensource.go similarity index 96% rename from tariff/octopusde/graphql/tokensource.go rename to tariff/octopuskraken/graphql/tokensource.go index 79e6bf953..9530f8c9c 100644 --- a/tariff/octopusde/graphql/tokensource.go +++ b/tariff/octopuskraken/graphql/tokensource.go @@ -19,6 +19,7 @@ var ErrAuthFailed = errors.New("authentication failed") type tokenSource struct { log *util.Logger + baseURI 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 cli := request.NewClient(ts.log) - tempClient := graphql.NewClient(BaseURI, cli) + tempClient := graphql.NewClient(ts.baseURI, cli) var q krakenTokenAuthentication if err := tempClient.Mutate(ctx, &q, map[string]any{ diff --git a/tariff/octopusde/graphql/types.go b/tariff/octopuskraken/graphql/types.go similarity index 100% rename from tariff/octopusde/graphql/types.go rename to tariff/octopuskraken/graphql/types.go diff --git a/templates/definition/tariff/octopus-it.yaml b/templates/definition/tariff/octopus-it.yaml new file mode 100644 index 000000000..df10bac19 --- /dev/null +++ b/templates/definition/tariff/octopus-it.yaml @@ -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 }} diff --git a/templates/definition/vehicle/octopus-it.yaml b/templates/definition/vehicle/octopus-it.yaml new file mode 100644 index 000000000..41394184c --- /dev/null +++ b/templates/definition/vehicle/octopus-it.yaml @@ -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" . }} diff --git a/vehicle/octopusde.go b/vehicle/octopusde.go index c2c4e12ab..098685e15 100644 --- a/vehicle/octopusde.go +++ b/vehicle/octopusde.go @@ -7,17 +7,17 @@ import ( "github.com/evcc-io/evcc/api" "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 type OctopusDe struct { *embed - *octopusde.API + *octopuskraken.API account string device string deviceID string - dataG func() (octopusde.Device, error) + dataG func() (octopuskraken.Device, error) } 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) - api, err := octopusde.NewAPI(log, cc.Email, cc.Password) + api, err := octopuskraken.NewAPI(log, octopuskraken.BaseURI, cc.Email, cc.Password) if err != nil { 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 // 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) if err != nil { - return octopusde.Device{}, err + return octopuskraken.Device{}, err } v.account = account devices, err := v.Devices(account) if err != nil { - return octopusde.Device{}, err + return octopuskraken.Device{}, err } for _, d := range devices { @@ -94,9 +94,9 @@ func (v *OctopusDe) status() (octopusde.Device, error) { } 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 diff --git a/vehicle/octopusde/api.go b/vehicle/octopuskraken/api.go similarity index 82% rename from vehicle/octopusde/api.go rename to vehicle/octopuskraken/api.go index 0c4a43e01..905fa8e62 100644 --- a/vehicle/octopusde/api.go +++ b/vehicle/octopuskraken/api.go @@ -1,4 +1,4 @@ -package octopusde +package octopuskraken import ( "context" @@ -6,25 +6,31 @@ import ( "time" "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" ) -// API is the Octopus Energy Germany Kraken client for vehicle data. It reuses the -// authenticated Kraken GraphQL client from the tariff implementation so the JWT -// token source and auth transport are not duplicated. +// BaseURI re-exports the tariff graphql package's Kraken API root. +const BaseURI = krakengql.BaseURI + +// 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 { - *octoDeGql.OctopusDeGraphQLClient + *krakengql.Client } -// NewAPI creates a Kraken API client authenticated via the given credentials. -func NewAPI(log *util.Logger, email, password string) (*API, error) { +// NewAPI creates a Kraken API client for the given Kraken instance (other +// 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 - client, err := octoDeGql.NewClient(log, email, password, "") + client, err := krakengql.NewClient(log, baseURI, email, password, "") if err != nil { return nil, err } - return &API{OctopusDeGraphQLClient: client}, nil + return &API{Client: client}, nil } // krakenAccounts lists the accounts accessible to the authenticated user. diff --git a/vehicle/octopusde/api_test.go b/vehicle/octopuskraken/api_test.go similarity index 98% rename from vehicle/octopusde/api_test.go rename to vehicle/octopuskraken/api_test.go index dd8c0f99a..b48c7401d 100644 --- a/vehicle/octopusde/api_test.go +++ b/vehicle/octopuskraken/api_test.go @@ -1,4 +1,4 @@ -package octopusde +package octopuskraken import ( "testing" diff --git a/vehicle/octopusde/service.go b/vehicle/octopuskraken/service.go similarity index 61% rename from vehicle/octopusde/service.go rename to vehicle/octopuskraken/service.go index e2f806c44..75203cc25 100644 --- a/vehicle/octopusde/service.go +++ b/vehicle/octopuskraken/service.go @@ -1,4 +1,4 @@ -package octopusde +package octopuskraken import ( "encoding/json" @@ -10,15 +10,23 @@ import ( ) func init() { - mux := http.NewServeMux() - mux.HandleFunc("GET /devices", getDevices) + register("octopus-de", BaseURI) + 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 // 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") q := req.URL.Query() @@ -31,8 +39,8 @@ func getDevices(w http.ResponseWriter, req *http.Request) { return } - log := util.NewLogger("octopus-de").Redact(email, password) - api, err := NewAPI(log, email, password) + log := util.NewLogger(name).Redact(email, password) + api, err := NewAPI(log, baseURI, email, password) if err != nil { log.ERROR.Println(err) return