Repository: evcc-io/evcc
Pull request #20696 (2025-04-18 15:29:27)

Solcast API calls only during the day
API calls only during the day to prevent too many calls.

See https://github.com/evcc-io/evcc/discussions/20655

andig: Gute Idee, aber das sollte opt-in und konfigurierbar sein ;)
TomF79: Okay, hätte es der Einfachheit halber mit fixen Zeiten versehen.

Leider übersteigt das meine Kenntnisse, könntest du es eventuell anpassen bitte, oder dabei belassen?
andig: Done- jetzt fehlt nur noch die Ergänzung des Temples (jeweils from/to als int mit Beschreibung). Dann sollte es gehen.
andig: Achso: was es auch nicht kann ist zwischen 22-2, aber das sollte hier kein Problem sein.
TomF79: Nein das denke ich auch, deshalb die Beschränkung auf tagsüber.
TomF79: > Done- jetzt fehlt nur noch die Ergänzung des Temples (jeweils from/to als int mit Beschreibung). Dann sollte es gehen.

Erledigt, hoffe das passt so.
CiNcH83: Ist das wirklich eine gute Idee? Wenn prognostiziert Entscheidungen getroffen werden und sich die Wettervorhersage in der Nacht ändert, könnte es Sinn machen, den Plan zu ändern...
TomF79: Du kannst ja mit from/to deine gewünschte Zeit konfigurieren.

Patch:
diff --git a/api/api.go b/api/api.go
index 2082caa8..605de895 100644
--- a/api/api.go
+++ b/api/api.go
@@ -239,8 +239,8 @@ type Circuit interface {
 	SetMaxPower(float64)
 	SetMaxCurrent(float64)
 	Update([]CircuitLoad) error
-	ValidateCurrent(old, new float64, charging bool) float64
-	ValidatePower(old, new float64, charging bool) float64
+	ValidateCurrent(old, new float64) float64
+	ValidatePower(old, new float64) float64
 }
 
 // Redactor is an interface to redact sensitive data
diff --git a/api/mock.go b/api/mock.go
index c7a2315e..7a00eeb1 100644
--- a/api/mock.go
+++ b/api/mock.go
@@ -953,31 +953,31 @@ func (mr *MockCircuitMockRecorder) Update(arg0 any) *gomock.Call {
 }
 
 // ValidateCurrent mocks base method.
-func (m *MockCircuit) ValidateCurrent(old, new float64, charging bool) float64 {
+func (m *MockCircuit) ValidateCurrent(old, new float64) float64 {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "ValidateCurrent", old, new, charging)
+	ret := m.ctrl.Call(m, "ValidateCurrent", old, new)
 	ret0, _ := ret[0].(float64)
 	return ret0
 }
 
 // ValidateCurrent indicates an expected call of ValidateCurrent.
-func (mr *MockCircuitMockRecorder) ValidateCurrent(old, new, charging any) *gomock.Call {
+func (mr *MockCircuitMockRecorder) ValidateCurrent(old, new any) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateCurrent", reflect.TypeOf((*MockCircuit)(nil).ValidateCurrent), old, new, charging)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateCurrent", reflect.TypeOf((*MockCircuit)(nil).ValidateCurrent), old, new)
 }
 
 // ValidatePower mocks base method.
-func (m *MockCircuit) ValidatePower(old, new float64, charging bool) float64 {
+func (m *MockCircuit) ValidatePower(old, new float64) float64 {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "ValidatePower", old, new, charging)
+	ret := m.ctrl.Call(m, "ValidatePower", old, new)
 	ret0, _ := ret[0].(float64)
 	return ret0
 }
 
 // ValidatePower indicates an expected call of ValidatePower.
-func (mr *MockCircuitMockRecorder) ValidatePower(old, new, charging any) *gomock.Call {
+func (mr *MockCircuitMockRecorder) ValidatePower(old, new any) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidatePower", reflect.TypeOf((*MockCircuit)(nil).ValidatePower), old, new, charging)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidatePower", reflect.TypeOf((*MockCircuit)(nil).ValidatePower), old, new)
 }
 
 // Wrap mocks base method.
diff --git a/assets/js/components/Loadpoints/SessionInfo.vue b/assets/js/components/Loadpoints/SessionInfo.vue
index 526be471..22ecb62d 100644
--- a/assets/js/components/Loadpoints/SessionInfo.vue
+++ b/assets/js/components/Loadpoints/SessionInfo.vue
@@ -64,6 +64,11 @@ export default {
 					value: this.fmtDuration(this.chargeRemainingDurationInterpolated),
 					available: this.chargeRemainingDurationInterpolated > 0,
 				},
