304 lines
6.9 KiB
Go
304 lines
6.9 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
|
|
env string
|
|
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 != 0 {
|
|
// 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) {
|
|
cc := struct {
|
|
ClientId string
|
|
ClientSecret string
|
|
Environment string
|
|
Contract int64
|
|
}{
|
|
Environment: "production",
|
|
}
|
|
|
|
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,
|
|
env: cc.Environment,
|
|
basic: basic,
|
|
Helper: request.NewHelper(log),
|
|
data: util.NewMonitor[api.Rates](2 * time.Hour),
|
|
}
|
|
|
|
t.Client.Transport = &oauth2.Transport{
|
|
Base: t.Client.Transport,
|
|
Source: oauth2.ReuseTokenSource(nil, oauth.BootstrapTokenSource(t.refreshToken)),
|
|
}
|
|
|
|
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
|
|
|
|
tariffType, err := t.tariffType()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
done := make(chan error)
|
|
if tariffType == 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) apiUri(path string) string {
|
|
uri := ostrom.URI_API_PRODUCTION
|
|
if t.env == "sandbox" {
|
|
uri = ostrom.URI_API_SANDBOX
|
|
}
|
|
return uri + path
|
|
}
|
|
|
|
func (t *Ostrom) authUri(path string) string {
|
|
uri := ostrom.URI_AUTH_PRODUCTION
|
|
if t.env == "sandbox" {
|
|
uri = ostrom.URI_AUTH_SANDBOX
|
|
}
|
|
return uri + path
|
|
}
|
|
|
|
func (t *Ostrom) getContracts() ([]ostrom.Contract, error) {
|
|
var res ostrom.Contracts
|
|
|
|
uri := t.apiUri("/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, error) {
|
|
uri := t.authUri("/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 {
|
|
if reportError(&once, done, err) {
|
|
return
|
|
}
|
|
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 := t.apiUri(fmt.Sprintf("/spot-prices?%s", params.Encode()))
|
|
if err := backoff.Retry(func() error {
|
|
return backoffPermanentError(t.GetJSON(uri, &res))
|
|
}, bo()); err != nil {
|
|
if reportError(&once, done, err) {
|
|
return
|
|
}
|
|
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
|
|
}
|
|
|
|
// tariffType returns the tariff type for the current contract, or an error for unknown contract types.
|
|
func (t *Ostrom) tariffType() (api.TariffType, error) {
|
|
switch t.contractType {
|
|
case ostrom.PRODUCT_DYNAMIC, ostrom.PRODUCT_DYNAMIC_V2:
|
|
return api.TariffTypePriceForecast, nil
|
|
case ostrom.PRODUCT_FAIR, ostrom.PRODUCT_FAIR_CAP:
|
|
return api.TariffTypePriceStatic, nil
|
|
default:
|
|
return 0, fmt.Errorf("invalid contract type: %s", t.contractType)
|
|
}
|
|
}
|
|
|
|
// Type implements the api.Tariff interface
|
|
func (t *Ostrom) Type() api.TariffType {
|
|
tariffType, err := t.tariffType()
|
|
if err != nil {
|
|
panic(err.Error())
|
|
}
|
|
return tariffType
|
|
}
|