Add Ostrom (#16354)
This commit is contained in:
parent
037aeaa63a
commit
160d6f4435
3 changed files with 386 additions and 0 deletions
265
tariff/ostrom.go
Normal file
265
tariff/ostrom.go
Normal 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
95
tariff/ostrom/api.go
Normal 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"`
|
||||
}
|
||||
26
templates/definition/tariff/ostrom.yaml
Normal file
26
templates/definition/tariff/ostrom.yaml
Normal 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 }}
|
||||
Loading…
Add table
Add a link
Reference in a new issue