diff --git a/cmd/setup.go b/cmd/setup.go index 357e89128..4d13266d2 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -805,7 +805,7 @@ func tariffInstance(name string, conf config.Typed) (api.Tariff, error) { return nil, fmt.Errorf("cannot decode custom tariff '%s': %w", name, err) } - instance, err := tariff.NewFromConfig(ctx, conf.Type, props) + instance, err := tariff.NewCachedFromConfig(ctx, conf.Type, props) if err != nil { if ce := new(util.ConfigError); errors.As(err, &ce) { return nil, err diff --git a/tariff/proxy.go b/tariff/proxy.go new file mode 100644 index 000000000..30623b130 --- /dev/null +++ b/tariff/proxy.go @@ -0,0 +1,167 @@ +package tariff + +import ( + "context" + "errors" + "slices" + "sync" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/jinzhu/now" +) + +// CachingProxy wraps a tariff with caching +type CachingProxy struct { + mu sync.Mutex + + key string + ctx context.Context + typ string + config map[string]any + + tariff api.Tariff +} + +var _ api.Tariff = (*CachingProxy)(nil) + +// NewCachedFromConfig creates a proxy that controls tariff instantiation and caching +func NewCachedFromConfig(ctx context.Context, typ string, other map[string]any) (api.Tariff, error) { + tariffType := typ + if typ == "template" { + if template, ok := other["template"].(string); ok { + tariffType = template + } + } + + p := &CachingProxy{ + ctx: ctx, + typ: typ, + config: other, + key: tariffType + "-" + cacheKey(typ, other), + } + + // not cached yet, create instance + if data, err := p.cacheGet(); err != nil { + tariff, err := NewFromConfig(ctx, typ, other) + if err != nil { + return nil, err + } + + p.tariff = tariff + } else { + log := util.NewLogger("tariff") + log.DEBUG.Printf("using cache: %s (start: %s, end: %s)", p.key, + data.Rates[0].Start.Local(), data.Rates[len(data.Rates)-1].End.Local(), + ) + } + + return p, nil +} + +func (p *CachingProxy) createInstance() { + t, err := NewFromConfig(p.ctx, p.typ, p.config) + if err != nil { + t = &proxyError{err} + } + + p.tariff = t +} + +// Rates returns cached data until underlying tariff is created, then delegates to tariff +func (p *CachingProxy) Rates() (api.Rates, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.tariff == nil { + if res, err := p.cacheGet(); err == nil { + return res.Rates, nil + } + + p.createInstance() + } + + res, err := p.tariff.Rates() + if err != nil { + return nil, err + } + + if p.dynamicTariff() { + err = p.cachePut(p.tariff.Type(), res) + } + + return res, err +} + +// Type returns the tariff type +func (p *CachingProxy) Type() api.TariffType { + p.mu.Lock() + defer p.mu.Unlock() + + if p.tariff == nil { + if res, err := p.cacheGet(); err == nil { + return res.Type + } + + p.createInstance() + } + + return p.tariff.Type() +} + +func (p *CachingProxy) dynamicTariff() bool { + return slices.Contains([]api.TariffType{ + api.TariffTypePriceForecast, + api.TariffTypeCo2, + api.TariffTypeSolar, + }, p.tariff.Type()) +} + +func (p *CachingProxy) cacheGet() (*cached, error) { + res, err := cacheGet(p.key) + if err != nil { + return nil, err + } + + // consider cache valid if it contains rates for 24 hours + until := time.Now().Add(24 * time.Hour) + + if !ratesValid(res.Rates, until) { + return nil, errors.New("not enough rates") + } + + res.Rates = currentRates(res.Rates) + if len(res.Rates) == 0 { + return nil, errors.New("no current rates") + } + + return res, nil +} + +func (p *CachingProxy) cachePut(typ api.TariffType, rates api.Rates) error { + return cachePut(p.key, typ, rates) +} + +func ratesValid(rr api.Rates, until time.Time) bool { + if len(rr) == 0 { + return false + } + + rr.Sort() + + return !rr[len(rr)-1].End.Before(until) +} + +func currentRates(rr api.Rates) api.Rates { + res := make(api.Rates, 0, len(rr)) + now := now.With(time.Now()).BeginningOfHour() + + for _, r := range rr { + if !r.End.Before(now) { + res = append(res, r) + } + } + + return res +} diff --git a/tariff/proxy_cache.go b/tariff/proxy_cache.go new file mode 100644 index 000000000..bac9108ce --- /dev/null +++ b/tariff/proxy_cache.go @@ -0,0 +1,30 @@ +package tariff + +import ( + "crypto/sha256" + "fmt" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/server/db/settings" +) + +type cached struct { + Type api.TariffType `json:"type"` + Rates api.Rates `json:"rates"` +} + +func cacheKey(typ string, other map[string]any) string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%s-%v", typ, other)))) +} + +func cachePut(key string, typ api.TariffType, rates api.Rates) error { + return settings.SetJson(key, &cached{ + Type: typ, + Rates: rates, + }) +} + +func cacheGet(key string) (*cached, error) { + var res cached + return &res, settings.Json(key, &res) +} diff --git a/tariff/proxy_error.go b/tariff/proxy_error.go new file mode 100644 index 000000000..f3a365ddd --- /dev/null +++ b/tariff/proxy_error.go @@ -0,0 +1,17 @@ +package tariff + +import "github.com/evcc-io/evcc/api" + +type proxyError struct { + error +} + +var _ api.Tariff = (*proxyError)(nil) + +func (t *proxyError) Rates() (api.Rates, error) { + return api.Rates{}, t.error +} + +func (t *proxyError) Type() api.TariffType { + return 0 // unknown +}