+				{
+					key: "finished",
+					value: this.fmtHourMinute(this.finishTime),
+					available: this.chargeRemainingDurationInterpolated > 0,
+				},
 				{
 					key: "duration",
 					value: this.fmtDuration(this.chargeDurationInterpolated),
@@ -119,6 +124,14 @@ export default {
 		showSm() {
 			return this.valueSm !== undefined;
 		},
+		finishTime() {
+			const remainingSeconds = this.chargeRemainingDurationInterpolated;
+			if (remainingSeconds > 0) {
+				const now = new Date();
+				return new Date(now.getTime() + remainingSeconds * 1000);
+			}
+			return undefined;
+		},
 		solarFormatted() {
 			return this.fmtPercentage(this.sessionSolarPercentage, 1);
 		},
diff --git a/assets/js/mixins/formatter.ts b/assets/js/mixins/formatter.ts
index 3a5cd98e..8b39150f 100644
--- a/assets/js/mixins/formatter.ts
+++ b/assets/js/mixins/formatter.ts
@@ -201,6 +201,12 @@ export default defineComponent({
 
 			return `${weekday} ${hour}`.trim();
 		},
+		fmtHourMinute(date: Date) {
+			return new Intl.DateTimeFormat(this.$i18n?.locale, {
+				hour: "numeric",
+				minute: "numeric",
+			}).format(date);
+		},
 		fmtFullDateTime(date: Date, short: boolean) {
 			return new Intl.DateTimeFormat(this.$i18n?.locale, {
 				weekday: short ? undefined : "short",
diff --git a/charger/tasmota.go b/charger/tasmota.go
index 47262ee7..37a05fd4 100644
--- a/charger/tasmota.go
+++ b/charger/tasmota.go
@@ -32,6 +32,7 @@ func NewTasmotaFromConfig(other map[string]interface{}) (api.Charger, error) {
 		URI          string
 		User         string
 		Password     string
+		Usage        string
 		StandbyPower float64
 		Channel      []int
 		Cache        time.Duration
@@ -44,11 +45,11 @@ func NewTasmotaFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewTasmota(cc.embed, cc.URI, cc.User, cc.Password, cc.Channel, cc.StandbyPower, cc.Cache)
+	return NewTasmota(cc.embed, cc.URI, cc.User, cc.Password, cc.Usage, cc.Channel, cc.StandbyPower, cc.Cache)
 }
 
 // NewTasmota creates Tasmota charger
-func NewTasmota(embed embed, uri, user, password string, channels []int, standbypower float64, cache time.Duration) (api.Charger, error) {
+func NewTasmota(embed embed, uri, user, password, usage string, channels []int, standbypower float64, cache time.Duration) (api.Charger, error) {
 	conn, err := tasmota.NewConnection(uri, user, password, channels, cache)
 	if err != nil {
 		return nil, err
diff --git a/charger/vaillant.go b/charger/vaillant.go
index d02d169b..5ccd7ef4 100644
--- a/charger/vaillant.go
+++ b/charger/vaillant.go
@@ -93,18 +93,46 @@ func NewVaillantFromConfig(ctx context.Context, other map[string]interface{}) (a
 	systemId := homes[0].SystemID
 	heating := cc.HeatingSetpoint > 0
 
+	wwCancel := func() {}
+
 	set := func(mode int64) error {
 		switch mode {
 		case Normal:
 			if heating {
 				return conn.StopZoneQuickVeto(systemId, cc.HeatingZone)
 			}
+
+			wwCancel()
 			return conn.StopHotWaterBoost(systemId, sensonet.HOTWATERINDEX_DEFAULT)
+
 		case Boost:
 			if heating {
 				return conn.StartZoneQuickVeto(systemId, cc.HeatingZone, cc.HeatingSetpoint, 4) // hours
 			}
-			return conn.StartHotWaterBoost(systemId, sensonet.HOTWATERINDEX_DEFAULT) // zone 255
+
+			if err := conn.StartHotWaterBoost(systemId, sensonet.HOTWATERINDEX_DEFAULT); err != nil {
+				return err
+			}
+
+			var wwCtx context.Context
+			wwCtx, wwCancel = context.WithCancel(ctx)
+
+			// re-boost every 15m
+			go func() {
+				for {
+					select {
+					case <-wwCtx.Done():
+						return
+					case <-time.After(15 * time.Minute):
+						if err := conn.StartHotWaterBoost(systemId, sensonet.HOTWATERINDEX_DEFAULT); err != nil {
+							log.ERROR.Println("hot water boost:", err)
+						}
+					}
+				}
+			}()
+
+			return nil
+
 		default:
 			return api.ErrNotAvailable
 		}
diff --git a/core/circuit/circuit.go b/core/circuit/circuit.go
index c3d3f398..4d9a416b 100644
--- a/core/circuit/circuit.go
+++ b/core/circuit/circuit.go
@@ -12,6 +12,7 @@ import (
 	"github.com/evcc-io/evcc/plugin"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/config"
+	"github.com/evcc-io/evcc/util/modbus"
 )
 
 var _ api.Circuit = (*Circuit)(nil)
@@ -235,7 +236,7 @@ func (c *Circuit) overloadOnError(t time.Time, val *float64) {
 }
 
 func (c *Circuit) updateMeters() error {
-	if f, err := backoff.RetryWithData(c.meter.CurrentPower, bo()); err == nil {
+	if f, err := backoff.RetryWithData(c.meter.CurrentPower, modbus.Backoff()); err == nil {
 		c.power = f
 		c.powerUpdated = time.Now()
 	} else {
@@ -249,7 +250,7 @@ func (c *Circuit) updateMeters() error {
 			var err error
 			i1, i2, i3, err = phaseMeter.Currents()
 			return err
-		}, bo()); err != nil {
+		}, modbus.Backoff()); err != nil {
 			c.overloadOnError(c.currentUpdated, &c.current)
 			return fmt.Errorf("circuit currents: %w", err)
 		}
@@ -320,54 +321,45 @@ func (c *Circuit) GetMaxPhaseCurrent() float64 {
 }
 
 // ValidatePower validates power request
-func (c *Circuit) ValidatePower(old, new float64, charging bool) float64 {
+func (c *Circuit) ValidatePower(old, new float64) float64 {
 	if maxPower := c.GetMaxPower(); maxPower != 0 {
+		delta := max(0, new-old)
 		potential := maxPower - c.power
 
-		var capped float64
-		if charging {
-			capped = min(new, maxPower, max(0, potential+old))
-		} else {
-			capped = min(new, max(0, potential))
-		}
-		if new > capped {
+		if delta > potential {
+			capped := min(new, max(0, old+potential))
 			c.log.DEBUG.Printf("validate power: %.5gW + (%.5gW -> %.5gW) > %.5gW capped at %.5gW", c.power, old, new, maxPower, capped)
+			new = capped
 		} else {
 			c.log.TRACE.Printf("validate power: %.5gW + (%.5gW -> %.5gW) <= %.5gW ok", c.power, old, new, maxPower)
 		}
-		new = capped
 	}
 
 	if c.parent == nil {
 		return new
 	}
 
-	return c.parent.ValidatePower(old, new, charging)
+	return c.parent.ValidatePower(old, new)
 }
 
 // ValidateCurrent validates current request
-func (c *Circuit) ValidateCurrent(old, new float64, charging bool) float64 {
+func (c *Circuit) ValidateCurrent(old, new float64) float64 {
 	if maxCurrent := c.GetMaxCurrent(); maxCurrent != 0 {
+		delta := max(0, new-old)
 		potential := maxCurrent - c.current
 
-		var capped float64
-		if charging {
-			capped = min(new, maxCurrent, max(0, potential+old))
-		} else {
-			capped = min(new, max(0, potential))
-		}
-		if new > capped {
+		if delta > potential {
+			capped := min(new, max(0, old+potential))
 			c.log.DEBUG.Printf("validate current: %.3gA + (%.3gA -> %.3gA) > %.3gA capped at %.3gA", c.current, old, new, maxCurrent, capped)
+			new = capped
 		} else {
 			c.log.TRACE.Printf("validate current: %.3gA + (%.3gA -> %.3gA) <= %.3gA ok", c.current, old, new, maxCurrent)
 		}
-
-		new = capped
 	}
 
 	if c.parent == nil {
 		return new
 	}
 
-	return c.parent.ValidateCurrent(old, new, charging)
+	return c.parent.ValidateCurrent(old, new)
 }
diff --git a/core/circuit/helper.go b/core/circuit/helper.go
deleted file mode 100644
index 77c88c8b..00000000
--- a/core/circuit/helper.go
+++ /dev/null
@@ -1,12 +0,0 @@
-package circuit
-
-import (
-	"time"
-
-	"github.com/cenkalti/backoff/v4"
-)
-
-// bo returns an exponential backoff for reading meter power quickly
-func bo() *backoff.ExponentialBackOff {
-	return backoff.NewExponentialBackOff(backoff.WithInitialInterval(20*time.Millisecond), backoff.WithMaxElapsedTime(time.Second))
-}
diff --git a/core/helper.go b/core/helper.go
index d8c2a31a..5f9fedab 100644
--- a/core/helper.go
+++ b/core/helper.go
@@ -2,9 +2,7 @@ package core
 
 import (
 	"fmt"
-	"time"
 
-	"github.com/cenkalti/backoff/v4"
 	"github.com/evcc-io/evcc/util/config"
 )
 
@@ -16,11 +14,6 @@ var (
 	Voltage float64
 )
 
-// bo returns an exponential backoff for reading meter power quickly
-func bo() *backoff.ExponentialBackOff {
-	return backoff.NewExponentialBackOff(backoff.WithInitialInterval(20*time.Millisecond), backoff.WithMaxElapsedTime(2*time.Second))
-}
-
 // powerToCurrent is a helper function to convert power to per-phase current
 func powerToCurrent(power float64, phases int) float64 {
 	if Voltage == 0 {
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 8aa9b9f8..3d2874cf 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -26,6 +26,7 @@ import (
 	"github.com/evcc-io/evcc/push"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/config"
+	"github.com/evcc-io/evcc/util/modbus"
 	"github.com/evcc-io/evcc/util/telemetry"
 )
 
@@ -836,10 +837,17 @@ func (lp *Loadpoint) setLimit(current float64) error {
 
 	// apply circuit limits
 	if lp.circuit != nil {
-		currentLimit := lp.circuit.ValidateCurrent(lp.offeredCurrent, current, lp.charging())
+		var actualCurrent float64
+		if lp.chargeCurrents != nil {
+			actualCurrent = max(lp.chargeCurrents[0], lp.chargeCurrents[1], lp.chargeCurrents[2])
+		} else if lp.charging() {
+			actualCurrent = lp.offeredCurrent
+		}
+
+		currentLimit := lp.circuit.ValidateCurrent(actualCurrent, current)
 
 		activePhases := lp.ActivePhases()
-		powerLimit := lp.circuit.ValidatePower(lp.chargePower, currentToPower(current, activePhases), lp.charging())
+		powerLimit := lp.circuit.ValidatePower(lp.chargePower, currentToPower(current, activePhases))
 		currentLimitViaPower := powerToCurrent(powerLimit, activePhases)
 
 		current = lp.roundedCurrent(min(currentLimit, currentLimitViaPower))
@@ -1461,7 +1469,7 @@ func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower, batteryBoostPo
 
 // UpdateChargePowerAndCurrents updates charge meter power and currents for load management
 func (lp *Loadpoint) UpdateChargePowerAndCurrents() float64 {
-	power, err := backoff.RetryWithData(lp.chargeMeter.CurrentPower, bo())
+	power, err := backoff.RetryWithData(lp.chargeMeter.CurrentPower, modbus.Backoff())
 	if err == nil {
 		lp.Lock()
 		lp.chargePower = power // update value if no error
@@ -1502,7 +1510,7 @@ func (lp *Loadpoint) UpdateChargePowerAndCurrents() float64 {
 			lp.publish(keys.ChargeCurrents, lp.chargeCurrents)
 
 			return nil
-		}, bo()); err != nil && !errors.Is(err, api.ErrNotAvailable) {
+		}, modbus.Backoff()); err != nil && !errors.Is(err, api.ErrNotAvailable) {
 			lp.log.ERROR.Printf("charge currents: %v", err)
 		}
 	}
diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go
index 681b3d95..67bcc10f 100644
--- a/core/loadpoint_plan.go
+++ b/core/loadpoint_plan.go
@@ -164,8 +164,7 @@ func (lp *Loadpoint) plannerActive() (active bool) {
 			return false
 		}
 
-		// remember last active plan's end time
-		lp.setPlanActive(true)
+		// remember last active plan's slot end time
 		lp.planSlotEnd = activeSlot.End
 	} else if lp.planActive {
 		// planner was active (any slot, not necessarily previous slot) and charge goal has not yet been met
@@ -177,7 +176,7 @@ func (lp *Loadpoint) plannerActive() (active bool) {
 			return true
 		case lp.clock.Now().Before(lp.planSlotEnd) && !lp.planSlotEnd.IsZero():
 			// don't stop an already running slot if goal was not met
-			lp.log.DEBUG.Println("plan: continuing until end of slot")
+			lp.log.DEBUG.Printf("plan: continuing until end of slot at %s", lp.planSlotEnd.Round(time.Second).Local())
 			return true
 		case requiredDuration < smallSlotDuration:
 			lp.log.DEBUG.Printf("plan: continuing for remaining %v", requiredDuration.Round(time.Second))
diff --git a/core/site.go b/core/site.go
index da95ed7b..922b47e3 100644
--- a/core/site.go
+++ b/core/site.go
@@ -30,6 +30,7 @@ import (
 	"github.com/evcc-io/evcc/tariff"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/config"
+	"github.com/evcc-io/evcc/util/modbus"
 	"github.com/evcc-io/evcc/util/telemetry"
 	"github.com/samber/lo"
 	"github.com/smallnest/chanx"
@@ -496,7 +497,7 @@ func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) [
 				fmt.Fprintf(&b, "%v !! %3dms %v\n", start, d.Milliseconds(), err)
 			}
 			return f, err
-		}, bo())
+		}, modbus.Backoff())
 		if err == nil {
 			site.log.DEBUG.Printf("%s %d power: %.0fW", key, i+1, power)
 		} else {
@@ -723,7 +724,7 @@ func (site *Site) updateGridMeter() error {
 
 	var mm measurement
 
-	if res, err := backoff.RetryWithData(site.gridMeter.CurrentPower, bo()); err == nil {
+	if res, err := backoff.RetryWithData(site.gridMeter.CurrentPower, modbus.Backoff()); err == nil {
 		mm.Power = res
 		site.gridPower = res
 		site.log.DEBUG.Printf("grid power: %.0fW", res)
diff --git a/core/vehicle/adapter.go b/core/vehicle/adapter.go
index b1ec1124..a6fe514a 100644
--- a/core/vehicle/adapter.go
+++ b/core/vehicle/adapter.go
@@ -92,10 +92,11 @@ func (v *adapter) SetPlanSoc(ts time.Time, soc int) error {
 	// remove plan
 	if soc == 0 {
 		ts = time.Time{}
+		v.log.DEBUG.Printf("delete %s plan", v.name)
+	} else {
+		v.log.DEBUG.Printf("set %s plan soc: %d @ %v", v.name, soc, ts.Round(time.Second).Local())
 	}
 
-	v.log.DEBUG.Printf("set %s plan soc: %d @ %v", v.name, soc, ts.Round(time.Second).Local())
-
 	settings.SetTime(v.key()+keys.PlanTime, ts)
 	settings.SetInt(v.key()+keys.PlanSoc, int64(soc))
 
diff --git a/go.mod b/go.mod
index 8592d463..c98dfc88 100644
--- a/go.mod
+++ b/go.mod
@@ -53,6 +53,7 @@ require (
 	github.com/influxdata/influxdb-client-go/v2 v2.14.0
 	github.com/insomniacslk/tapo v1.0.1
 	github.com/itchyny/gojq v0.12.17
+	github.com/jarcoal/httpmock v1.3.0
 	github.com/jeremywohl/flatten v1.0.1
 	github.com/jinzhu/now v1.1.5
 	github.com/joeshaw/carwings v0.0.0-20250124122309-e366d592915c
@@ -218,6 +219,6 @@ tool (
 
 replace gopkg.in/yaml.v3 => github.com/andig/yaml v0.0.0-20240531135838-1ff5761ab467
 
-replace github.com/grid-x/modbus => github.com/evcc-io/modbus v0.0.0-20250403111054-69e330ffbd07
+replace github.com/grid-x/modbus => github.com/evcc-io/modbus v0.0.0-20250420095335-2cd088941a35
 
 replace github.com/lorenzodonini/ocpp-go => github.com/evcc-io/ocpp-go v0.0.0-20250322092544-c0c6094051c0
diff --git a/go.sum b/go.sum
index 69621d48..86132397 100644
--- a/go.sum
+++ b/go.sum
@@ -155,8 +155,8 @@ github.com/enbility/zeroconf/v2 v2.0.0-20240920094356-be1cae74fda6/go.mod h1:Bsz
 github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/evcc-io/modbus v0.0.0-20250403111054-69e330ffbd07 h1:OQNaVvgKSJabdxrQVkXANhpfsinAil1GzbOwHgghxTo=
-github.com/evcc-io/modbus v0.0.0-20250403111054-69e330ffbd07/go.mod h1:swrNGAVgI1r/3d/sEKE7qgujdRR9aHVPYKyc3gvpVTc=
+github.com/evcc-io/modbus v0.0.0-20250420095335-2cd088941a35 h1:OBKG6g2jjwF93LOWXrHyPBhggJkgInWMWVvA7AkCf7Q=
+github.com/evcc-io/modbus v0.0.0-20250420095335-2cd088941a35/go.mod h1:swrNGAVgI1r/3d/sEKE7qgujdRR9aHVPYKyc3gvpVTc=
 github.com/evcc-io/ocpp-go v0.0.0-20250322092544-c0c6094051c0 h1:Qz34Pm1Wr05jjJia5g2On3zRqdZh+BLTwqHgJGsiIh4=
 github.com/evcc-io/ocpp-go v0.0.0-20250322092544-c0c6094051c0/go.mod h1:2kcukDdhui4u730VfnYVWuwzDLgw+mBRGDir/QAyBhg=
 github.com/evcc-io/rct v0.1.2-0.20250315164247-d2f41b161785 h1:OWCBVMcPsVTffdiZN3VYaq9p4fsWlzd490J6pekaKLA=
@@ -442,6 +442,8 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m
 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
+github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
 github.com/mergermarket/go-pkcs7 v0.0.0-20170926155232-153b18ea13c9 h1:j6boLfPkcFlRVaKbc0hf5PVh3jJrdHv9n6SIPOdVKaU=
 github.com/mergermarket/go-pkcs7 v0.0.0-20170926155232-153b18ea13c9/go.mod h1:GH7jtq102ZiRB7LEKgqP54akN7GOVaNpCJrDWTeWSMY=
 github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
diff --git a/i18n/de.toml b/i18n/de.toml
index b4764812..b267619e 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -614,6 +614,7 @@ charged = "Geladen"
 co2 = "⌀ CO₂"
 duration = "Ladedauer"
 fallbackName = "Ladepunkt"
+finished = "Ladeende"
 power = "Leistung"
 price = "Kosten"
 remaining = "Restzeit"
diff --git a/i18n/en.toml b/i18n/en.toml
index 363f0c6d..036574e4 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -613,6 +613,7 @@ charged = "Charged"
 co2 = "⌀ CO₂"
 duration = "Duration"
 fallbackName = "Charging point"
+finished = "Finish time"
 power = "Power"
 price = "Cost"
 remaining = "Remaining"
diff --git a/meter/lgpcs/lgpcs.go b/meter/lgpcs/lgpcs.go
index 65fb1598..5068bcb1 100644
--- a/meter/lgpcs/lgpcs.go
+++ b/meter/lgpcs/lgpcs.go
@@ -31,55 +31,49 @@ type Com struct {
 }
 
 var (
-	once     sync.Once
-	instance *Com
+	instances map[string]*Com = map[string]*Com{}
+	mu        sync.Mutex      = sync.Mutex{}
 )
 
-// GetInstance implements the singleton pattern to handle the access via the authkey to the PCS of the LG ESS HOME system
+// GetInstance retrives a singleton per uri from a map to handle the access via the authkey to the PCS of the LG ESS HOME system
 func GetInstance(uri, registration, password string, cache time.Duration, essType Model) (*Com, error) {
-	uri = util.DefaultScheme(strings.TrimSuffix(uri, "/"), "https")
-
-	var err error
-	once.Do(func() {
-		log := util.NewLogger("lgess")
-		instance = &Com{
-			Helper:       request.NewHelper(log),
-			uri:          uri,
-			registration: registration,
-			password:     password,
-			essType:      essType,
-			log:          log,
-		}
-
-		// ignore the self signed certificate
-		instance.Client.Transport = request.NewTripper(log, transport.Insecure())
-
-		// caches the data access for the "cache" time duration
-		// sends a new request to the pcs if the cache is expired and Data() requested
-		instance.dataG = util.Cached(instance.essInfo, cache)
+	mu.Lock()
+	defer mu.Unlock()
 
-		// do first login if no authKey exists and uri and password exist
-		if instance.authKey == "" && instance.uri != "" && (instance.password != "" || instance.registration != "") {
-			err = instance.Login()
-		}
-	})
+	uri = util.DefaultScheme(strings.TrimSuffix(uri, "/"), "https")
 
-	// check if different uris are provided
-	if uri != "" && instance.uri != uri {
-		return nil, fmt.Errorf("uri mismatch: %s vs %s", instance.uri, uri)
+	instance, found := instances[uri]
+	if found {
+		return instance, nil
 	}
 
-	// check if different registrations are provided
-	if registration != "" && instance.registration != registration {
-		return nil, errors.New("registration mismatch")
+	log := util.NewLogger(fmt.Sprintf("lgess-%s", strings.TrimPrefix("https://", uri)))
+	instance = &Com{uri: uri,
+		Helper:       request.NewHelper(log),
+		registration: registration,
+		password:     password,
+		essType:      essType,
+		log:          log,
 	}
+	// put instance into the cache map
+	instances[uri] = instance
+
+	// ignore the self signed certificate
+	instance.Client.Transport = request.NewTripper(log, transport.Insecure())
 
-	// check if different passwords are provided
-	if password != "" && instance.password != password {
-		return nil, errors.New("password mismatch")
+	// caches the data access for the "cache" time duration
+	// sends a new request to the pcs if the cache is expired and Data() requested
+	instance.dataG = util.Cached(instance.essInfo, cache)
+
+	// do first login if no authKey exists and uri and password exist
+	if instance.authKey == "" && instance.uri != "" && (instance.password != "" || instance.registration != "") {
+		if err := instance.Login(); err != nil {
+			return nil, err
+		}
+		return instance, nil
 	}
 
-	return instance, err
+	return nil, errors.New("missing credentials")
 }
 
 // Login calls login and stores the returned authorization key
diff --git a/meter/shelly/gen1.go b/meter/shelly/gen1.go
index d4ed9d44..512111f2 100644
--- a/meter/shelly/gen1.go
+++ b/meter/shelly/gen1.go
@@ -115,9 +115,9 @@ func (c *gen1) TotalEnergy() (float64, error) {
 
 	switch {
 	case c.channel < len(res.Meters):
-		energy = res.Meters[c.channel].Total - res.Meters[c.channel].Total_Returned
+		energy = res.Meters[c.channel].Total
 	case c.channel < len(res.EMeters):
-		energy = res.EMeters[c.channel].Total - res.EMeters[c.channel].Total_Returned
+		energy = res.EMeters[c.channel].Total
 	default:
 		return 0, errors.New("invalid channel, missing power meter")
 	}
diff --git a/meter/tasmota.go b/meter/tasmota.go
index acb1578c..04a69807 100644
--- a/meter/tasmota.go
+++ b/meter/tasmota.go
@@ -1,6 +1,7 @@
 package meter
 
 import (
+	"math"
 	"strings"
 	"time"
 
@@ -23,7 +24,7 @@ func init() {
 //go:generate go tool decorate -f decorateTasmota -b *Tasmota -r api.Meter -t "api.PhaseVoltages,Voltages,func() (float64, float64, float64, error)" -t "api.PhaseCurrents,Currents,func() (float64, float64, float64, error)"
 
 // NewTasmotaFromConfig creates a Tasmota meter from generic config
-func NewTasmotaFromConfig(other map[string]interface{}) (api.Meter, error) {
+func NewTasmotaFromConfig(other map[string]any) (api.Meter, error) {
 	cc := struct {
 		URI      string
 		User     string
@@ -68,19 +69,21 @@ var _ api.Meter = (*Tasmota)(nil)
 
 // CurrentPower implements the api.Meter interface
 func (c *Tasmota) CurrentPower() (float64, error) {
-	if c.usage == "grid" {
-		return c.conn.SmlPower()
+	power, err := c.conn.CurrentPower()
+	if err != nil {
+		return 0, err
+	}
+	// positive power for pv usage
+	if c.usage == "pv" {
+		return math.Abs(power), nil
 	}
-	return c.conn.CurrentPower()
+	return power, nil
 }
 
 var _ api.MeterEnergy = (*Tasmota)(nil)
 
 // TotalEnergy implements the api.MeterEnergy interface
 func (c *Tasmota) TotalEnergy() (float64, error) {
-	if c.usage == "grid" {
-		return c.conn.SmlTotalEnergy()
-	}
 	return c.conn.TotalEnergy()
 }
 
diff --git a/meter/tasmota/connection.go b/meter/tasmota/connection.go
index 4986a4de..078aaecd 100644
--- a/meter/tasmota/connection.go
+++ b/meter/tasmota/connection.go
@@ -208,13 +208,13 @@ func (c *Connection) CurrentPower() (float64, error) {
 		}
 		res += power
 	}
-	return res, nil
+	return res + float64(s.StatusSNS.SML.PowerCurr), nil
 }
 
 // TotalEnergy implements the api.MeterEnergy interface
 func (c *Connection) TotalEnergy() (float64, error) {
 	res, err := c.statusSnsG.Get()
-	return res.StatusSNS.Energy.Total, err
+	return res.StatusSNS.Energy.Total + res.StatusSNS.SML.TotalOut, err
 }
 
 // Currents implements the api.PhaseCurrents interface
@@ -250,15 +250,3 @@ func (c *Connection) getPhaseValues(fun func(StatusSNSResponse) Channels) (float
 
 	return res[0], res[1], res[2], nil
 }
-
-// SmlPower provides the sml sensor power
-func (c *Connection) SmlPower() (float64, error) {
-	res, err := c.statusSnsG.Get()
-	return float64(res.StatusSNS.SML.PowerCurr), err
-}
-
-// SmlTotalEnergy provides the sml sensor total import energy
-func (c *Connection) SmlTotalEnergy() (float64, error) {
-	res, err := c.statusSnsG.Get()
-	return res.StatusSNS.SML.TotalIn, err
-}
diff --git a/server/http.go b/server/http.go
index 3c6572cd..90ee3ce2 100644
--- a/server/http.go
+++ b/server/http.go
@@ -39,17 +39,6 @@ type HTTPd struct {
 	*http.Server
 }
 
-// loggingResponseWriter wraps http.ResponseWriter to capture status code
-type loggingResponseWriter struct {
-	http.ResponseWriter
-	statusCode int
-}
-
-func (lrw *loggingResponseWriter) WriteHeader(code int) {
-	lrw.statusCode = code
-	lrw.ResponseWriter.WriteHeader(code)
-}
-
 // NewHTTPd creates HTTP server with configured routes for loadpoint
 func NewHTTPd(addr string, hub *SocketHub) *HTTPd {
 	router := mux.NewRouter().StrictSlash(true)
@@ -59,17 +48,8 @@ func NewHTTPd(addr string, hub *SocketHub) *HTTPd {
 	// log all requests
 	router.Use(func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			// simple logging for websocket connections
-			if r.Header.Get("Upgrade") == "websocket" {
-				log.TRACE.Printf("%s %s", r.Method, r.URL.Path)
-				next.ServeHTTP(w, r)
-				return
-			}
-
-			// capture status code
-			lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
-			next.ServeHTTP(lrw, r)
-			log.TRACE.Printf("%s %s %d", r.Method, r.URL.Path, lrw.statusCode)
+			log.TRACE.Printf("%s %s", r.Method, r.URL.Path)
+			next.ServeHTTP(w, r)
 		})
 	})
 
diff --git a/tariff/solcast.go b/tariff/solcast.go
index 7042b7ee..41e7ef8d 100644
--- a/tariff/solcast.go
+++ b/tariff/solcast.go
@@ -18,9 +18,10 @@ import (
 
 type Solcast struct {
 	*request.Helper
-	log  *util.Logger
-	site string
-	data *util.Monitor[api.Rates]
+	log    *util.Logger
+	site   string
+	fromTo FromTo
+	data   *util.Monitor[api.Rates]
 }
 
 var _ api.Tariff = (*Solcast)(nil)
@@ -34,6 +35,7 @@ func NewSolcastFromConfig(other map[string]interface{}) (api.Tariff, error) {
 		Site     string
 		Token    string
 		Interval time.Duration
+		FromTo   `mapstructure:",squash"`
 	}{
 		Interval: 3 * time.Hour,
 	}
@@ -56,6 +58,7 @@ func NewSolcastFromConfig(other map[string]interface{}) (api.Tariff, error) {
 		log:    log,
 		site:   cc.Site,
 		Helper: request.NewHelper(log),
+		fromTo: cc.FromTo,
 		data:   util.NewMonitor[api.Rates](2 * cc.Interval),
 	}
 
@@ -71,8 +74,16 @@ func NewSolcastFromConfig(other map[string]interface{}) (api.Tariff, error) {
 func (t *Solcast) run(interval time.Duration, done chan error) {
 	var once sync.Once
 
-	// don't exceed 10 requests per 24h
 	for ; true; <-time.Tick(interval) {
+		// ensure we don't run when not needed, but execute once at startup
+		select {
+		case <-t.data.Done():
+			if !t.fromTo.IsActive(time.Now().Hour()) {
+				continue
+			}
+		default:
+		}
+
 		var res solcast.Forecasts
 
 		if err := backoff.Retry(func() error {
@@ -80,7 +91,6 @@ func (t *Solcast) run(interval time.Duration, done chan error) {
 			return backoffPermanentError(t.GetJSON(uri, &res))
 		}, bo()); err != nil {
 			once.Do(func() { done <- err })
-
 			t.log.ERROR.Println(err)
 			continue
 		}
diff --git a/tariff/types.go b/tariff/types.go
index e9af1c69..4c7183ad 100644
--- a/tariff/types.go
+++ b/tariff/types.go
@@ -12,3 +12,13 @@ func (t Typed) Name() string {
 	}
 	return t.Type
 }
+
+type FromTo struct {
+	From, To int
+}
+
+func (ft FromTo) IsActive(hour int) bool {
+	return ft.From == 0 && ft.To == 0 ||
+		ft.From < ft.To && ft.From <= hour && hour <= ft.To ||
+		ft.From > ft.To && (ft.From <= hour || hour <= ft.To)
+}
diff --git a/templates/definition/charger/bender.yaml b/templates/definition/charger/bender.yaml
index 742cc09c..874a8fff 100644
--- a/templates/definition/charger/bender.yaml
+++ b/templates/definition/charger/bender.yaml
@@ -20,7 +20,10 @@ products:
       generic: AMTRON ChargeControl
   - brand: Mennekes
     description:
-      generic: AMTRON 4You
+      generic: AMTRON 4You 500
+  - brand: Mennekes
+    description:
+      generic: AMTRON 4Business 700
   - brand: Webasto
     description:
       generic: Live
diff --git a/templates/definition/charger/mennekes-compact.yaml b/templates/definition/charger/mennekes-compact.yaml
index 98ff218e..4665aa3a 100644
--- a/templates/definition/charger/mennekes-compact.yaml
+++ b/templates/definition/charger/mennekes-compact.yaml
@@ -6,7 +6,7 @@ products:
       generic: AMTRON Compact 2.0s
   - brand: Mennekes
     description:
-      generic: AMTRON 4You 310
+      generic: AMTRON 4You 300
   - brand: Kostal
     description:
       generic: Enector
diff --git a/templates/definition/charger/vaillant.yaml b/templates/definition/charger/vaillant.yaml
index 18cd14f3..8e637977 100644
--- a/templates/definition/charger/vaillant.yaml
+++ b/templates/definition/charger/vaillant.yaml
@@ -12,6 +12,10 @@ requirements:
 params:
   - name: user
   - name: password
+  - name: realm
+    type: choice
+    choice: [DE, AT]
+    default: DE
   - name: zone
     type: int
     help:
@@ -28,5 +32,6 @@ render: |
   type: vaillant
   user: {{ .user }}
   password: {{ .password }}
+  realm: {{ if eq .realm "AT" -}} vaillant-austria-b2c {{- end }}
   heatingzone: {{ .zone }}
   heatingsetpoint: {{ .setpoint }}
diff --git a/templates/definition/meter/huawei-emma.yaml b/templates/definition/meter/huawei-emma.yaml
index 2172e4c1..96f136b9 100644
--- a/templates/definition/meter/huawei-emma.yaml
+++ b/templates/definition/meter/huawei-emma.yaml
@@ -74,7 +74,7 @@ render: |
       address: 30344 # E-Total
       type: holding
       decode: uint32
-    scale: 0.1
+    scale: 0.01
   {{- end }}
   {{- if eq .usage "battery" }}
   power:
diff --git a/templates/definition/meter/sunspec-meter.yaml b/templates/definition/meter/sunspec-meter.yaml
new file mode 100644
index 00000000..d93ff387
--- /dev/null
+++ b/templates/definition/meter/sunspec-meter.yaml
@@ -0,0 +1,108 @@
+template: sunspec-meter
+products:
+  - description:
+      de: SunSpec Zähler
+      en: SunSpec Meter
+  - brand: Fronius
+    description:
+      de: Smartmeter (über Wechselrichter)
+      en: Smartmeter (via Inverter)
+params:
+  - name: usage
+    choice: ["grid", "pv"]
+  - name: modbus
+    choice: ["tcpip"]
+render: |
+  type: custom
+  # sunspec model 201 or 203 (int+sf)/ 211 or 213 (float) meter
+  {{- if eq .usage "grid" }}
+  power:
+    source: sunspec
+    uri: {{ .host }}:{{ .port }}
+    id: {{ .id }}
+    value:
+      - 203:W
+      - 213:W
+  energy:
+    source: sunspec
+    uri: {{ .host }}:{{ .port }}
+    id: {{ .id }}
+    value:
+      - 203:TotWhImp
+      - 213:TotWhImp
+    scale: 0.001
+  currents:
+    - source: sunspec
+      uri: {{ .host }}:{{ .port }}
+      id: {{ .id }}
+      value:
+        - 203:AphA
+        - 213:AphA
+    - source: sunspec
+      uri: {{ .host }}:{{ .port }}
+      id: {{ .id }}
+      value:
+        - 203:AphB
+        - 213:AphB
+    - source: sunspec
+      uri: {{ .host }}:{{ .port }}
+      id: {{ .id }}
+      value:
+        - 203:AphC
+        - 213:AphC
+  voltages:
+    - source: sunspec
+      uri: {{ .host }}:{{ .port }}
+      id: {{ .id }}
+      value:
+        - 203:PhVphA
+        - 213:PhVphA
+    - source: sunspec
+      uri: {{ .host }}:{{ .port }}
+      id: {{ .id }}
+      value:
+        - 203:PhVphB
+        - 213:PhVphB
+    - source: sunspec
+      uri: {{ .host }}:{{ .port }}
+      id: {{ .id }}
+      value:
+        - 203:PhVphC
+        - 213:PhVphC
+  powers:
+    - source: sunspec
+      uri: {{ .host }}:{{ .port }}
+      id: {{ .id }}
+      value:
+        - 203:WphA
+        - 213:WphA
+    - source: sunspec
+      uri: {{ .host }}:{{ .port }}
+      id: {{ .id }}
+      value:
+        - 203:WphB
+        - 213:WphB
+    - source: sunspec
+      uri: {{ .host }}:{{ .port }}
+      id: {{ .id }}
+      value:
+        - 203:WphC
+        - 213:WphC
+  {{- end }}
+  {{- if eq .usage "pv" }}
+  power:
+    source: sunspec
+    uri: {{ .host }}:{{ .port }}
+    id: {{ .id }}
+    value:
+      - 203:W
+      - 213:W
+  energy:
+    source: sunspec
+    uri: {{ .host }}:{{ .port }}
+    id: {{ .id }}
+    value:
+      - 203:TotWhExp
+      - 213:TotWhExp
+    scale: 0.001
+  {{- end }}
diff --git a/templates/definition/meter/tasmota-sml.yaml b/templates/definition/meter/tasmota-sml.yaml
index 1ff9a4bb..73a09abf 100644
--- a/templates/definition/meter/tasmota-sml.yaml
+++ b/templates/definition/meter/tasmota-sml.yaml
@@ -56,7 +56,7 @@ requirements:
       ```
 params:
   - name: usage
-    choice: ["grid"]
+    choice: ["grid", "pv", "battery", "charge"]
   - name: host
   - name: user
     required: false
diff --git a/templates/definition/meter/tasmota.yaml b/templates/definition/meter/tasmota.yaml
index fd71e6ee..792c7916 100644
--- a/templates/definition/meter/tasmota.yaml
+++ b/templates/definition/meter/tasmota.yaml
@@ -1,11 +1,12 @@
 template: tasmota
 products:
-  - description:
-      generic: Tasmota (1 Phase Meter)
+  - brand: Tasmota
+    description:
+      generic: Tasmota (1 Phase + SML Meter)
 group: switchsockets
 params:
   - name: usage
-    choice: ["pv", "battery", "charge"]
+    choice: ["grid", "pv", "battery", "charge"]
   - name: host
   - name: user
     required: false
diff --git a/templates/definition/tariff/green-grid-compass.yaml b/templates/definition/tariff/green-grid-compass.yaml
index ebae8515..19d514ac 100644
--- a/templates/definition/tariff/green-grid-compass.yaml
+++ b/templates/definition/tariff/green-grid-compass.yaml
@@ -28,6 +28,7 @@ params:
     default: DE_LU
 render: |
   type: custom
+  tariff: solar
   forecast:
     source: http
     uri: https://eco2grid.com/green-grid-compass/v2/forecast/hourly?zone_code={{ .zone }}&start={{ `{{ now | date "2006-01-02T15" }}` }}:00:00&end={{ `{{ addDate now 0 0 2 | date "2006-01-02T15" }}` }}:00:00&apikey={{ .apiKey }}
diff --git a/templates/definition/tariff/nordpool.yaml b/templates/definition/tariff/nordpool.yaml
index 10d5700d..2a36914b 100644
--- a/templates/definition/tariff/nordpool.yaml
+++ b/templates/definition/tariff/nordpool.yaml
@@ -46,6 +46,7 @@ params:
   - preset: tariff-base
 render: |
   type: custom
+  {{ include "tariff-base" . }}
   forecast:
     source: go
     script: |
@@ -79,4 +80,3 @@ render: |
                 "value": .entryPerArea.{{ .region }} / 1000
               }
             ] | tostring
-  {{ include "tariff-base" . }}
diff --git a/templates/definition/tariff/solcast.yaml b/templates/definition/tariff/solcast.yaml
index f4468178..7487d4c5 100644
--- a/templates/definition/tariff/solcast.yaml
+++ b/templates/definition/tariff/solcast.yaml
@@ -18,6 +18,16 @@ params:
       en: Solcast API Token
       de: Solcast API Token
     required: true
+  - name: from
+    help:
+      en: Start time of data retrieval, specified in full hours, e.g. "6"
+      de: Startzeit der Datenabrufe, Angabe in vollen Stunden, z.B "6"
+    advanced: true
+  - name: to
+    help:
+      en: End time of data retrieval, specified in full hours, e.g. "20"
+      de: Endzeit der Datenabrufe, Angabe in vollen Stunden, z.B "20"
+    advanced: true
   - name: interval
     default: 3h
     advanced: true
diff --git a/templates/definition/vehicle/tesla.yaml b/templates/definition/vehicle/tesla.yaml
index b310edd5..28eedadb 100644
--- a/templates/definition/vehicle/tesla.yaml
+++ b/templates/definition/vehicle/tesla.yaml
@@ -3,6 +3,7 @@ covers: ["tesla-command", "tesla-proxy"]
 products:
   - brand: Tesla
 requirements:
+  evcc: ["skiptest"]
   description:
     de: |
       Tesla bietet eine offizielle, aber kostenpflichtige Fahrzeug-API an.
diff --git a/util/modbus/functions.go b/util/modbus/functions.go
index d5c0bc87..5c745896 100644
--- a/util/modbus/functions.go
+++ b/util/modbus/functions.go
@@ -7,8 +7,15 @@ import (
 	"fmt"
 	"strconv"
 	"strings"
+	"time"
+
+	"github.com/cenkalti/backoff/v4"
 )
 
+func Backoff() *backoff.ExponentialBackOff {
+	return backoff.NewExponentialBackOff(backoff.WithInitialInterval(20*time.Millisecond), backoff.WithMaxElapsedTime(5*time.Second))
+}
+
 // decodeMask converts a bit mask in decimal or hex format to uint64
 func decodeMask(mask string) (uint64, error) {
 	mask = strings.ToLower(mask)
diff --git a/vehicle/niu.go b/vehicle/niu.go
index 114a8f3c..664c8e28 100644
--- a/vehicle/niu.go
+++ b/vehicle/niu.go
@@ -154,3 +154,17 @@ func (v *Niu) Range() (int64, error) {
 	res, err := v.apiG()
 	return res.Data.EstimatedMileage, err
 }
+
+var _ api.VehicleOdometer = (*Niu)(nil)
+
+// Odometer implements the api.VehicleOdometer interface
+func (v *Niu) Odometer() (float64, error) {
+	var res niu.Response
+
+	req, err := v.request(niu.ApiURI + "/overallTally?sn=" + v.serial)
+	if err == nil {
+		err = v.DoJSON(req, &res)
+	}
+
+	return res.Data.TotalMileage, err
+}
diff --git a/vehicle/niu/types.go b/vehicle/niu/types.go
index e4729d4c..5944f627 100644
--- a/vehicle/niu/types.go
+++ b/vehicle/niu/types.go
@@ -63,5 +63,8 @@ type Response struct {
 		}
 		// LeftTime         float32 `json:"leftTime,omitempty"`
 		EstimatedMileage int64 `json:"estimatedMileage,omitempty"`
+
+		// overallTally
+		TotalMileage float64 `json:"totalMileage,omitempty"`
 	}
 }
diff --git a/vehicle/tesla.go b/vehicle/tesla.go
index 21e7da42..59765df4 100644
--- a/vehicle/tesla.go
+++ b/vehicle/tesla.go
@@ -47,7 +47,7 @@ func NewTeslaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	}
 
 	if cc.Credentials.ID == "" {
-		return nil, errors.New("missing client id, see https://github.com/evcc-io/evcc/discussions/17501")
+		return nil, errors.New("missing client id, see https://docs.evcc.io/en/docs/devices/vehicles#tesla")
 	}
 
 	token, err := cc.Tokens.Token()


Test patch:
diff --git a/charger/easee_test.go b/charger/easee_test.go
index 29dd4965..809c28ef 100644
--- a/charger/easee_test.go
+++ b/charger/easee_test.go
@@ -2,11 +2,16 @@ package charger
 
 import (
 	"encoding/json"
+	"fmt"
+	"net/http"
 	"testing"
 	"time"
 
+	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/charger/easee"
 	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/request"
+	"github.com/jarcoal/httpmock"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -22,12 +27,18 @@ func createPayload(id easee.ObservationID, timestamp time.Time, dataType easee.D
 	return json.RawMessage(out)
 }
 
-func newEasee() Easee {
-	return Easee{
+func newEasee() *Easee {
+	log := util.NewLogger("easee")
+	e := Easee{
+		Helper:    request.NewHelper(log),
 		obsTime:   make(map[easee.ObservationID]time.Time),
-		log:       util.NewLogger("easee"),
+		log:       log,
 		startDone: func() {},
+		cmdC:      make(chan easee.SignalRCommandResponse),
+		obsC:      make(chan easee.Observation),
 	}
+	e.Client.Timeout = 500 * time.Millisecond //aggressive timeout to accelerate testing
+	return &e
 }
 
 func TestProductUpdate_IgnoreOutdatedProductUpdate(t *testing.T) {
@@ -49,3 +60,282 @@ func TestProductUpdate_IgnoreOutdatedProductUpdate(t *testing.T) {
 	assert.Equal(t, now, e.obsTime[easee.CHARGER_OP_MODE])
 	assert.Equal(t, 2, e.opMode)
 }
+
+func TestProductUpdate_InitialStateCheck(t *testing.T) {
+	now := time.Now().UTC().Truncate(0) //truncate removes sub nanos
+
+	e := newEasee()
+
+	assert.False(t, e.initialStatePresent)
+
+	tc := []struct {
+		obsId           easee.ObservationID
+		dataType        easee.DataType
+		value           string
+		expectInitState bool
+	}{
+		{easee.CHARGER_OP_MODE, easee.Integer, "2", false},
+		{easee.TOTAL_POWER, easee.Double, "11.0", false},
+		{easee.SESSION_ENERGY, easee.Double, "22.0", false},
+		{easee.LIFETIME_ENERGY, easee.Double, "1000.0", true},
+	}
+
+	for _, tc := range tc {
+		t.Logf("%+v", tc)
+
+		e.ProductUpdate(createPayload(tc.obsId, now, tc.dataType, tc.value))
+		assert.Equal(t, tc.expectInitState, e.initialStatePresent)
+	}
+}
+
+// TestInExpectedOpMode tests the inExpectedOpMode function with different scenarios
+func TestInExpectedOpMode(t *testing.T) {
+	tc := []struct {
+		opMode int
+		enable bool
+		expect bool
+	}{
+		{easee.ModeDisconnected, false, false},
+		{easee.ModeAwaitingAuthentication, false, true},
+		{easee.ModeAwaitingStart, false, true},
+		{easee.ModeOffline, false, false},
+
+		//enable cases
+		{easee.ModeAwaitingAuthentication, true, false},
+		{easee.ModeOffline, true, false},
+		{easee.ModeCharging, true, true},
+		{easee.ModeCompleted, true, true},
+		{easee.ModeAwaitingStart, true, true},
+		{easee.ModeReadyToCharge, true, true},
+	}
+	for _, tc := range tc {
+		t.Logf("%+v", tc)
+
+		e := newEasee()
+		e.opMode = tc.opMode
+		res := e.inExpectedOpMode(tc.enable)
+		assert.Equal(t, tc.expect, res)
+	}
+}
+
+func TestEasee_waitForTickResponse(t *testing.T) {
+	testCases := []struct {
+		name         string
+		expectedTick int64
+		cmdCValue    *easee.SignalRCommandResponse
+		expectedErr  error
+	}{
+		{
+			name:         "Success - Tick Found",
+			expectedTick: 123,
+			cmdCValue:    &easee.SignalRCommandResponse{Ticks: 123, WasAccepted: true},
+			expectedErr:  nil,
+		},
+		{
+			name:         "Success - Tick Found, but Rejected",
+			expectedTick: 456,
+			cmdCValue:    &easee.SignalRCommandResponse{Ticks: 456, WasAccepted: false},
+			expectedErr:  fmt.Errorf("command rejected: %d", 456),
+		},
+		{
+			name:         "Timeout",
+			expectedTick: 789,
+			expectedErr:  api.ErrTimeout,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Logf("%+v", tc)
+
+			e := newEasee()
+
+			// Set up the command channel for test and Easee to share
+			cmdC := make(chan easee.SignalRCommandResponse, 1) // make it buffered for ease of testing
+			e.cmdC = cmdC
+
+			if tc.cmdCValue != nil {
+				cmdC <- *tc.cmdCValue
+			}
+
+			err := e.waitForTickResponse(tc.expectedTick)
+
+			// Assert the result
+			if tc.expectedErr != nil {
+				assert.EqualError(t, err, tc.expectedErr.Error())
+			} else {
+				assert.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestEasee_postJsonAndWait(t *testing.T) {
+	const chargerID string = "TESTTEST"
+	const ticks int64 = 638798974487432600
+
+	settingsUri := fmt.Sprintf("%s/chargers/%s/settings", easee.API, chargerID)
+	commandUri := fmt.Sprintf("%s/chargers/%s/commands/resume_charging", easee.API, chargerID)
+
+	settingsReply := fmt.Sprintf("{\"device\":\"%s\",\"commandId\":48,\"ticks\":%d}", chargerID, ticks)
+
+	cmdResponse := easee.SignalRCommandResponse{
+		WasAccepted: true,
+		Ticks:       ticks,
+	}
+
+	testCases := []struct {
+		uri      string
+		httpRc   int
+		respBody string
+		cmdResp  *easee.SignalRCommandResponse
+		noop     bool
+		err      error
+	}{
+		{settingsUri, 200, "", nil, false, nil},                                   //sync reply
+		{settingsUri, 202, "[]", nil, true, nil},                                  //noop reply
+		{settingsUri, 202, "[" + settingsReply + "]", nil, false, api.ErrTimeout}, //timeout
+		{commandUri, 202, "{}", nil, true, nil},                                   //noop command reply
+		{commandUri, 202, settingsReply, &cmdResponse, false, nil},                //command reply
+		{commandUri, 400, "", nil, false, fmt.Errorf("invalid status: %d", 400)},  //unexpected result
+	}
+
+	for _, tc := range testCases {
+		t.Logf("%+v", tc)
+
+		e := newEasee()
+
+		httpmock.ActivateNonDefault(e.Client)
+		httpmock.RegisterResponder(http.MethodPost, tc.uri,
+			httpmock.NewStringResponder(tc.httpRc, tc.respBody))
+
+		if tc.cmdResp != nil {
+			go func() {
+				e.cmdC <- *tc.cmdResp
+			}()
+		}
+
+		noop, err := e.postJSONAndWait(tc.uri, nil)
+
+		assert.Equal(t, tc.noop, noop)
+		assert.Equal(t, tc.err, err)
+
+		httpmock.Reset()
+	}
+}
+
+func TestEasee_waitForChargerEnabledState(t *testing.T) {
+	testCases := []struct {
+		expEnabled  bool
+		updateState bool
+		sendObs     bool
+		expectErr   error
+	}{
+		{false, false, false, nil},           // short circuit, already in target state
+		{true, true, true, nil},              // normal flow
+		{true, false, false, api.ErrTimeout}, // missing state change
+		{true, true, false, nil},             // late landing state change (transition without Obs)
+	}
+
+	for _, tc := range testCases {
+		t.Logf("%+v", tc)
+
+		e := newEasee()
+		e.opMode = easee.ModeAwaitingAuthentication
+
+		if tc.updateState { // simulate state changes
+			go func() {
+				e.opMode = easee.ModeCharging // transition to charging
+				if tc.sendObs {
+					e.obsC <- easee.Observation{
+						ID: easee.CHARGER_OP_MODE,
+					}
+				}
+			}()
+		}
+
+		err := e.waitForChargerEnabledState(tc.expEnabled)
+
+		if tc.expectErr != nil {
+			assert.EqualError(t, err, tc.expectErr.Error())
+		} else {
+			assert.NoError(t, err)
+		}
+	}
+}
+
+func TestEasee_waitForDynamicChargerCurrent(t *testing.T) {
+	testCases := []struct {
+		expectedDcc float64
+		updateState bool
+		sendObs     bool
+		expectErr   error
+	}{
+		{6, false, false, nil},             // short circuit, already at target dcc
+		{32, true, true, nil},              // normal flow
+		{32, false, false, api.ErrTimeout}, // missing state change
+		{32, true, false, nil},             // late landing state change (transition without Obs)
+	}
+
+	for _, tc := range testCases {
+		t.Logf("%+v", tc)
+
+		e := newEasee()
+		e.dynamicChargerCurrent = 6
+
+		if tc.updateState { // simulate state changes
+			go func() {
+				e.dynamicChargerCurrent = 32 // transition to 32A
+				if tc.sendObs {
+					e.obsC <- easee.Observation{
+						ID:       easee.DYNAMIC_CHARGER_CURRENT,
+						DataType: easee.Double,
+						Value:    "32",
+					}
+				}
+			}()
+		}
+
+		err := e.waitForDynamicChargerCurrent(tc.expectedDcc)
+
+		if tc.expectErr != nil {
+			assert.EqualError(t, err, tc.expectErr.Error())
+		} else {
+			assert.NoError(t, err)
+		}
+	}
+}
+
+func TestEasee_MaxCurrent(t *testing.T) {
+	testCases := []struct {
+		targetCurrent int64
+		expectCurrent float64
+	}{
+		{6, 6},   // short circuit
+		{32, 16}, // target above max
+		{10, 10}, // normal case
+	}
+
+	for _, tc := range testCases {
+		t.Logf("%+v", tc)
+
+		e := newEasee()
+		e.charger = "CHARGERID"
+		e.maxChargerCurrent = 16
+		e.dynamicChargerCurrent = 6
+
+		uriPattern := fmt.Sprintf("=~%s.*", easee.API)
+
+		//register mock NoOp reply, suffices for this test case
+		httpmock.ActivateNonDefault(e.Client)
+		httpmock.RegisterResponder(http.MethodPost, uriPattern,
+			httpmock.NewStringResponder(202, "[]"))
+
+		err := e.MaxCurrent(tc.targetCurrent)
+
+		assert.NoError(t, err)
+		assert.Equal(t, tc.expectCurrent, e.current)
+		//TODO this fails, either current or dynamicChargerCurrent need to go
+		//assert.Equal(t, e.current, e.dynamicChargerCurrent)
+	}
+}
diff --git a/core/circuit/circuit_test.go b/core/circuit/circuit_test.go
index 9950f46d..172ad176 100644
--- a/core/circuit/circuit_test.go
+++ b/core/circuit/circuit_test.go
@@ -23,6 +23,7 @@ func circuitTests() []circuitTest {
 		{0, 0, 0, 0, 0, 0}, // =
 		{0, 0, 0, 0, 1, 1}, // +
 		{0, 0, 0, 0, 2, 1}, // +
+		{0, 0, 0, 1, 1, 1}, // =
 
 		// circuit 1 loaded
 		{0, 1, 0, 0, 0, 0}, // =
@@ -39,6 +40,9 @@ func circuitTests() []circuitTest {
 		{0, 2, 0, 2, 3, 1}, // +
 		{0, 2, 0, 2, 1, 1}, // -
 
+		{0, 1.1, 0, 2, 1, 1}, // -
+		{0, 1.1, 0, 1, 0, 0}, // -
+
 		// parent loaded
 		{1, 0, 0, 0, 0, 0}, // =
 		{1, 0, 0, 0, 1, 0}, // +
@@ -47,13 +51,18 @@ func circuitTests() []circuitTest {
 		{1, 0, 0, 2, 1, 1}, // -
 
 		// parent overloaded
-		{2, 0, 0, 0, 0, 0},   // =
-		{2, 0, 0, 0, 1, 0},   // +
-		{2, 0, 0, 1, 1, 0},   // =
-		{2, 0, 0, 2, 2, 1},   // =
-		{2, 0, 0, 2, 3, 1},   // +
-		{2, 0, 0, 2, 1, 1},   // -
+		{2, 0, 0, 0, 0, 0}, // =
+		{2, 0, 0, 0, 1, 0}, // +
+		{2, 0, 0, 1, 1, 0}, // =
+		{2, 0, 0, 2, 2, 1}, // =
+		{2, 0, 0, 2, 3, 1}, // +
+		{2, 0, 0, 2, 1, 1}, // -
+
+		{1.1, 0, 0, 2, 1, 1}, // -
 		{1.1, 0, 0, 1, 0, 0}, // -
+
+		// negative load
+		{-1, -1, 0, 0, 2, 2}, // +
 	}
 }
 
@@ -83,7 +92,7 @@ func TestCircuitPower(t *testing.T) {
 		cm2.EXPECT().CurrentPower().Return(tc.c2, nil)
 		require.NoError(t, pc.Update(nil))
 
-		assert.Equal(t, tc.res, c1.ValidatePower(tc.old, tc.new, true), tc)
+		assert.Equal(t, tc.res, c1.ValidatePower(tc.old, tc.new), tc)
 
 		ctrl.Finish()
 	}
@@ -125,7 +134,7 @@ func TestCircuitCurrents(t *testing.T) {
 		cm2.MockPhaseCurrents.EXPECT().Currents().Return(tc.c2, tc.c2, tc.c2, nil)
 		require.NoError(t, pc.Update(nil))
 
-		assert.Equal(t, tc.res, c1.ValidateCurrent(tc.old, tc.new, true), tc)
+		assert.Equal(t, tc.res, c1.ValidateCurrent(tc.old, tc.new), tc)
 
 		ctrl.Finish()
 	}
diff --git a/tariff/types_test.go b/tariff/types_test.go
new file mode 100644
index 00000000..0ddb8ab6
--- /dev/null
+++ b/tariff/types_test.go
@@ -0,0 +1,34 @@
+package tariff
+
+import (
+	"testing"
+	"time"
+
+	"github.com/benbjohnson/clock"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestFromTo(t *testing.T) {
+	tc := []struct {
+		from, to, now int
+		active        bool
+	}{
+		{0, 0, 0, true},
+		{1, 2, 1, true},
+		{1, 2, 2, true},
+		{1, 2, 0, false},
+		{1, 2, 3, false},
+		{22, 2, 21, false},
+		{22, 2, 22, true},
+		{22, 2, 2, true},
+		{22, 2, 3, false},
+	}
+
+	for _, tc := range tc {
+		clock := clock.NewMock()
+		clock.Add(time.Duration(tc.now) * time.Hour)
+
+		ft := FromTo{tc.from, tc.to}
+		assert.Equal(t, tc.active, ft.IsActive(tc.now), "expected %v")
+	}
+}