evcc-io/tariff/ostrom.go
2025-10-31 15:25:53 +00:00

266 lines
6.1 KiB
Go

package tariff
import (
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/tariff/ostrom"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
"github.com/jinzhu/now"
"golang.org/x/oauth2"
)
type Ostrom struct {
*embed
*request.Helper
log *util.Logger
zip string
contractType string
cityId int // Required for the Fair tariff types
basic string
data *util.Monitor[api.Rates]
}
var _ api.Tariff = (*Ostrom)(nil)
func init() {
registry.Add("ostrom", NewOstromFromConfig)
}
// Search for a contract in list of contracts
func ensureContractEx(cid int64, contracts []ostrom.Contract) (ostrom.Contract, error) {
var zero ostrom.Contract
if cid != -1 {
// cid defined
for _, contract := range contracts {
if cid == contract.Id {
return contract, nil
}
}
} else if len(contracts) == 1 {
// cid empty and exactly one object
return contracts[0], nil
}
return zero, errors.New("cannot find contract")
}
func NewOstromFromConfig(other map[string]any) (api.Tariff, error) {
var cc struct {
ClientId string
ClientSecret string
Contract int64
}
cc.Contract = -1
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if cc.ClientId == "" || cc.ClientSecret == "" {
return nil, api.ErrMissingCredentials
}
basic := transport.BasicAuthHeader(cc.ClientId, cc.ClientSecret)
log := util.NewLogger("ostrom").Redact(basic)
t := &Ostrom{
log: log,
basic: basic,
Helper: request.NewHelper(log),
data: util.NewMonitor[api.Rates](2 * time.Hour),
}
t.Client.Transport = &oauth2.Transport{
Base: t.Client.Transport,
Source: oauth.RefreshTokenSource(nil, t),
}
contracts, err := t.getContracts()
if err != nil {
return nil, err
}
contract, err := ensureContractEx(cc.Contract, contracts)
if err != nil {
return nil, err
}
t.contractType = contract.Product
t.zip = contract.Address.Zip
done := make(chan error)
if t.Type() == api.TariffTypePriceStatic {
t.cityId, err = t.getCityId()
if err != nil {
return nil, err
}
go t.runStatic(done)
} else {
go t.run(done)
}
if err := <-done; err != nil {
return nil, err
}
return t, nil
}
func (t *Ostrom) getContracts() ([]ostrom.Contract, error) {
var res ostrom.Contracts
uri := ostrom.URI_API + "/contracts"
err := t.GetJSON(uri, &res)
return res.Data, err
}
func (t *Ostrom) getCityId() (int, error) {
var city ostrom.CityId
uri := fmt.Sprintf("%s?zip=%s", ostrom.URI_GET_CITYID, t.zip)
if err := t.GetJSON(uri, &city); err != nil {
return 0, err
}
if len(city) < 1 {
return 0, errors.New("city not found")
}
return city[0].Id, nil
}
func (t *Ostrom) getFixedPrice() (float64, error) {
var tariffs ostrom.Tariffs
uri := fmt.Sprintf("%s?usage=1000&cityId=%d", ostrom.URI_GET_STATIC_PRICE, t.cityId)
if err := backoff.Retry(func() error {
return backoffPermanentError(t.GetJSON(uri, &tariffs))
}, bo()); err != nil {
return 0, err
}
for _, tariff := range tariffs.Ostrom {
if tariff.ProductCode == ostrom.PRODUCT_BASIC {
return tariff.UnitPricePerkWH, nil
}
}
return 0, errors.New("tariff not found")
}
func (t *Ostrom) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) {
uri := ostrom.URI_AUTH + "/oauth2/token"
data := url.Values{"grant_type": {"client_credentials"}}
req, _ := request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), map[string]string{
"Authorization": t.basic,
"Content-Type": request.FormContent,
"Accept": request.JSONContent,
})
var res oauth2.Token
client := request.NewHelper(t.log)
err := client.DoJSON(req, &res)
return util.TokenWithExpiry(&res), err
}
// This function is used to calculate the prices for the Simplay Fair tarrifs
// using the price given in the configuration
// Unfortunately, the API does not allow to query the price for these yet.
func (t *Ostrom) runStatic(done chan error) {
var once sync.Once
for tick := time.Tick(time.Hour); ; <-tick {
price, err := t.getFixedPrice()
if err != nil {
once.Do(func() { done <- err })
t.log.ERROR.Println(err)
continue
}
data := make(api.Rates, 48)
for i := range data {
ts := now.BeginningOfDay().Add(time.Duration(i) * time.Hour)
data[i] = api.Rate{
Start: ts,
End: ts.Add(time.Hour),
Value: price / 100.0,
}
}
mergeRates(t.data, data)
once.Do(func() { close(done) })
}
}
// This function calls th ostrom API to query the
// dynamic prices
func (t *Ostrom) run(done chan error) {
var once sync.Once
for tick := time.Tick(time.Hour); ; <-tick {
var res ostrom.Prices
start := now.BeginningOfDay()
end := start.AddDate(0, 0, 2)
params := url.Values{
"startDate": {start.Format("2006-01-02T15:04:05.000Z07:00")},
"endDate": {end.Format("2006-01-02T15:04:05.000Z07:00")},
"resolution": {"HOUR"},
"zip": {t.zip},
}
uri := fmt.Sprintf("%s/spot-prices?%s", ostrom.URI_API, params.Encode())
if err := backoff.Retry(func() error {
return backoffPermanentError(t.GetJSON(uri, &res))
}, bo()); err != nil {
once.Do(func() { done <- err })
t.log.ERROR.Println(err)
continue
}
data := make(api.Rates, 0, 48)
for _, val := range res.Data {
ts := val.StartTimestamp.Local()
data = append(data, api.Rate{
Start: ts,
End: ts.Add(time.Hour),
Value: (val.Marketprice + val.AdditionalCost) / 100.0, // Both values include VAT
})
}
mergeRates(t.data, data)
once.Do(func() { close(done) })
}
}
// Rates implements the api.Tariff interface
func (t *Ostrom) 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 *Ostrom) Type() api.TariffType {
switch t.contractType {
case ostrom.PRODUCT_DYNAMIC:
return api.TariffTypePriceForecast
case ostrom.PRODUCT_FAIR, ostrom.PRODUCT_FAIR_CAP:
return api.TariffTypePriceStatic
default:
panic("invalid contract type: " + t.contractType)
}
}