Add Octopus Energy Italy tariff (#31505)

This commit is contained in:
andig 2026-07-07 12:06:08 +02:00 • committed by GitHub
parent 8a771d5fb3
commit de976b7aa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 360 additions and 67 deletions

View file

@ -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

View file

@ -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
View 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
View 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")
}

View file

@ -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()

View file

@ -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{

View 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 }}

View 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" . }}

View file

@ -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

View file

@ -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.

View file

@ -1,4 +1,4 @@
package octopusde package octopuskraken
import ( import (
"testing" "testing"

View file

@ -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