Repository: evcc-io/evcc
Pull request #12696 (2024-03-05 18:29:00)

Plexlog template
Bestätigung durch Custom Meter steht noch aus.
https://github.com/evcc-io/evcc/discussions/11661



andig: Was ist Plexlog und warum brauchen wir das?
VolkerK62: Eine Datenmanager ähnlich Solarlog

Patch:
diff --git a/assets/css/app.css b/assets/css/app.css
index e8911ac0..10b9def5 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -39,6 +39,8 @@
 
 	--evcc-grid: var(--bs-gray-dark);
 	--evcc-self: var(--evcc-dark-green);
+	--evcc-pv: var(--evcc-dark-green);
+	--evcc-battery: var(--evcc-darker-green);
 	--evcc-export: var(--evcc-yellow);
 	--evcc-background: var(--bs-gray-bright);
 	--evcc-box: var(--bs-white);
diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue
index c0e01a22..857044bf 100644
--- a/assets/js/components/Energyflow/Energyflow.vue
+++ b/assets/js/components/Energyflow/Energyflow.vue
@@ -9,7 +9,8 @@
 			<Visualization
 				class="col-12 mb-3 mb-md-4"
 				:gridImport="gridImport"
-				:selfConsumption="selfConsumption"
+				:selfPv="selfPv"
+				:selfBattery="selfBattery"
 				:loadpoints="loadpointsCompact"
 				:pvExport="pvExport"
 				:batteryCharge="batteryCharge"
@@ -25,8 +26,14 @@
 			<div ref="detailsInner" class="details-inner row">
 				<div class="col-12 d-flex justify-content-between pt-2 mb-4">
 					<div class="d-flex flex-nowrap align-items-center text-truncate">
-						<span class="color-self me-2"
-							><shopicon-filled-square></shopicon-filled-square
+						<span class="me-2 legend-self"
+							><shopicon-filled-square
+								class="color-pv legend-pv"
+							></shopicon-filled-square>
+							<shopicon-filled-square
+								v-if="selfBattery > 0"
+								:class="`color-battery legend-battery legend-battery--${selfPv > 0 ? 'mixed' : 'only'}`"
+							></shopicon-filled-square
 						></span>
 						<span class="text-nowrap text-truncate">
 							{{ $t("main.energyflow.selfConsumption") }}
@@ -39,16 +46,16 @@
 						<span class="text-nowrap text-truncate">
 							{{ $t("main.energyflow.gridImport") }}
 						</span>
-						<span class="color-grid ms-2"
-							><shopicon-filled-square></shopicon-filled-square
+						<span class="ms-2"
+							><shopicon-filled-square class="legend-grid"></shopicon-filled-square
 						></span>
 					</div>
 					<div v-else class="d-flex flex-nowrap align-items-center text-truncate">
 						<span class="text-nowrap text-truncate">
 							{{ $t("main.energyflow.pvExport") }}
 						</span>
-						<span class="color-export ms-2"
-							><shopicon-filled-square></shopicon-filled-square
+						<span class="ms-2"
+							><shopicon-filled-square class="legend-export"></shopicon-filled-square
 						></span>
 					</div>
 				</div>
