diff --git a/tariff/ostrom.go b/tariff/ostrom.go new file mode 100644 index 0000000000..fdfd9e630b --- /dev/null +++ b/tariff/ostrom.go @@ -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) + } +} diff --git a/tariff/ostrom/api.go b/tariff/ostrom/api.go new file mode 100644 index 0000000000..a56cf3f36e --- /dev/null +++ b/tariff/ostrom/api.go @@ -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"` +} diff --git a/templates/definition/tariff/ostrom.yaml b/templates/definition/tariff/ostrom.yaml new file mode 100644 index 0000000000..834780eb2c --- /dev/null +++ b/templates/definition/tariff/ostrom.yaml @@ -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 }}