evcc-io/tariff/octopusit.go

181 lines
4.7 KiB
Go

package tariff
import (
"errors"
"fmt"
"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
// Germany's Kraken auth/transport but its own rate query (schemas diverge).
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.ItActiveAgreement()
if err != nil {
if errors.Is(err, krakengql.ErrAuthFailed) {
return backoff.Permanent(err)
}
return backoffPermanentError(err)
}
rates, err = ratesForItAgreement(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
}
// ratesForItAgreement returns a flat rate covering the planning horizon.
// Only FIXED_SINGLE_RATE products are supported - F1/F2/F3 is not implemented.
func ratesForItAgreement(agr krakengql.ItAgreement, now time.Time) ([]RatePeriod, error) {
horizon, err := computeItHorizon(now, agr, planDays)
if err != nil {
return nil, err
}
p := agr.Product.Prices
if p.ConsumptionChargeF2 != "" || p.ConsumptionChargeF3 != "" {
return nil, fmt.Errorf("unsupported time-of-use product %q: F2/F3 rates are not implemented", agr.Product.Code)
}
rate, err := parseFloat(p.ConsumptionCharge)
if err != nil {
return nil, fmt.Errorf("failed to parse consumption charge: %w", err)
}
// prices are € per kWh; RatePeriod stores cents per kWh like the DE tariff.
return []RatePeriod{{
ValidFrom: horizon.start,
ValidTo: horizon.end,
GrossUnitRateCentsPerKwh: rate * 100,
}}, nil
}
// computeItHorizon returns the planning window, capped by the agreement's validity.
func computeItHorizon(now time.Time, agreement krakengql.ItAgreement, planDays int) (planningHorizon, error) {
start := now
end := now.AddDate(0, 0, planDays)
if agreement.ValidFrom.After(end) || (!agreement.ValidTo.IsZero() && agreement.ValidTo.Before(start)) {
return planningHorizon{}, errors.New("agreement is not valid for the planning horizon")
}
if agreement.ValidFrom.After(start) {
start = agreement.ValidFrom
}
if !agreement.ValidTo.IsZero() && agreement.ValidTo.Before(end) {
end = agreement.ValidTo
}
return planningHorizon{start: start, end: end}, nil
}