Repository: evcc-io/evcc (go)

Issue:
Shelly EM: falsche Energy Werte
### Describe the bug

Beim Shelly EM (Gen1) (als Charger an meiner Wärmepumpe) werden beide Energiewerte (total und total_returned) miteinander verrechnet.


 Beim Shelly PlugS (Gen1) (ext Meter) ist mir nicht klar, wo der Energiewert herkommt. `total` ist es nicht. 


Fehler tritt erst mit dem letzten Nightly auf.
Liegt wohl an #20570 


### Steps to reproduce

.

### Configuration details

```yaml
Shelly EM (config UI)
db:10 {Type:template Title: Icon: Product:} map[channel:0 heating:true host:192.168.178.21 icon:heatpump integrateddevice:true standbypower:10 template:shelly]

Shelly PlugS (yaml)
- name: extW #Waschen
  type: template
  template: shelly-1pm
  usage: charge
  host: 192.168.178.72
```

### Log details

```text
Shelly EM
[shelly] TRACE 2025/04/19 05:37:06 GET http://192.168.178.21/shelly
[shelly] TRACE 2025/04/19 05:37:06 {"type":"SHEM","mac":"C45BBE77DE81","auth":false,"fw":"20230913-114150/v1.14.0-gcb84623","discoverable":true,"longid":1,"num_outputs":1,"num_meters":0,"num_emeters":2,"report_period":1}
[shelly] TRACE 2025/04/19 05:37:06 GET http://192.168.178.21/status
[shelly] TRACE 2025/04/19 05:37:06 {"wifi_sta":{"connected":true,"ssid":"KAS1","ip":"192.168.178.21","rssi":-63},"cloud":{"enabled":false,"connected":false},"mqtt":{"connected":true},"time":"05:37","unixtime":1745033824,"serial":17295,"has_update":false,"mac":"C45BBE77DE81","cfg_changed_cnt":1,"actions_stats":{"skipped":0},"relays":[{"ison":true,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"overpower":false,"is_valid":true,"source":"http"}],"emeters":[{"power":0.00,"reactive":0.00,"pf":0.00,"voltage":232.65,"is_valid":true,"total":1268868.6,"total_returned":1390938.0},{"power":-40.63,"reactive":-22.93,"pf":0.87,"voltage":232.65,"is_valid":true,"total":1299646.8,"total_returned":1402502.7}],"update":{"status":"idle","has_update":false,"new_version":"20230913-114150/v1.14.0-gcb84623","old_version":"20230913-114150/v1.14.0-gcb84623","beta_version":"20231107-164916/v1.14.1-rc1-g0617c15"},"ram_total":51064,"ram_free":34644,"fs_size":233681,"fs_free":156373,"uptime":2167167}
[shelly] TRACE 2025/04/19 05:37:06 GET http://192.168.178.21/relay/0
[shelly] TRACE 2025/04/19 05:37:06 {"ison":true,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"overpower":false,"is_valid":true,"source":"http"}
Power:         0W
Energy:        -122.1kWh
Charge status: B
Enabled:       true
Features:      [IntegratedDevice Heating]

Shelly PlugS
[shelly] TRACE 2025/04/19 05:54:58 GET http://192.168.178.72/shelly
[shelly] TRACE 2025/04/19 05:54:58 {"type":"SHPLG2-1","mac":"C45BBEE308F1","auth":false,"fw":"20230913-113610/v1.14.0-gcb84623","discoverable":true,"num_outputs":1,"num_meters":1}
[shelly] TRACE 2025/04/19 05:54:58 GET http://192.168.178.72/status
[shelly] TRACE 2025/04/19 05:54:58 {"wifi_sta":{"connected":true,"ssid":"KAS1","ip":"192.168.178.72","rssi":-47},"cloud":{"enabled":false,"connected":false},"mqtt":{"connected":true},"time":"05:54","unixtime":1745034898,"serial":30127,"has_update":false,"mac":"C45BBEE308F1","cfg_changed_cnt":1,"actions_stats":{"skipped":0},"relays":[{"ison":true,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"overpower":false,"source":"input"}],"meters":[{"power":0.00,"overpower":0.00,"is_valid":true,"timestamp":1745042098,"counters":[0.000, 0.000, 0.000],"total":1310750}],"update":{"status":"idle","has_update":false,"new_version":"20230913-113610/v1.14.0-gcb84623","old_version":"20230913-113610/v1.14.0-gcb84623","beta_version":"20231107-164401/v1.14.1-rc1-g0617c15"},"ram_total":52096,"ram_free":39912,"fs_size":233681,"fs_free":165409,"uptime":6112636}
Power:  0W
Energy: 21.8kWh
```

### What type of operating system or environment does evcc run on?

Linux

### External automation

- [x] I have made sure that no external automation like HomeAssistant or Node-RED is active or accessing any of the mentioned devices when this issue occurs.

### Nightly build

- [x] I have verified that the issue is reproducible with the latest nightly build

### Version

0.203.1 (8fb06f4c)

Pull request description:
Shelly: Gen1 API - remove offsetting energies - keep Total only
Fixes #20718

evcc expects only the energy meter in the positive power flow direction.

Patch:
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/core/circuit/circuit.go b/core/circuit/circuit.go
index c3d3f398..d8e39226 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)
 		}
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..f0ebac21 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"
 )
 
@@ -1461,7 +1462,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 +1503,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/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/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/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/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/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/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)
+	}
+}