Repository: evcc-io/evcc
Pull request #13451 (2024-04-15 20:30:19)

Tariff: add forecast provider
Fix https://github.com/evcc-io/evcc/issues/13435

This PR adds a `forecast` option to the custom tariff. `forecast`, if specified, must be a `StringProvider` delivering JSON conforming to `api.Rates`, i.e. a slice of maps containing `start`, `end` and `price`.

Users can specify either `price` or `forecast`.


Patch:
diff --git a/assets/js/views/Log.vue b/assets/js/views/Log.vue
index f4ec8099..00b227da 100644
--- a/assets/js/views/Log.vue
+++ b/assets/js/views/Log.vue
@@ -291,7 +291,7 @@ export default {
 }
 @keyframes fadeIn {
 	from {
-		opacity: 0;
+		opacity: 0.3;
 	}
 	to {
 		opacity: var(--opacity);
@@ -304,7 +304,17 @@ export default {
 	animation-duration: 1s;
 	animation-fill-mode: forwards;
 	animation-timing-function: ease-out;
+	text-indent: 1rem hanging;
+	/* smaller exception for mobile */
+	font-size: 8px;
 }
+@media (min-width: 576px) {
+	.log {
+		/* default code size */
+		font-size: 0.875em;
+	}
+}
+
 .log-warn {
 	color: var(--bs-warning);
 }
diff --git a/charger/go-e.go b/charger/go-e.go
index 3b9979b3..02fbaf5c 100644
--- a/charger/go-e.go
+++ b/charger/go-e.go
@@ -57,11 +57,8 @@ func NewGoEFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	if cc.URI != "" && cc.Token != "" {
-		return nil, errors.New("should only have one of uri/token")
-	}
-	if cc.URI == "" && cc.Token == "" {
-		return nil, errors.New("must have one of uri/token")
+	if (cc.URI != "") == (cc.Token != "") {
+		return nil, errors.New("must have either uri or token")
 	}
 
 	return NewGoE(cc.URI, cc.Token, cc.Cache)
diff --git a/meter/battery.go b/meter/battery.go
index 1a5a87dd..a008c9c3 100644
--- a/meter/battery.go
+++ b/meter/battery.go
@@ -20,7 +20,7 @@ func (m *battery) LimitController(socG func() (float64, error), limitSocS func(f
 			if err != nil {
 				return err
 			}
-			return limitSocS(max(soc, m.MinSoc))
+			return limitSocS(min(100, max(soc, m.MinSoc)))
 
 		case api.BatteryCharge:
 			return limitSocS(m.MaxSoc)
diff --git a/tariff/tariff.go b/tariff/tariff.go
index 0333b69e..7dd64fe5 100644
--- a/tariff/tariff.go
+++ b/tariff/tariff.go
@@ -1,9 +1,13 @@
 package tariff
 
 import (
+	"encoding/json"
 	"fmt"
+	"slices"
+	"sync"
 	"time"
 
+	"github.com/cenkalti/backoff/v4"
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/provider"
 	"github.com/evcc-io/evcc/util"
@@ -12,6 +16,8 @@ import (
 
 type Tariff struct {
 	*embed
+	log    *util.Logger
+	data   *util.Monitor[api.Rates]
 	priceG func() (float64, error)
 }
 
@@ -23,29 +29,92 @@ func init() {
 
 func NewConfigurableFromConfig(other map[string]interface{}) (api.Tariff, error) {
 	var cc struct {
-		embed `mapstructure:",squash"`
-		Price provider.Config
+		embed    `mapstructure:",squash"`
+		Price    *provider.Config
+		Forecast *provider.Config
 	}
 
 	if err := util.DecodeOther(other, &cc); err != nil {
 		return nil, err
 	}
 
-	priceG, err := provider.NewFloatGetterFromConfig(cc.Price)
-	if err != nil {
-		return nil, fmt.Errorf("price: %w", err)
+	if (cc.Price != nil) == (cc.Forecast != nil) {
+		return nil, fmt.Errorf("must have either price or forecast")
+	}
+
+	var (
+		err       error
+		priceG    func() (float64, error)
+		forecastG func() (string, error)
+	)
+
+	if cc.Price != nil {
+		priceG, err = provider.NewFloatGetterFromConfig(*cc.Price)
+		if err != nil {
+			return nil, fmt.Errorf("price: %w", err)
+		}
+	}
+
+	if cc.Forecast != nil {
+		forecastG, err = provider.NewStringGetterFromConfig(*cc.Forecast)
+		if err != nil {
+			return nil, fmt.Errorf("forecast: %w", err)
+		}
 	}
 
 	t := &Tariff{
+		log:    util.NewLogger("tariff"),
 		embed:  &cc.embed,
 		priceG: priceG,
+		data:   util.NewMonitor[api.Rates](2 * time.Hour),
 	}
 
-	return t, nil
+	if forecastG != nil {
+		done := make(chan error)
+		go t.run(forecastG, done)
+		err = <-done
+	}
+
+	return t, err
 }
 
-// Rates implements the api.Tariff interface
-func (t *Tariff) Rates() (api.Rates, error) {
+func (t *Tariff) run(forecastG func() (string, error), done chan error) {
+	var once sync.Once
+	bo := newBackoff()
+
+	tick := time.NewTicker(time.Hour)
+	for ; true; <-tick.C {
+		var data api.Rates
+		if err := backoff.Retry(func() error {
+			s, err := forecastG()
+			if err != nil {
+				return backoffPermanentError(err)
+			}
+
+			return json.Unmarshal([]byte(s), &data)
+		}, bo); err != nil {
+			once.Do(func() { done <- err })
+
+			t.log.ERROR.Println(err)
+			continue
+		}
+
+		data.Sort()
+
+		t.data.Set(data)
+		once.Do(func() { close(done) })
+	}
+}
+
+func (t *Tariff) forecastRates() (api.Rates, error) {
+	var res api.Rates
+	err := t.data.GetFunc(func(val api.Rates) {
+		res = slices.Clone(val)
+	})
+	return res, err
+}
+
+func (t *Tariff) priceRates() (api.Rates, error) {
 	price, err := t.priceG()
 	if err != nil {
 		return nil, err
@@ -66,7 +135,19 @@ func (t *Tariff) Rates() (api.Rates, error) {
 	return res, nil
 }
 
+// Rates implements the api.Tariff interface
+func (t *Tariff) Rates() (api.Rates, error) {
+	if t.priceG != nil {
+		return t.priceRates()
+	}
+
+	return t.forecastRates()
+}
+
 // Type implements the api.Tariff interface
 func (t *Tariff) Type() api.TariffType {
-	return api.TariffTypePriceDynamic
+	if t.priceG != nil {
+		return api.TariffTypePriceDynamic
+	}
+	return api.TariffTypePriceForecast
 }
diff --git a/util/logstash/log.go b/util/logstash/log.go
index 062d6475..4e434f07 100644
--- a/util/logstash/log.go
+++ b/util/logstash/log.go
@@ -28,11 +28,13 @@ func Size() int64 {
 type logger struct {
 	mu   sync.RWMutex
 	data *ring.Ring
+	size int
 }
 
 func New(size int) *logger {
 	return &logger{
-		data: ring.New(size),
+		data: ring.New(1),
+		size: size,
 	}
 }
 
@@ -44,6 +46,12 @@ func (l *logger) Write(p []byte) (n int, err error) {
 
 	if !strings.HasPrefix(string(p), "[cache ]") {
 		l.data.Value = element(string(p))
+
+		// dynamically grow the ring
+		if l.data.Len() < l.size {
+			l.data.Link(ring.New(1))
+		}
+
 		l.data = l.data.Next()
 	}
 
@@ -95,16 +103,17 @@ func (l *logger) All(areas []string, level jww.Threshold, count int) []string {
 	r := l.data
 	all := len(areas) == 0 && level == jww.LevelTrace
 
-	var res []string
+	res := make([]string, 0, r.Len())
 	for range r.Len() {
 		r = r.Next()
 		if e, ok := r.Value.(element); ok && e != "" && (all || e.match(areas, level)) {
 			res = append(res, string(e))
-			if count > 0 && len(res) >= count {
-				break
-			}
 		}
 	}
 
+	if count > 0 && len(res) > count {
+		res = res[len(res)-count:]
+	}
+
 	return res
 }


Test patch:
diff --git a/util/logstash/log_test.go b/util/logstash/log_test.go
index 173092be..53193e02 100644
--- a/util/logstash/log_test.go
+++ b/util/logstash/log_test.go
@@ -7,13 +7,15 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+var (
+	s1 = "[test1 ] TRACE test1"
+	s2 = "[test2 ] ERROR test2"
+	s3 = "[test1 ] TRACE test3"
+)
+
 func TestLog(t *testing.T) {
 	log := New(10)
 
-	s1 := "[test1 ] TRACE test1"
-	s2 := "[test2 ] ERROR test2"
-	s3 := "[test1 ] TRACE test3"
-
 	// old to new
 	log.Write([]byte(s1))
 	log.Write([]byte(s2))
@@ -26,8 +28,23 @@ func TestLog(t *testing.T) {
 
 	assert.Equal(t, []string{s1, s3}, log.All([]string{"test1"}, jww.LevelTrace, 0))
 	assert.Equal(t, []string{s1, s2, s3}, log.All(nil, jww.LevelTrace, 0))
-	assert.Nil(t, log.All(nil, jww.LevelFatal, 0))
+	assert.Equal(t, []string{s3}, log.All(nil, jww.LevelTrace, 1))
+
+	assert.Equal(t, []string{}, log.All(nil, jww.LevelFatal, 0))
 
 	assert.Equal(t, idx, log.data, "data should not be changed after All() call")
 	assert.Equal(t, []string{"test1", "test2"}, log.Areas())
 }
+
+func BenchmarkLog(b *testing.B) {
+	log := New(10000)
+
+	// old to new
+	log.Write([]byte(s1))
+	log.Write([]byte(s2))
+	log.Write([]byte(s3))
+
+	for i := 0; i < b.N; i++ {
+		log.All(nil, jww.LevelTrace, 1)
+	}
+}