From 5cdf197b941dd98d6c0ba131cbde90d61dbd8cb5 Mon Sep 17 00:00:00 2001 From: ross-w Date: Wed, 21 Feb 2024 22:48:34 +1100 Subject: [PATCH] Add support for Amber Electric (AU) (#12381) --- evcc.dist.yaml | 10 ++ tariff/amber.go | 136 +++++++++++++++++++++++++ tariff/amber/types.go | 19 ++++ templates/definition/tariff/amber.yaml | 14 +++ 4 files changed, 179 insertions(+) create mode 100644 tariff/amber.go create mode 100644 tariff/amber/types.go create mode 100644 templates/definition/tariff/amber.yaml diff --git a/evcc.dist.yaml b/evcc.dist.yaml index 6dc052bfe5..cc860ec121 100644 --- a/evcc.dist.yaml +++ b/evcc.dist.yaml @@ -183,6 +183,11 @@ tariffs: # charges: # optional, additional charges per kWh # tax: # optional, additional tax (0.1 for 10%) + # type: amber + # token: # api token from https://app.amber.com.au/developers/ + # siteid: # site ID returned by the API + # channel: general + # type: custom # price from a plugin source; see https://docs.evcc.io/docs/reference/plugins # price: # source: http @@ -197,6 +202,11 @@ tariffs: # type: octopusenergy # tariff: AGILE-FLEX-22-11-25 # Tariff code # region: A # optional + + # type: amber + # token: # api token from https://app.amber.com.au/developers/ + # siteid: # site ID returned by the API + # channel: feedIn co2: # co2 tariff provides co2 intensity forecast and is for co2-optimized target charging if no variable grid tariff is specified # type: grünstromindex # GrünStromIndex (Germany only) diff --git a/tariff/amber.go b/tariff/amber.go new file mode 100644 index 0000000000..4c3bc6126a --- /dev/null +++ b/tariff/amber.go @@ -0,0 +1,136 @@ +package tariff + +import ( + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/tariff/amber" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/transport" + "golang.org/x/exp/slices" +) + +type Amber struct { + *embed + *request.Helper + log *util.Logger + uri string + channel string + data *util.Monitor[api.Rates] +} + +var _ api.Tariff = (*Amber)(nil) + +func init() { + registry.Add("amber", NewAmberFromConfig) +} + +func NewAmberFromConfig(other map[string]interface{}) (api.Tariff, error) { + var cc struct { + embed `mapstructure:",squash"` + Token string + SiteID string + Channel string + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if cc.Token == "" { + return nil, errors.New("missing token") + } + + if cc.SiteID == "" { + return nil, errors.New("missing siteid") + } + + if cc.Channel == "" { + return nil, errors.New("missing channel") + } + + log := util.NewLogger("amber").Redact(cc.Token) + + t := &Amber{ + embed: &cc.embed, + log: log, + Helper: request.NewHelper(log), + uri: fmt.Sprintf(amber.URI, strings.ToUpper(cc.SiteID), time.Now().AddDate(0, 0, 1).Format("2006-01-02")), + channel: strings.ToLower(cc.Channel), + data: util.NewMonitor[api.Rates](2 * time.Hour), + } + + t.Client.Transport = &transport.Decorator{ + Base: t.Client.Transport, + Decorator: transport.DecorateHeaders(map[string]string{ + "Authorization": fmt.Sprintf("Bearer %s", cc.Token), + }), + } + + done := make(chan error) + go t.run(done) + err := <-done + + return t, err +} + +func (t *Amber) run(done chan error) { + var once sync.Once + bo := newBackoff() + + for ; true; <-time.Tick(time.Minute) { + var res []amber.PriceInfo + + if err := backoff.Retry(func() error { + return t.GetJSON(t.uri, &res) + }, bo); err != nil { + once.Do(func() { done <- err }) + + t.log.ERROR.Println(err) + continue + } + + data := make(api.Rates, 0, len(res)) + + for _, r := range res { + if t.channel == strings.ToLower(r.ChannelType) { + startTime, _ := time.Parse("2006-01-02T15:04:05Z", r.StartTime) + endTime, _ := time.Parse("2006-01-02T15:04:05Z", r.EndTime) + ar := api.Rate{ + Start: startTime.Local(), + End: endTime.Local(), + Price: r.PerKwh / 1e2, + } + data = append(data, ar) + } + } + data.Sort() + + t.data.Set(data) + once.Do(func() { close(done) }) + } +} + +// Rates implements the api.Tariff interface +func (t *Amber) Rates() (api.Rates, error) { + var res api.Rates + err := t.data.GetFunc(func(val api.Rates) { + res = slices.Clone(val) + }) + return res, err +} + +func (t *Amber) Unit() string { + return "AUD" +} + +// Type returns the tariff type +func (t *Amber) Type() api.TariffType { + return api.TariffTypePriceForecast +} diff --git a/tariff/amber/types.go b/tariff/amber/types.go new file mode 100644 index 0000000000..6ee0265a1d --- /dev/null +++ b/tariff/amber/types.go @@ -0,0 +1,19 @@ +package amber + +const URI = "https://api.amber.com.au/v1/sites/%s/prices?endDate=%s&resolution=30" + +type PriceInfo struct { + Type string `json:"type"` + Date string `json:"date"` + Duration int `json:"duration"` + StartTime string `json:"startTime"` + EndTime string `json:"endTime"` + NemTime string `json:"nemTime"` + PerKwh float64 `json:"perKwh"` + Renewables float64 `json:"renewables"` + SpotPerKwh float64 `json:"spotPerKwh"` + ChannelType string `json:"channelType"` + SpikeStatus string `json:"spikeStatus"` + Descriptor string `json:"descriptor"` + Estimate bool `json:"estimate"` +} diff --git a/templates/definition/tariff/amber.yaml b/templates/definition/tariff/amber.yaml new file mode 100644 index 0000000000..a68d6ceaff --- /dev/null +++ b/templates/definition/tariff/amber.yaml @@ -0,0 +1,14 @@ +template: amber +products: + - brand: Amber Electric +params: + - preset: tariff-base + - name: token + - name: siteid + - name: channel +render: | + type: amber + {{ include "tariff-base" . }} + token: {{ .token }} + siteid: {{ .siteid }} + channel: {{ .channel }}