@@ -232,10 +239,14 @@ export default {
 		batteryHold: function () {
 			return this.batteryMode === "hold";
 		},
-		selfConsumption: function () {
-			const ownPower = this.batteryDischarge + this.pvProduction;
-			const consumption = this.homePower + this.batteryCharge + this.loadpointsPower;
-			return Math.min(ownPower, consumption);
+		consumption: function () {
+			return this.homePower + this.batteryCharge + this.loadpointsPower;
+		},
+		selfPv: function () {
+			return Math.min(this.pvProduction, this.consumption);
+		},
+		selfBattery: function () {
+			return Math.min(this.batteryDischarge, this.consumption - this.selfPv);
 		},
 		activeLoadpoints: function () {
 			return this.loadpointsCompact.filter((lp) => lp.charging);
@@ -259,7 +270,7 @@ export default {
 			return Math.max(0, this.gridPower * -1);
 		},
 		powerInKw: function () {
-			return Math.max(this.gridImport, this.selfConsumption, this.pvExport) >= 1000;
+			return Math.max(this.gridImport, this.selfPv, this.selfBattery, this.pvExport) >= 1000;
 		},
 		inPower: function () {
 			return this.gridImport + this.pvProduction + this.batteryDischarge;
@@ -353,10 +364,28 @@ export default {
 .color-grid {
 	color: var(--evcc-grid);
 }
-.color-self {
-	color: var(--evcc-self);
-}
 .color-export {
 	color: var(--evcc-export);
 }
+.legend-grid {
+	color: var(--evcc-grid);
+}
+.legend-export {
+	color: var(--evcc-export);
+}
+.legend-pv {
+	color: var(--evcc-pv);
+}
+.legend-self {
+	position: relative;
+}
+.legend-battery {
+	position: absolute;
+	top: 0;
+	left: 0;
+	color: var(--evcc-battery);
+}
+.legend-battery--mixed {
+	clip-path: polygon(100% 0, 100% 100%, 0 100%);
+}
 </style>
diff --git a/assets/js/components/Energyflow/Visualization.vue b/assets/js/components/Energyflow/Visualization.vue
index cf5936cc..a5f90fab 100644
--- a/assets/js/components/Energyflow/Visualization.vue
+++ b/assets/js/components/Energyflow/Visualization.vue
@@ -19,14 +19,22 @@
 			<div class="label-scale-name">In</div>
 		</div>
 		<div ref="site_progress" class="site-progress">
+			<div class="site-progress-bar self-pv" :style="{ width: widthTotal(selfPvAdjusted) }">
+				<AnimatedNumber
+					v-if="selfPv && visualizationReady"
+					class="power"
+					:to="selfPv"
+					:format="fmtBarValue"
+				/>
+			</div>
 			<div
-				class="site-progress-bar self-consumption"
-				:style="{ width: widthTotal(selfConsumptionAdjusted) }"
+				class="site-progress-bar self-battery"
+				:style="{ width: widthTotal(selfBatteryAdjusted) }"
 			>
 				<AnimatedNumber
-					v-if="selfConsumption && visualizationReady"
+					v-if="selfBattery && visualizationReady"
 					class="power"
-					:to="selfConsumption"
+					:to="selfBattery"
 					:format="fmtBarValue"
 				/>
 			</div>
@@ -98,7 +106,8 @@ export default {
 	mixins: [formatter],
 	props: {
 		gridImport: { type: Number, default: 0 },
-		selfConsumption: { type: Number, default: 0 },
+		selfPv: { type: Number, default: 0 },
+		selfBattery: { type: Number, default: 0 },
 		pvExport: { type: Number, default: 0 },
 		loadpoints: { type: Array, default: () => [] },
 		batteryCharge: { type: Number, default: 0 },
@@ -116,19 +125,27 @@ export default {
 			return this.applyThreshold(this.pvExport);
 		},
 		totalRaw: function () {
-			return this.gridImport + this.selfConsumption + this.pvExport;
+			return this.gridImport + this.selfPv + this.selfBattery + this.pvExport;
 		},
 		gridImportAdjusted: function () {
 			return this.applyThreshold(this.gridImport);
 		},
-		selfConsumptionAdjusted: function () {
-			return this.applyThreshold(this.selfConsumption);
+		selfPvAdjusted: function () {
+			return this.applyThreshold(this.selfPv);
+		},
+		selfBatteryAdjusted: function () {
+			return this.applyThreshold(this.selfBattery);
 		},
 		pvExportAdjusted: function () {
 			return this.applyThreshold(this.pvExport);
 		},
 		totalAdjusted: function () {
-			return this.gridImportAdjusted + this.selfConsumptionAdjusted + this.pvExportAdjusted;
+			return (
+				this.gridImportAdjusted +
+				this.selfPvAdjusted +
+				this.selfBatteryAdjusted +
+				this.pvExportAdjusted
+			);
 		},
 		visualizationReady: function () {
 			return this.totalAdjusted > 0 && this.width > 0;
@@ -231,10 +248,16 @@ html.dark .grid-import {
 	color: var(--bs-dark);
 }
 
-.self-consumption {
-	background-color: var(--evcc-self);
+.self-pv {
+	background-color: var(--evcc-pv);
 	color: var(--bs-dark);
 }
+
+.self-battery {
+	background-color: var(--evcc-battery);
+	color: var(--bs-dark);
+}
+
 .pv-export {
 	background-color: var(--evcc-export);
 	color: var(--bs-dark);
diff --git a/charger/bender.go b/charger/bender.go
index d172b1ce..d50bc12e 100644
--- a/charger/bender.go
+++ b/charger/bender.go
@@ -289,7 +289,11 @@ func (wb *BenderCC) getPhaseValues(reg uint16, divider float64) (float64, float6
 
 	var res [3]float64
 	for i := range res {
-		res[i] = float64(binary.BigEndian.Uint32(b[4*i:])) / divider
+		u32 := binary.BigEndian.Uint32(b[4*i:])
+		if u32 == math.MaxUint32 {
+			u32 = 0
+		}
+		res[i] = float64(u32) / divider
 	}
 
 	return res[0], res[1], res[2], nil
diff --git a/cmd/setup.go b/cmd/setup.go
index bae6a69f..02ee0128 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -542,7 +542,7 @@ func configureHEMS(conf config.Typed, site *core.Site, httpd *server.HTTPd) erro
 func configureMDNS(conf networkConfig) error {
 	host := strings.TrimSuffix(conf.Host, ".local")
 
-	zc, err := zeroconf.RegisterProxy("EV Charge Controller", "_http._tcp", "local.", conf.Port, host, nil, []string{}, nil)
+	zc, err := zeroconf.RegisterProxy("evcc", "_http._tcp", "local.", conf.Port, host, nil, []string{"path=/"}, nil)
 	if err != nil {
 		return fmt.Errorf("mDNS announcement: %w", err)
 	}
diff --git a/core/site.go b/core/site.go
index 2d4e82ee..8e6c9a44 100644
--- a/core/site.go
+++ b/core/site.go
@@ -210,7 +210,7 @@ func NewSiteFromConfig(
 	// revert battery mode on shutdown
 	shutdown.Register(func() {
 		if mode := site.GetBatteryMode(); batteryModeModified(mode) {
-			if err := site.updateBatteryMode(api.BatteryNormal); err != nil {
+			if err := site.applyBatteryMode(api.BatteryNormal); err != nil {
 				site.log.ERROR.Println("battery mode:", err)
 			}
 		}
@@ -462,19 +462,19 @@ func (site *Site) updateBatteryMeters() error {
 
 	for i, meter := range site.batteryMeters {
 		power, err := backoff.RetryWithData(meter.CurrentPower, bo())
-		if err == nil {
-			site.batteryPower += power
-			if len(site.batteryMeters) > 1 {
-				site.log.DEBUG.Printf("battery %d power: %.0fW", i+1, power)
-			}
-		} else {
+		if err != nil {
 			// power is required- return on error
 			return fmt.Errorf("battery %d power: %v", i+1, err)
 		}
 
+		site.batteryPower += power
+		if len(site.batteryMeters) > 1 {
+			site.log.DEBUG.Printf("battery %d power: %.0fW", i+1, power)
+		}
+
 		// battery energy (discharge)
 		var energy float64
-		if m, ok := meter.(api.MeterEnergy); err == nil && ok {
+		if m, ok := meter.(api.MeterEnergy); ok {
 			energy, err = m.TotalEnergy()
 			if err == nil {
 				totalEnergy += energy
@@ -768,21 +768,9 @@ func (site *Site) update(lp updater) {
 		flexiblePower = site.prioritizer.GetChargePowerFlexibility(lp)
 	}
 
-	var smartCostActive bool
-	if tariff := site.GetTariff(PlannerTariff); tariff != nil && tariff.Type() != api.TariffTypePriceStatic {
-		rates, err := tariff.Rates()
-
-		var rate api.Rate
-		if err == nil {
-			rate, err = rates.Current(time.Now())
-		}
-
-		if err == nil {
-			limit := lp.GetSmartCostLimit()
-			smartCostActive = limit != 0 && rate.Price <= limit
-		} else {
-			site.log.ERROR.Println("smartCost:", err)
-		}
+	smartCostActive, err := site.smartCostActive(lp)
+	if err != nil {
+		site.log.ERROR.Println("smartCost:", err)
 	}
 
 	if sitePower, batteryBuffered, batteryStart, err := site.sitePower(totalChargePower, flexiblePower); err == nil {
@@ -810,12 +798,8 @@ func (site *Site) update(lp updater) {
 		site.log.ERROR.Println(err)
 	}
 
-	if batMode := site.GetBatteryMode(); site.batteryDischargeControl {
-		if mode := site.determineBatteryMode(site.Loadpoints(), smartCostActive); mode != batMode {
-			if err := site.updateBatteryMode(mode); err != nil {
-				site.log.ERROR.Println("battery mode:", err)
-			}
-		}
+	if site.batteryDischargeControl {
+		site.updateBatteryMode()
 	}
 
 	site.stats.Update(site)
diff --git a/core/site_api.go b/core/site_api.go
index 344dca19..d22d3afd 100644
--- a/core/site_api.go
+++ b/core/site_api.go
@@ -304,7 +304,7 @@ func (site *Site) SetBatteryDischargeControl(val bool) error {
 	if site.GetBatteryDischargeControl() != val {
 		// reset to normal when disabling
 		if mode := site.GetBatteryMode(); !val && batteryModeModified(mode) {
-			if err := site.updateBatteryMode(api.BatteryNormal); err != nil {
+			if err := site.applyBatteryMode(api.BatteryNormal); err != nil {
 				return err
 			}
 		}
diff --git a/core/site_battery.go b/core/site_battery.go
index c688f547..ecc51d2c 100644
--- a/core/site_battery.go
+++ b/core/site_battery.go
@@ -1,6 +1,8 @@
 package core
 
 import (
+	"time"
+
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/core/keys"
 	"github.com/evcc-io/evcc/core/loadpoint"
@@ -30,17 +32,7 @@ func (site *Site) SetBatteryMode(batMode api.BatteryMode) {
 	}
 }
 
-func (site *Site) determineBatteryMode(loadpoints []loadpoint.API, smartCostActive bool) api.BatteryMode {
-	for _, lp := range loadpoints {
-		if lp.GetStatus() == api.StatusC && (smartCostActive || lp.IsFastChargingActive()) {
-			return api.BatteryHold
-		}
-	}
-
-	return api.BatteryNormal
-}
-
-func (site *Site) updateBatteryMode(mode api.BatteryMode) error {
+func (site *Site) applyBatteryMode(mode api.BatteryMode) error {
 	// update batteries
 	for _, meter := range site.batteryMeters {
 		if batCtrl, ok := meter.(api.BatteryController); ok {
@@ -55,3 +47,46 @@ func (site *Site) updateBatteryMode(mode api.BatteryMode) error {
 
 	return nil
 }
+
+func (site *Site) smartCostActive(lp loadpoint.API) (bool, error) {
+	tariff := site.GetTariff(PlannerTariff)
+	if tariff == nil || tariff.Type() == api.TariffTypePriceStatic {
+		return false, nil
+	}
+
+	rates, err := tariff.Rates()
+	if err != nil {
+		return false, err
+	}
+
+	rate, err := rates.Current(time.Now())
+	if err != nil {
+		return false, err
+	}
+
+	limit := lp.GetSmartCostLimit()
+	return limit != 0 && rate.Price <= limit, nil
+}
+
+func (site *Site) updateBatteryMode() {
+	mode := api.BatteryNormal
+
+	for _, lp := range site.Loadpoints() {
+		smartCostActive, err := site.smartCostActive(lp)
+		if err != nil {
+			site.log.ERROR.Println("smart cost:", err)
+			continue
+		}
+
+		if lp.GetStatus() == api.StatusC && (smartCostActive || lp.IsFastChargingActive()) {
+			mode = api.BatteryHold
+			break
+		}
+	}
+
+	if batMode := site.GetBatteryMode(); mode != batMode {
+		if err := site.applyBatteryMode(mode); err != nil {
+			site.log.ERROR.Println("battery mode:", err)
+		}
+	}
+}
diff --git a/provider/mqtt/client.go b/provider/mqtt/client.go
index cd2f930c..42c3abb7 100644
--- a/provider/mqtt/client.go
+++ b/provider/mqtt/client.go
@@ -123,8 +123,9 @@ func (m *Client) ConnectionHandler(client paho.Client) {
 
 // Cleanup recursively removes a topic
 func (m *Client) Cleanup(topic string, retained bool) error {
-	if !m.Client.Subscribe(topic, m.Qos, func(c paho.Client, msg paho.Message) {
-		if len(msg.Payload()) == 0 {
+	statusTopic := topic + "/status"
+	if !m.Client.Subscribe(topic+"/#", m.Qos, func(c paho.Client, msg paho.Message) {
+		if len(msg.Payload()) == 0 || msg.Topic() == statusTopic {
 			return
 		}
 
diff --git a/server/mqtt.go b/server/mqtt.go
index a302374a..97561dba 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -33,7 +33,7 @@ func NewMQTT(root string, site site.API) (*MQTT, error) {
 	}
 	m.publisher = m.publishString
 
-	err := m.Handler.Cleanup(m.root+"/#", true)
+	err := m.Handler.Cleanup(m.root, true)
 	if err == nil {
 		err = m.Listen(site)
 	}
diff --git a/tariff/groupe-e.go b/tariff/groupe-e.go
new file mode 100644
index 00000000..00a3a486
--- /dev/null
+++ b/tariff/groupe-e.go
@@ -0,0 +1,108 @@
+package tariff
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"slices"
+	"sync"
+	"time"
+
+	"github.com/cenkalti/backoff/v4"
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/request"
+)
+
+type GroupeE struct {
+	*embed
+	log  *util.Logger
+	data *util.Monitor[api.Rates]
+}
+
+var _ api.Tariff = (*GroupeE)(nil)
+
+func init() {
+	registry.Add("groupe-e", NewGroupeEFromConfig)
+}
+
+func NewGroupeEFromConfig(other map[string]interface{}) (api.Tariff, error) {
+	var cc struct {
+		embed `mapstructure:",squash"`
+	}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	t := &GroupeE{
+		embed: &cc.embed,
+		log:   util.NewLogger("groupe-e"),
+		data:  util.NewMonitor[api.Rates](2 * time.Hour),
+	}
+
+	done := make(chan error)
+	go t.run(done)
+	err := <-done
+
+	return t, err
+}
+
+func (t *GroupeE) run(done chan error) {
+	var once sync.Once
+	bo := newBackoff()
+	client := request.NewHelper(t.log)
+
+	for ; true; <-time.Tick(time.Hour) {
+		var res []struct {
+			StartTimestamp time.Time `json:"start_timestamp"`
+			EndTimestamp   time.Time `json:"end_timestamp"`
+			VarioPlus      float64   `json:"vario_plus"`
+		}
+
+		start := time.Now().Truncate(time.Hour)
+		uri := fmt.Sprintf("https://api.tariffs.groupe-e.ch/v1/tariffs?start_timestamp=%s&end_timestamp=%s", start.Format(time.RFC3339), start.Add(48*time.Hour).Format(time.RFC3339))
+
+		if err := backoff.Retry(func() error {
+			err := client.GetJSON(uri, &res)
+			var se request.StatusError
+			if errors.As(err, &se) && se.HasStatus(http.StatusBadRequest) {
+				return backoff.Permanent(se)
+			}
+			return err
+		}, bo); err != nil {
+			once.Do(func() { done <- err })
+
+			t.log.ERROR.Println(err)
+			continue
+		}
+
+		data := make(api.Rates, 0, len(res))
+		for _, r := range res {
+			ar := api.Rate{
+				Start: r.StartTimestamp.Local(),
+				End:   r.EndTimestamp.Local(),
+				Price: t.totalPrice(r.VarioPlus / 1e2), // Rp/kWh
+			}
+			data = append(data, ar)
+		}
+		data.Sort()
+
+		t.data.Set(data)
+		once.Do(func() { close(done) })
+	}
+}
+
+// Rates implements the api.Tariff interface
+func (t *GroupeE) Rates() (api.Rates, error) {
+	var res api.Rates
+	err := t.data.GetFunc(func(val api.Rates) {
+		res = slices.Clone(val)
+	})
+	return res, err
+}
+
+// Type implements the api.Tariff interface
+func (t *GroupeE) Type() api.TariffType {
+	return api.TariffTypePriceForecast
+}
diff --git a/tariff/ngeso.go b/tariff/ngeso.go
index a04a7d98..bd51d472 100644
--- a/tariff/ngeso.go
+++ b/tariff/ngeso.go
@@ -76,26 +76,23 @@ func (t *Ngeso) run(done chan error) {
 
 	// Data updated by ESO every half hour, but we only need data every hour to stay current.
 	for ; true; <-time.Tick(time.Hour) {
-		var carbonResponse ngeso.CarbonForecastResponse
-		if err := backoff.Retry(func() error {
-			var err error
-			carbonResponse, err = tReq.DoRequest(client)
-
-			// Consider whether errors.As would be more appropriate if this needs to start dealing with wrapped errors.
-			if se, ok := err.(request.StatusError); ok && se.HasStatus(http.StatusBadRequest) {
-				// Catch cases where we're sending completely incorrect data (usually the result of a bad region).
-				return backoff.Permanent(se)
+		res, err := backoff.RetryWithData(func() (ngeso.CarbonForecastResponse, error) {
+			res, err := tReq.DoRequest(client)
+			var se request.StatusError
+			if errors.As(err, &se) && se.HasStatus(http.StatusBadRequest) {
+				return nil, backoff.Permanent(se)
 			}
-			return err
-		}, bo); err != nil {
+			return res, err
+		}, bo)
+		if err != nil {
 			once.Do(func() { done <- err })
 
 			t.log.ERROR.Println(err)
 			continue
 		}
 
-		data := make(api.Rates, 0, len(carbonResponse.Results()))
-		for _, r := range carbonResponse.Results() {
+		data := make(api.Rates, 0, len(res.Results()))
+		for _, r := range res.Results() {
 			ar := api.Rate{
 				Start: r.ValidityStart.Time,
 				End:   r.ValidityEnd.Time,
diff --git a/templates/definition/meter/ac-elwa-2.yaml b/templates/definition/meter/ac-elwa-2.yaml
new file mode 100644
index 00000000..fa6dbd6e
--- /dev/null
+++ b/templates/definition/meter/ac-elwa-2.yaml
@@ -0,0 +1,20 @@
+template: ac-elwa-2
+products:
+  - brand: my-PV
+    description:
+      generic: AC ELWA 2
+params:
+  - name: usage
+    choice: ["aux"]
+  - name: host
+render: |
+  type: custom
+  power:
+    source: http
+    uri: http://{{ .host }}/data.jsn
+    jq: .power_elwa2
+  soc:
+    source: http
+    uri: http://{{ .host }}/data.jsn
+    jq: .temp1
+    scale: 0.1
diff --git a/templates/definition/meter/plexlog.yaml b/templates/definition/meter/plexlog.yaml
new file mode 100644
index 00000000..d10e336b
--- /dev/null
+++ b/templates/definition/meter/plexlog.yaml
@@ -0,0 +1,72 @@
+template: plexlog
+products:
+  - description:
+      generic: Plexlog
+requirements:
+  description:
+    de: |
+      Die Werte werden ca. alle 15 Sekunden aktualisiert, deshalb sollte das evcc `interval` nicht kleiner als 30 Sekunden gewählt werden.
+    en: |
+      The values are updated approximately every 15 seconds, hence the evcc `interval` should not be less than 30 seconds.
+params:
+  - name: usage
+    choice: ["grid", "pv", "battery"]
+    allinone: true
+  - name: host
+  - name: port
+    default: 503
+render: |
+  type: custom
+  {{- if eq .usage "grid" }}
+  power:
+    source: calc
+    add:
+    - source: modbus
+      uri: {{ .host }}:{{ .port }}
+      id: 1
+      register:
+        address: 0 # Erzeugung
+        type: input
+        decode: int32
+      scale: -1
+      timeout: 30s
+    - source: modbus
+      uri: {{ .host }}:{{ .port }}
+      id: 1
+      register:
+        address: 2 # Verbrauch
+        type: input
+        decode: int32
+      timeout: 30s
+  {{- end }}
+  {{- if eq .usage "pv" }}
+  power:
+    source: modbus
+    uri: {{ .host }}:{{ .port }}
+    id: 1
+    register:
+      address: 0 # Erzeugung
+      type: input
+      decode: int32
+    timeout: 30s
+  {{- end }}
+  {{- if eq .usage "battery" }}
+  power:
+    source: modbus
+    uri: {{ .host }}:{{ .port }}
+    id: 1
+    register:
+      address: 37 # Batterie Leistung
+      type: input
+      decode: int32
+    timeout: 30s
+  soc:
+    source: modbus
+    uri: {{ .host }}:{{ .port }}
+    id: 1
+    register:
+      address: 36 # Batterie SOC
+      type: input
+      decode: uint16
+    timeout: 30s
+  {{- end }}
diff --git a/templates/definition/meter/thor.yaml b/templates/definition/meter/thor.yaml
index 1724103f..979b2fdf 100644
--- a/templates/definition/meter/thor.yaml
+++ b/templates/definition/meter/thor.yaml
@@ -12,4 +12,4 @@ render: |
   power:
     source: http
     uri: http://{{ .host }}/data.jsn
-    jq: .power_act
+    jq: if .power_act == null then 0 else .power_act end + if .power_ac9 == null then 0 else .power_ac9 end
diff --git a/templates/definition/tariff/groupe-e.yaml b/templates/definition/tariff/groupe-e.yaml
new file mode 100644
index 00000000..3e437aa3
--- /dev/null
+++ b/templates/definition/tariff/groupe-e.yaml
@@ -0,0 +1,10 @@
+template: groupe-e
+products:
+  - brand: Groupe E
+    description:
+      generic: Vario Plus
+params:
+  - preset: tariff-base
+render: |
+  type: groupe-e
+  {{ include "tariff-base" . }}
diff --git a/util/cloud/client.go b/util/cloud/client.go
index ce89d113..84a5ec2a 100644
--- a/util/cloud/client.go
+++ b/util/cloud/client.go
@@ -1,10 +1,12 @@
 package cloud
 
 import (
+	"crypto/tls"
 	_ "embed"
 
 	"github.com/evcc-io/evcc/util"
 	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials"
 )
 
 var (
@@ -19,7 +21,8 @@ func Connection() (*grpc.ClientConn, error) {
 	}
 
 	var err error
-	conn, err = grpc.Dial(host)
+	creds := credentials.NewTLS(new(tls.Config))
+	conn, err = grpc.Dial(host, grpc.WithTransportCredentials(creds))
 
 	return conn, err
 }
diff --git a/vehicle/jlr.go b/vehicle/jlr.go
index 7731f745..6566913e 100644
--- a/vehicle/jlr.go
+++ b/vehicle/jlr.go
@@ -106,6 +106,8 @@ func (v *JLR) RegisterDevice(log *util.Logger, user, device string, t jlr.Token)
 		"Accept":                  "application/json",
 		"X-Device-Id":             device,
 		"x-telematicsprogramtype": "jlrpy",
+		"x-App-Id":                "ICR_JAGUAR",
+		"x-App-Secret":            "018dd168-6271-707f-9fd4-aed2bf76905e",
 	})
 	if err == nil {
 		_, err = c.DoBody(req)
diff --git a/vehicle/jlr/api.go b/vehicle/jlr/api.go
index c5260b1e..9e66cce5 100644
--- a/vehicle/jlr/api.go
+++ b/vehicle/jlr/api.go
@@ -36,6 +36,8 @@ func NewAPI(log *util.Logger, device string, ts oauth2.TokenSource) *API {
 					"Authorization":           fmt.Sprintf("Bearer %s", token.AccessToken),
 					"X-Device-Id":             device,
 					"x-telematicsprogramtype": "jlrpy",
+					"x-App-Id":                "ICR_JAGUAR",
+					"x-App-Secret":            "018dd168-6271-707f-9fd4-aed2bf76905e",
 				} {
 					req.Header.Set(k, v)
 				}
diff --git a/vehicle/jlr/identity.go b/vehicle/jlr/identity.go
index c04c6852..56f82bb8 100644
--- a/vehicle/jlr/identity.go
+++ b/vehicle/jlr/identity.go
@@ -38,6 +38,8 @@ func (v *Identity) login(data map[string]string) (Token, error) {
 		"Authorization": "Basic YXM6YXNwYXNz",
 		"Content-type":  request.JSONContent,
 		"X-Device-Id":   v.device,
+		"x-App-Id":      "ICR_JAGUAR",
+		"x-App-Secret":  "018dd168-6271-707f-9fd4-aed2bf76905e",
 	})
 
 	var token Token
diff --git a/vehicle/tronity.go b/vehicle/tronity.go
index 8c466a35..dec5b987 100644
--- a/vehicle/tronity.go
+++ b/vehicle/tronity.go
@@ -38,6 +38,7 @@ import (
 type Tronity struct {
 	*embed
 	*request.Helper
+	log   *util.Logger
 	oc    *oauth2.Config
 	vid   string
 	bulkG func() (tronity.Bulk, error)
@@ -82,6 +83,7 @@ func NewTronityFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	}
 
 	v := &Tronity{
+		log:    log,
 		embed:  &cc.embed,
 		Helper: request.NewHelper(log),
 		oc:     oc,
@@ -153,7 +155,7 @@ func (v *Tronity) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) {
 	req, _ := request.New(http.MethodPost, v.oc.Endpoint.TokenURL, request.MarshalJSON(data), request.JSONEncoding)
 
 	var token oauth.Token
-	err := v.DoJSON(req, &token)
+	err := request.NewHelper(v.log).DoJSON(req, &token)
 
 	return (*oauth2.Token)(&token), err
 }


Test patch:
diff --git a/core/site_battery_test.go b/core/site_battery_test.go
index 630f46f8..a39f0833 100644
--- a/core/site_battery_test.go
+++ b/core/site_battery_test.go
@@ -4,50 +4,12 @@ import (
 	"testing"
 
 	"github.com/evcc-io/evcc/api"
-	"github.com/evcc-io/evcc/core/loadpoint"
 	"github.com/evcc-io/evcc/util"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"go.uber.org/mock/gomock"
 )
 
-func TestDetermineBatteryMode(t *testing.T) {
-	ctrl := gomock.NewController(t)
-
-	tcs := []struct {
-		chargeStatus       api.ChargeStatus
-		fastChargingActive bool
-		smartCostActive    bool
-		expBatMode         api.BatteryMode
-	}{
-		{api.StatusB, false, false, api.BatteryNormal}, // not charging | fast charge not active | smart cost not active -> bat normal
-		{api.StatusB, true, false, api.BatteryNormal},  // not charging | fast charge active 	 | smart cost not active -> bat normal
-		{api.StatusC, false, false, api.BatteryNormal}, // charging 	| fast charge not active | smart cost not active -> bat normal
-		{api.StatusC, true, false, api.BatteryHold},    // charging 	| fast charge active 	 | smart cost not active -> bat hold
-		{api.StatusB, false, true, api.BatteryNormal},  // not charging | fast charge not active | smart cost active	 -> bat normal
-		{api.StatusB, true, true, api.BatteryNormal},   // not charging | fast charge active	 | smart cost active	 -> bat normal
-		{api.StatusC, false, true, api.BatteryHold},    // charging 	| fast charge not active | smart cost active	 -> bat hold
-		{api.StatusC, true, true, api.BatteryHold},     // charging 	| fast charge active	 | smart cost active	 -> bat hold
-	}
-
-	log := util.NewLogger("foo")
-
-	for _, tc := range tcs {
-		s := &Site{
-			log: log,
-		}
-
-		lp := loadpoint.NewMockAPI(ctrl)
-		lp.EXPECT().GetStatus().Return(tc.chargeStatus).AnyTimes()
-		lp.EXPECT().IsFastChargingActive().Return(tc.fastChargingActive).AnyTimes()
-
-		loadpoints := []loadpoint.API{lp}
-
-		mode := s.determineBatteryMode(loadpoints, tc.smartCostActive)
-		assert.Equal(t, tc.expBatMode, mode, tc)
-	}
-}
-
 func TestUpdateBatteryMode(t *testing.T) {
 	expBatMode := api.BatteryHold
 
@@ -68,7 +30,7 @@ func TestUpdateBatteryMode(t *testing.T) {
 		batteryMode:   api.BatteryNormal,
 	}
 
-	err := s.updateBatteryMode(expBatMode)
+	err := s.applyBatteryMode(expBatMode)
 	require.NoError(t, err)
 	assert.Equal(t, expBatMode, s.GetBatteryMode())
 }