Add Ostrom (#16354)

This commit is contained in:
kscholty 2024-11-22 09:51:27 +01:00 • committed by GitHub
parent 037aeaa63a
commit 160d6f4435
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 386 additions and 0 deletions

265
tariff/ostrom.go Normal file
View file

@ -0,0 +1,265 @@
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]interface{}) (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)
}
err = <-done
return t, err
}
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
tick := time.NewTicker(time.Hour)
for ; true; <-tick.C {
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),
Price: 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
tick := time.NewTicker(time.Hour)
for ; true; <-tick.C {
var res ostrom.Prices
start := now.BeginningOfDay()
end := start.AddDate(0, 0, 2)
params := url.Values{
"startDate": {start.Format(time.RFC3339)},
"endDate": {end.Format(time.RFC3339)},
"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),
Price: (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)
}
}

95
tariff/ostrom/api.go Normal file
View file

@ -0,0 +1,95 @@
package ostrom
import (
"time"
)
// URIs, production and sandbox
// see https://docs.ostrom-api.io/reference/environments
const (
URI_AUTH_PRODUCTION = "https://auth.production.ostrom-api.io"
URI_API_PRODUCTION = "https://production.ostrom-api.io"
URI_AUTH_SANDBOX = "https://auth.sandbox.ostrom-api.io"
URI_API_SANDBOX = "https://sandbox.ostrom-api.io"
URI_GET_CITYID = "https://api.ostrom.de/v1/addresses/cities"
URI_GET_STATIC_PRICE = "https://api.ostrom.de/v1/tariffs/city-id"
URI_AUTH = URI_AUTH_PRODUCTION
URI_API = URI_API_PRODUCTION
)
const (
PRODUCT_FAIR = "SIMPLY_FAIR"
PRODUCT_FAIR_CAP = "SIMPLY_FAIR_WITH_PRICE_CAP"
PRODUCT_DYNAMIC = "SIMPLY_DYNAMIC"
PRODUCT_BASIC = "basisProdukt"
)
type Prices struct {
Data []ForecastInfo
}
type ForecastInfo struct {
StartTimestamp time.Time `json:"date"`
Marketprice float64 `json:"grossKwhPrice"`
AdditionalCost float64 `json:"grossKwhTaxAndLevies"`
}
type Contracts struct {
Data []Contract
}
type Address struct {
Zip string `json:"zip"` //"22083",
City string `json:"city"` //"Hamburg",
Street string `json:"street"` //"Mozartstr.",
HouseNumber string `json:"housenumber"` //"35"
}
type Contract struct {
Id int64 `json:"id"` //"100523456",
Type string `json:"type"` //"ELECTRICITY",
Product string `json:"productCode"` //"SIMPLY_DYNAMIC",
Status string `json:"status"` //"ACTIVE",
FirstName string `json:"customerFirstName"` //"Max",
LastName string `json:"customerLastName"` //"Mustermann",
StartDate string `json:"startDate"` // "2024-03-22",
Dposit int `json:"currentMonthlyDepositAmount"` //120,
Address Address `json:"address"`
}
type CityId []struct {
Id int `json:"id"`
Postcode string `json:"postcode"`
Name string `json:"name"`
}
type Tariffs struct {
Ostrom []struct {
ProductCode string `json:"productCode"`
Tariff int `json:"tariff"`
BasicFee int `json:"basicFee"`
NetworkFee float64 `json:"networkFee"`
UnitPricePerkWH float64 `json:"unitPricePerkWH"`
TariffWithStormPreisBremse int `json:"tariffWithStormPreisBremse"`
StromPreisBremseUnitPrice int `json:"stromPreisBremseUnitPrice"`
AccumulatedUnitPriceWithStromPreisBremse float64 `json:"accumulatedUnitPriceWithStromPreisBremse"`
UnitPrice float64 `json:"unitPrice"`
EnergyConsumption int `json:"energyConsumption"`
BasePriceBrutto float64 `json:"basePriceBrutto"`
WorkingPriceBrutto float64 `json:"workingPriceBrutto"`
WorkingPriceNetto float64 `json:"workingPriceNetto"`
MeterChargeBrutto int `json:"meterChargeBrutto"`
WorkingPricePowerTax float64 `json:"workingPricePowerTax"`
AverageHourlyPriceToday float64 `json:"averageHourlyPriceToday,omitempty"`
MinHourlyPriceToday float64 `json:"minHourlyPriceToday,omitempty"`
MaxHourlyPriceToday float64 `json:"maxHourlyPriceToday,omitempty"`
} `json:"ostrom"`
Footprint struct {
Usage int `json:"usage"`
KgCO2Emissions int `json:"kgCO2Emissions"`
} `json:"footprint"`
IsPendingApplicationAllowed bool `json:"isPendingApplicationAllowed"`
Status string `json:"status"`
PartnerName any `json:"partnerName"`
}

View file

@ -0,0 +1,26 @@
template: ostrom
products:
- brand: Ostrom
requirements:
description:
en: "Create a 'Production Client' in the Ostrom developer portal: https://developer.ostrom-api.io/"
de: "Erzeuge einen 'Production Client' in dem Tibber-Entwicklerportal: https://developer.ostrom-api.io/"
evcc: ["skiptest"]
group: price
params:
- name: clientid
example: 476c477d8a039529478ebd690d35ddd80e3308ffc49b59c65b142321aee963a4
required: true
- name: clientsecret
example: 476c477d8a039529478ebd690d35ddd80e3308ffc49b59c65b142321aee963a4476c477d8a039529478ebd690d35ddd80e3308ffc49b59c65b142321aee963a4a
required: true
- name: contract
example: 100523456
help:
de: Nur erforderlich, wenn mehrere Verträge unter einem Benutzer existieren
en: Only required if multiple contracts belong to the same user
render: |
type: ostrom
ClientId: {{ .clientid }}
ClientSecret: {{ .clientsecret }}
Contract: {{ .contract }}