Repository: evcc-io/evcc
Pull request #8905 (2023-07-10 23:48:55)

Publish total energies
Adds missing publishing of total energies

- [x] PV production
- [x] Battery discharge

Replaces #3384


Patch:
diff --git a/README.md b/README.md
index 9b188a5e..1aca7472 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@ To build evcc from source, [Go][1] 1.20 and [Node][2] 18 are required.
 Build and run go backend. The UI becomes available at http://127.0.0.1:7070/
 
 ```sh
+make install-ui
 make ui
 make install
 make
diff --git a/assets/js/components/BatterySettingsModal.vue b/assets/js/components/BatterySettingsModal.vue
index aff148ae..c094ef91 100644
--- a/assets/js/components/BatterySettingsModal.vue
+++ b/assets/js/components/BatterySettingsModal.vue
@@ -364,7 +364,6 @@ export default {
 		bufferStartOptionToSoc(option) {
 			const bufferSoc = this.selectedBufferSoc;
 			const bufferHeight = 100 - bufferSoc;
-			console.log("bufferStartOptionToSoc", { bufferSoc, bufferHeight, option });
 			switch (option) {
 				case "low":
 					return bufferSoc + bufferHeight * 0.2;
diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue
index c0f2b67d..3164d0dc 100644
--- a/assets/js/components/Energyflow/Energyflow.vue
+++ b/assets/js/components/Energyflow/Energyflow.vue
@@ -23,20 +23,29 @@
 		<div class="details" :style="{ height: detailsHeight }">
 			<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">
+					<div class="d-flex flex-nowrap align-items-center text-truncate">
 						<span class="color-self me-2"
 							><shopicon-filled-square></shopicon-filled-square
 						></span>
-						<span>{{ $t("main.energyflow.selfConsumption") }}</span>
+						<span class="text-nowrap text-truncate">
+							{{ $t("main.energyflow.selfConsumption") }}
+						</span>
 					</div>
-					<div v-if="gridImport > 0" class="d-flex flex-nowrap align-items-center">
-						<span>{{ $t("main.energyflow.gridImport") }}</span>
+					<div
+						v-if="gridImport > 0"
+						class="d-flex flex-nowrap align-items-center text-truncate"
+					>
+						<span class="text-nowrap text-truncate">
+							{{ $t("main.energyflow.gridImport") }}
+						</span>
 						<span class="color-grid ms-2"
 							><shopicon-filled-square></shopicon-filled-square
 						></span>
 					</div>
-					<div v-else class="d-flex flex-nowrap align-items-center">
-						<span>{{ $t("main.energyflow.pvExport") }}</span>
+					<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>
diff --git a/assets/js/components/Energyflow/EnergyflowEntry.vue b/assets/js/components/Energyflow/EnergyflowEntry.vue
index 6ec3e20c..50c41bf3 100644
--- a/assets/js/components/Energyflow/EnergyflowEntry.vue
+++ b/assets/js/components/Energyflow/EnergyflowEntry.vue
@@ -5,7 +5,7 @@
 			<VehicleIcon v-else-if="isVehicle" :names="vehicleIcons" />
 			<component :is="`shopicon-regular-${icon}`" v-else></component>
 		</span>
-		<span class="text-nowrap flex-grow-1 ms-3">
+		<span class="text-nowrap flex-grow-1 ms-3 text-truncate">
 			{{ name }}
 		</span>
 		<span class="text-end text-nowrap ps-1 fw-bold d-flex">
diff --git a/assets/js/mixins/formatter.js b/assets/js/mixins/formatter.js
index f7f55fcb..624a4e55 100644
--- a/assets/js/mixins/formatter.js
+++ b/assets/js/mixins/formatter.js
@@ -1,3 +1,19 @@
+// list of currencies where energy price should be displayed in subunits (factor 100)
+const ENERGY_PRICE_IN_SUBUNIT = {
+  AUD: "c", // Australian cent
+  BGN: "st", // Bulgarian stotinka
+  BRL: "¢", // Brazilian centavo
+  CAD: "¢", // Canadian cent
+  CHF: "rp", // Swiss Rappen
+  CNY: "f", // Chinese fen
+  EUR: "ct", // Euro cent
+  GBP: "p", // GB pence
+  ILS: "ag", // Israeli agora
+  NZD: "c", // New Zealand cent
+  PLN: "gr", // Polish grosz
+  USD: "¢", // US cent
+};
+
 export default {
   data: function () {
     return {
@@ -201,7 +217,7 @@ export default {
       let value = amout;
       let minimumFractionDigits = 1;
       let maximumFractionDigits = 3;
-      if (["EUR", "USD"].includes(currency)) {
+      if (ENERGY_PRICE_IN_SUBUNIT[currency]) {
         value *= 100;
         minimumFractionDigits = 1;
         maximumFractionDigits = 1;
@@ -217,10 +233,7 @@ export default {
       return price;
     },
     pricePerKWhUnit: function (currency = "EUR", short = false) {
-      let unit = currency;
-      if (["EUR", "USD"].includes(currency)) {
-        unit = "ct";
-      }
+      const unit = ENERGY_PRICE_IN_SUBUNIT[currency] || currency;
       return `${unit}${short ? "" : "/kWh"}`;
     },
     fmtTimeAgo: function (elapsed) {
diff --git a/charger/easee.go b/charger/easee.go
index 580c8dd4..317e3aff 100644
--- a/charger/easee.go
+++ b/charger/easee.go
@@ -28,6 +28,7 @@ import (
 	"sync"
 	"time"
 
+	"github.com/cenkalti/backoff/v4"
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/charger/easee"
 	"github.com/evcc-io/evcc/core/loadpoint"
@@ -202,7 +203,18 @@ func (c *Easee) chargerSite(charger string) (easee.Site, error) {
 
 // connect creates an HTTP connection to the signalR hub
 func (c *Easee) connect(ts oauth2.TokenSource) func() (signalr.Connection, error) {
-	return func() (signalr.Connection, error) {
+	bo := backoff.NewExponentialBackOff()
+	bo.MaxElapsedTime = time.Minute
+
+	return func() (conn signalr.Connection, err error) {
+		defer func() {
+			if err != nil {
+				time.Sleep(bo.NextBackOff())
+			} else {
+				bo.Reset()
+			}
+		}()
+
 		tok, err := ts.Token()
 		if err != nil {
 			return nil, err
diff --git a/charger/openevse.go b/charger/openevse.go
index 30758621..2b4cdb4a 100644
--- a/charger/openevse.go
+++ b/charger/openevse.go
@@ -180,24 +180,27 @@ func (c *OpenEVSE) Status() (api.ChargeStatus, error) {
 func (c *OpenEVSE) Enabled() (bool, error) {
 	ctx, cancel := c.requestContextWithTimeout()
 	defer cancel()
-	overrideResp, err := c.api.GetManualOverrideWithResponse(ctx)
+	overrideResp, err := c.api.GetStatusWithResponse(ctx)
 
 	if err != nil {
 		return false, err
 	}
 
 	if overrideResp.JSON200 != nil && overrideResp.JSON200.State != nil {
-		switch state := *overrideResp.JSON200.State; state {
+		status := *overrideResp.JSON200.Status
+
+		switch status {
 		case "disabled":
 			return false, nil
 		case "enabled", "active":
 			return true, nil
 		default:
-			return false, fmt.Errorf("unknown EVSE state: %s", state)
+			return false, fmt.Errorf("unknown EVSE status: %s", status)
 		}
 	}
 
-	return false, errors.New("invalid EVSE state")
+	// no override:
+	return false, fmt.Errorf("invalid EVSE status")
 }
 
 // Enable implements the api.Charger interface
diff --git a/charger/openevse/api.go b/charger/openevse/api.go
index 0c10fe7f..08414cf2 100644
--- a/charger/openevse/api.go
+++ b/charger/openevse/api.go
@@ -2087,9 +2087,12 @@ type GetStatusResponse struct {
 		// WiFi RSSI signal strength
 		Srssi *int `json:"srssi,omitempty"`
 
-		// The EVSE status
+		// The EVSE state
 		State *int `json:"state,omitempty"`
 
+		// The EVSE status
+		Status *string `json:"status,omitempty"`
+
 		// Number of stuck relay errors
 		Stuckcount *int `json:"stuckcount,omitempty"`
 
@@ -3265,9 +3268,12 @@ func ParseGetStatusResponse(rsp *http.Response) (*GetStatusResponse, error) {
 			// WiFi RSSI signal strength
 			Srssi *int `json:"srssi,omitempty"`
 
-			// The EVSE status
+			// The EVSE state
 			State *int `json:"state,omitempty"`
 
+			// The EVSE status
+			Status *string `json:"status,omitempty"`
+
 			// Number of stuck relay errors
 			Stuckcount *int `json:"stuckcount,omitempty"`
 
diff --git a/core/site.go b/core/site.go
index 390d82b1..73fa09c5 100644
--- a/core/site.go
+++ b/core/site.go
@@ -33,12 +33,14 @@ type Updater interface {
 
 // meterMeasurement is used as slice element for publishing structured data
 type meterMeasurement struct {
-	Power float64 `json:"power"`
+	Power  float64 `json:"power"`
+	Energy float64 `json:"energy"`
 }
 
 // batteryMeasurement is used as slice element for publishing structured data
 type batteryMeasurement struct {
 	Power    float64 `json:"power"`
+	Energy   float64 `json:"energy"`
 	Soc      float64 `json:"soc"`
 	Capacity float64 `json:"capacity"`
 }
@@ -394,15 +396,17 @@ func (site *Site) updateMeters() error {
 	}
 
 	if len(site.pvMeters) > 0 {
+		var totalEnergy float64
+
 		site.pvPower = 0
+
 		mm := make([]meterMeasurement, len(site.pvMeters))
 
 		for i, meter := range site.pvMeters {
+			// pv power
 			var power float64
 			err := retry.Do(site.updateMeter(meter, &power), retryOptions...)
 
-			mm[i] = meterMeasurement{Power: power}
-
 			if err == nil {
 				// ignore negative values which represent self-consumption
 				site.pvPower += math.Max(0, power)
@@ -413,22 +417,43 @@ func (site *Site) updateMeters() error {
 				err = fmt.Errorf("pv %d power: %v", i+1, err)
 				site.log.ERROR.Println(err)
 			}
+
+			// pv energy (production)
+			var energy float64
+			if m, ok := meter.(api.MeterEnergy); err == nil && ok {
+				energy, err := m.TotalEnergy()
+				if err == nil {
+					totalEnergy += energy
+				} else {
+					site.log.ERROR.Printf("pv %d energy: %v", i+1, err)
+				}
+			}
+
+			mm[i] = meterMeasurement{
+				Power:  power,
+				Energy: energy,
+			}
 		}
 
 		site.log.DEBUG.Printf("pv power: %.0fW", site.pvPower)
 		site.publish("pvPower", site.pvPower)
 
+		site.publish("pvEnergy", totalEnergy)
+
 		site.publish("pv", mm)
 	}
 
 	if len(site.batteryMeters) > 0 {
 		var totalCapacity float64
+		var totalEnergy float64
+
 		site.batteryPower = 0
 		site.batterySoc = 0
 
 		mm := make([]batteryMeasurement, len(site.batteryMeters))
 
 		for i, meter := range site.batteryMeters {
+			// battery power
 			var power float64
 
 			// NOTE battery errors are logged but ignored as we don't consider them relevant
@@ -443,6 +468,18 @@ func (site *Site) updateMeters() error {
 				site.log.ERROR.Printf("battery %d power: %v", i+1, err)
 			}
 
+			// battery energy (discharge)
+			var energy float64
+			if m, ok := meter.(api.MeterEnergy); err == nil && ok {
+				energy, err := m.TotalEnergy()
+				if err == nil {
+					totalEnergy += energy
+				} else {
+					site.log.ERROR.Printf("battery %d energy: %v", i+1, err)
+				}
+			}
+
+			// battery soc and capacity
 			var capacity float64
 			soc, err := meter.(api.Battery).Soc()
 
@@ -465,6 +502,7 @@ func (site *Site) updateMeters() error {
 
 			mm[i] = batteryMeasurement{
 				Power:    power,
+				Energy:   energy,
 				Soc:      soc,
 				Capacity: capacity,
 			}
@@ -484,12 +522,15 @@ func (site *Site) updateMeters() error {
 		site.log.DEBUG.Printf("battery power: %.0fW", site.batteryPower)
 		site.publish("batteryPower", site.batteryPower)
 
+		site.publish("batteryEnergy", totalEnergy)
+
 		site.publish("battery", mm)
 	}
 
+	// grid power
 	err := retryMeter("grid", site.gridMeter, &site.gridPower)
 
-	// powers
+	// grid phase powers
 	var p1, p2, p3 float64
 	if phaseMeter, ok := site.gridMeter.(api.PhasePowers); err == nil && ok {
 		p1, p2, p3, err = phaseMeter.Powers()
@@ -502,7 +543,7 @@ func (site *Site) updateMeters() error {
 		}
 	}
 
-	// currents
+	// grid phase currents (signed)
 	if phaseMeter, ok := site.gridMeter.(api.PhaseCurrents); err == nil && ok {
 		var i1, i2, i3 float64
 		i1, i2, i3, err = phaseMeter.Currents()
@@ -515,11 +556,12 @@ func (site *Site) updateMeters() error {
 		}
 	}
 
-	// energy
+	// grid energy (import)
 	if energyMeter, ok := site.gridMeter.(api.MeterEnergy); err == nil && ok {
-		val, err := energyMeter.TotalEnergy()
+		var e float64
+		e, err = energyMeter.TotalEnergy()
 		if err == nil {
-			site.publish("gridEnergy", val)
+			site.publish("gridEnergy", e)
 		} else {
 			site.log.ERROR.Printf("grid energy: %v", err)
 		}
@@ -794,6 +836,10 @@ func (site *Site) loopLoadpoints(next chan<- Updater) {
 func (site *Site) Run(stopC chan struct{}, interval time.Duration) {
 	site.Health = NewHealth(time.Minute + interval)
 
+	if max := 30 * time.Second; interval < max {
+		site.log.WARN.Printf("interval <%.0fs can lead to unexpected behavior, see https://docs.evcc.io/docs/reference/configuration/interval", max.Seconds())
+	}
+
 	loadpointChan := make(chan Updater)
 	go site.loopLoadpoints(loadpointChan)
 
diff --git a/evcc.dist.yaml b/evcc.dist.yaml
index 68e00d94..dcccb28d 100644
--- a/evcc.dist.yaml
+++ b/evcc.dist.yaml
@@ -10,7 +10,7 @@ network:
   # evcc will listen on all available interfaces
   port: 7070
 
-interval: 10s # control cycle interval
+interval: 30s # control cycle interval
 
 # database configuration for persisting charge sessions and settings
 # database:
diff --git a/i18n/da.toml b/i18n/da.toml
index 233fbaf3..731b3ad6 100644
--- a/i18n/da.toml
+++ b/i18n/da.toml
@@ -7,6 +7,16 @@ legendMiddleName = "Bil først"
 legendMiddleSubline = "Hjem efterfølgende"
 legendTitle = "Hvordan skal solenergi benyttes?"
 legendTopAutostart = "starter automatisk"
+legendTopName = "batterie understøtted opladning"
+legendTopSubline = "uden afbrydelser"
+modalTitle = "batterie indstillinger"
+note = "OBS: Disse indstillinger gælder kun for solar modus. Indstillingerne ændres tilsvarende."
+
+[batterySettings.bufferStart]
+full = "næsten fuld"
+low = "en smule fyld"
+medium = "halv fuld"
+never = "kun med nok solar overskuld"
 
 [footer]
 
@@ -23,8 +33,8 @@ powerSub2 = "opladning ..."
 tabTitle = "Live fællesskab"
 
 [footer.savings]
-footerLong = "{percent}% solenergi"
-footerShort = "{percent}% solenergi"
+footerLong = "{percent}% Fotovoltaisk energi"
+footerShort = "{percent}% Fotovoltaisk energi"
 modalTitle = "Oversigt over ladeenergi"
 percentGrid = "{grid} kWh Net"
 percentSelf = "{self} kWh solenergi"
@@ -83,9 +93,28 @@ blog = "Blog"
 docs = "Dokumentation"
 github = "GitHub"
 login = "Logins til køretøjer"
+needHelp = "Brug for hjælp?"
 sessions = "Opladning sessioner"
 settings = "Indstillinger"
 
+[help]
+discussionsButton = "GitHub diskussioner"
+documentationButton = "Dokumentation"
+issueButton = "Meld en fejl"
+issueDescription = "Fær en mærkelig eller forkert adfærd?"
+modalTitle = "Brug for hjælp?"
+primaryActions = "Noget fungerer ikke, som det skulle? Det er gode steder at få hjælp."
+restartButton = "Genstart"
+restartDescription = "Har du prøvet at slukke og tænde den igen?"
+secondaryActions = "Stadig ikke i stand til at løse dit problem? Her er nogle mere ekstreme muligheder."
+
+[help.restart]
+cancel = "Afbryd"
+confirm = "Ja, genstart!"
+description = "Under normale omstændigheder bør genstart ikke være nødvendig. Overvej at indsende en fejlmelding, hvis du har brug for at genstarte evcc regelmæssigt."
+disclaimer = "Bemærk: evcc afsluttes og har brug for at operativsystemet genstarter tjenesten."
+modalTitle = "Er du sikker på, at du vil genstarte?"
+
 [main]
 vehicles = "Parkering"
 
@@ -113,10 +142,14 @@ pvProduction = "Produktion"
 selfConsumption = "Self-konsumption"
 
 [main.loadpoint]
+avgPrice = "⌀ pris"
 charged = "Opladt"
+co2 = "⌀ CO₂"
 duration = "Varighed"
 fallbackName = "Ladepunkt"
 power = "Strøm"
+price = "Σ Pris"
+solar = "solenergi"
 
 [main.loadpointSettings]
 currents = "Ladestrøm"
@@ -209,6 +242,7 @@ vehicleTarget = "Køretøjsgrænse: {soc}%"
 charging = "Opladning..."
 cheapEnergyCharging = "Billig energi tilgængelig. Oplader..."
 cleanEnergyCharging = "Ren energi tilgængelig. Oplader..."
+climating = "Forkonditionering registreret."
 connected = "Forbundet."
 disconnected = "Afbrudt."
 guard = "Starter om {remaining}…"
@@ -234,21 +268,33 @@ reload = "Genindlæs?"
 
 [session]
 cancel = "Afbryd"
+co2 = "CO2"
+date = "Tidsrum"
 delete = "Slet"
 finished = "Fuldført"
-meterstart = "Meter start"
+meter = "Kilometertælller"
+meterstart = "kilometertælleraflæsning"
 meterstop = "Meter slut"
 odometer = "Kilometerstand"
+price = "Pris"
 started = "Startet"
 title = "Lade session"
 
 [sessions]
-date = "Periode"
+avgPrice = "⌀ pris"
+co2 = "⌀ CO₂"
+csvMonth = "Download {month} CSV"
+csvTotal = "Download total CSV"
+date = "Start"
 downloadCsv = "Download som CSV"
 energy = "Opladt"
 loadpoint = "Ladepunkt"
+noData = "Ingen opladninger i denne måned."
+price = "Σ pris"
 reallyDelete = "Er du sikker på at du vil slette denne session?"
+solar = "solenergi"
 title = "Opladning Sessioner"
+total = "Total"
 vehicle = "Køretøj"
 
 [sessions.csv]
@@ -262,6 +308,11 @@ meterstop = "Målerstop (kWh)"
 odometer = "Kilometertal (km)"
 vehicle = "Køretøj"
 
+[sessions.filter]
+allLoadpoints = "alle ladepunkter"
+allVehicles = "alle køretøjer"
+filter = "Filter"
+
 [settings]
 title = "Indstillinger"
 
@@ -290,7 +341,7 @@ light = "lys"
 [settings.unit]
 km = "km"
 label = "Enheder"
-mi = "miles"
+mi = "mil"
 
 [startupError]
 configFile = "Konfigurationsfil brugt:"
diff --git a/i18n/sv.toml b/i18n/sv.toml
index 0066e72d..a1f75cef 100644
--- a/i18n/sv.toml
+++ b/i18n/sv.toml
@@ -99,7 +99,7 @@ modalTitle = "Är du säker på att du vill starta om?"
 vehicles = "Parkering"
 
 [main.chargingPlan]
-activeLabel = "{tid}"
+activeLabel = "{time}"
 arrivalTab = "Ankomst"
 departureTab = "Avfärd"
 modalTitle = "Laddplan"
diff --git a/package-lock.json b/package-lock.json
index 2bd8dcf4..852c27d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3789,9 +3789,9 @@
       }
     },
     "node_modules/editorconfig/node_modules/semver": {
-      "version": "5.7.1",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
-      "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+      "version": "5.7.2",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+      "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
       "bin": {
         "semver": "bin/semver"
       }
@@ -6986,9 +6986,9 @@
       }
     },
     "node_modules/semver": {
-      "version": "6.3.0",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
-      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
       "bin": {
         "semver": "bin/semver.js"
       }
diff --git a/provider/const.go b/provider/const.go
index 6db48b8e..6472eeb3 100644
--- a/provider/const.go
+++ b/provider/const.go
@@ -26,7 +26,7 @@ func NewConstFromConfig(other map[string]interface{}) (Provider, error) {
 		return nil, err
 	}
 
-	pipe, err := pipeline.New(cc.Settings)
+	pipe, err := pipeline.New(nil, cc.Settings)
 	if err != nil {
 		return nil, err
 	}
diff --git a/provider/http.go b/provider/http.go
index 931c68d4..2ff66d44 100644
--- a/provider/http.go
+++ b/provider/http.go
@@ -60,8 +60,9 @@ func NewHTTPProviderFromConfig(other map[string]interface{}) (Provider, error) {
 		return nil, err
 	}
 
+	log := util.NewLogger("http")
 	http := NewHTTP(
-		util.NewLogger("http"),
+		log,
 		cc.Method,
 		cc.URI,
 		cc.Insecure,
@@ -80,7 +81,7 @@ func NewHTTPProviderFromConfig(other map[string]interface{}) (Provider, error) {
 
 	if err == nil {
 		var pipe *pipeline.Pipeline
-		pipe, err = pipeline.New(cc.Settings)
+		pipe, err = pipeline.New(log, cc.Settings)
 		http = http.WithPipeline(pipe)
 	}
 
diff --git a/provider/mqtt.go b/provider/mqtt.go
index 2a589e01..986fc7af 100644
--- a/provider/mqtt.go
+++ b/provider/mqtt.go
@@ -53,7 +53,7 @@ func NewMqttFromConfig(other map[string]interface{}) (Provider, error) {
 		m = m.WithRetained()
 	}
 
-	pipe, err := pipeline.New(cc.Settings)
+	pipe, err := pipeline.New(log, cc.Settings)
 	if err == nil {
 		m = m.WithPipeline(pipe)
 	}
diff --git a/provider/mqtt/client.go b/provider/mqtt/client.go
index 8d4794ad..f0042a83 100644
--- a/provider/mqtt/client.go
+++ b/provider/mqtt/client.go
@@ -137,7 +137,8 @@ func (m *Client) Listen(topic string, callback func(string)) {
 
 // ListenSetter creates a /set listener that resets the payload after handling
 func (m *Client) ListenSetter(topic string, callback func(string) error) {
-	m.Listen(topic+"/set", func(payload string) {
+	topic += "/set"
+	m.Listen(topic, func(payload string) {
 		if err := callback(payload); err != nil {
 			m.log.ERROR.Printf("set %s: %v", topic, err)
 		}
diff --git a/provider/pipeline/pipeline.go b/provider/pipeline/pipeline.go
index a040a45b..d99389d3 100644
--- a/provider/pipeline/pipeline.go
+++ b/provider/pipeline/pipeline.go
@@ -9,6 +9,7 @@ import (
 
 	xj "github.com/basgys/goxml2json"
 	"github.com/evcc-io/evcc/provider/javascript"
+	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/jq"
 	"github.com/itchyny/gojq"
 	"github.com/robertkrimen/otto"
@@ -16,6 +17,7 @@ import (
 )
 
 type Pipeline struct {
+	log    *util.Logger
 	re     *regexp.Regexp
 	jq     *gojq.Query
 	dflt   string
@@ -35,8 +37,10 @@ type Settings struct {
 	Script  string
 }
 
-func New(cc Settings) (*Pipeline, error) {
-	p := new(Pipeline)
+func New(log *util.Logger, cc Settings) (*Pipeline, error) {
+	p := &Pipeline{
+		log: log,
+	}
 
 	var err error
 	if err == nil && cc.Regex != "" {
@@ -137,6 +141,10 @@ func (p *Pipeline) transformXML(value []byte) []byte {
 		return value
 	}
 
+	if p.log != nil {
+		p.log.TRACE.Println(out.String())
+	}
+
 	return out.Bytes()
 }
 
diff --git a/provider/socket.go b/provider/socket.go
index 4e64287f..61e04a70 100644
--- a/provider/socket.go
+++ b/provider/socket.go
@@ -85,7 +85,7 @@ func NewSocketProviderFromConfig(other map[string]interface{}) (Provider, error)
 	}
 
 	var err error
-	if p.pipeline, err = pipeline.New(cc.Settings); err != nil {
+	if p.pipeline, err = pipeline.New(log, cc.Settings); err != nil {
 		return nil, err
 	}
 
diff --git a/tariff/awattar.go b/tariff/awattar.go
index 3f411914..34c2eb60 100644
--- a/tariff/awattar.go
+++ b/tariff/awattar.go
@@ -30,9 +30,8 @@ func init() {
 
 func NewAwattarFromConfig(other map[string]interface{}) (api.Tariff, error) {
 	cc := struct {
-		embed    `mapstructure:",squash"`
-		Currency string // TODO deprecated
-		Region   string
+		embed  `mapstructure:",squash"`
+		Region string
 	}{
 		Region: "DE",
 	}
@@ -41,10 +40,6 @@ func NewAwattarFromConfig(other map[string]interface{}) (api.Tariff, error) {
 		return nil, err
 	}
 
-	if cc.Currency == "" {
-		cc.Currency = "EUR"
-	}
-
 	t := &Awattar{
 		embed: &cc.embed,
 		log:   util.NewLogger("awattar"),
diff --git a/tariff/elering.go b/tariff/elering.go
index 92eb468a..241597f4 100644
--- a/tariff/elering.go
+++ b/tariff/elering.go
@@ -32,9 +32,8 @@ func init() {
 
 func NewEleringFromConfig(other map[string]interface{}) (api.Tariff, error) {
 	var cc struct {
-		embed    `mapstructure:",squash"`
-		Currency string // TODO deprecated
-		Region   string
+		embed  `mapstructure:",squash"`
+		Region string
 	}
 
 	if err := util.DecodeOther(other, &cc); err != nil {
diff --git a/tariff/fixed.go b/tariff/fixed.go
index cd094339..6f168407 100644
--- a/tariff/fixed.go
+++ b/tariff/fixed.go
@@ -25,9 +25,8 @@ func init() {
 
 func NewFixedFromConfig(other map[string]interface{}) (api.Tariff, error) {
 	var cc struct {
-		Currency string // TODO deprecated
-		Price    float64
-		Zones    []struct {
+		Price float64
+		Zones []struct {
 			Price       float64
 			Days, Hours string
 		}
diff --git a/tariff/tibber.go b/tariff/tibber.go
index 6f991d5d..16faebed 100644
--- a/tariff/tibber.go
+++ b/tariff/tibber.go
@@ -15,6 +15,7 @@ import (
 )
 
 type Tibber struct {
+	*embed
 	mux     sync.Mutex
 	log     *util.Logger
 	homeID  string
@@ -31,6 +32,7 @@ func init() {
 
 func NewTibberFromConfig(other map[string]interface{}) (api.Tariff, error) {
 	var cc struct {
+		embed  `mapstructure:",squash"`
 		Token  string
 		HomeID string
 		Unit   string
@@ -47,6 +49,7 @@ func NewTibberFromConfig(other map[string]interface{}) (api.Tariff, error) {
 	log := util.NewLogger("tibber").Redact(cc.Token, cc.HomeID)
 
 	t := &Tibber{
+		embed:  &cc.embed,
 		log:    log,
 		homeID: cc.HomeID,
 		client: tibber.NewClient(log, cc.Token),
@@ -113,10 +116,14 @@ func (t *Tibber) run(done chan error) {
 func (t *Tibber) rates(pi []tibber.Price) api.Rates {
 	data := make(api.Rates, 0, len(pi))
 	for _, r := range pi {
+		price := r.Total
+		if t.Charges != 0 || t.Tax != 0 {
+			price = t.totalPrice(r.Energy)
+		}
 		ar := api.Rate{
 			Start: r.StartsAt.Local(),
 			End:   r.StartsAt.Add(time.Hour).Local(),
-			Price: r.Total,
+			Price: price,
 		}
 		data = append(data, ar)
 	}
diff --git a/templates/definition/charger/ocpp-elvi.yaml b/templates/definition/charger/ocpp-elvi.yaml
index 2f9f192d..dd168c1a 100644
--- a/templates/definition/charger/ocpp-elvi.yaml
+++ b/templates/definition/charger/ocpp-elvi.yaml
@@ -5,5 +5,25 @@ products:
       generic: Elvi
 params:
   - preset: ocpp
+  - name: meter
+    type: bool
+    default: true
+    description:
+      de: Hat die Wallbox einen integrierten Zähler verbaut?
+      en: The charger has an integrated metering device installed?
+    help:
+      de: Deaktiveren wenn die Wallbox keinen integrierten Zähler verbaut hat
+      en: Deactivate if the charger has no integrated metering device installed
+  - name: meterinterval
+    advanced: true
+    type: duration
+    description:
+      de: Zählerwerte im Intervall anfordern
+      en: Interval for requesting metering values
+    default: 4s
 render: |
   {{ include "ocpp" . }}
+  {{- if ne .meter "false" }}
+  metervalues: Current.Import.L1,Current.Import.L2,Current.Import.L3,Energy.Active.Import.Register,Power.Active.Import,Voltage.L1,Voltage.L2,Voltage.L3
+  meterinterval: {{ .meterinterval }}
+  {{- end }}
diff --git a/templates/definition/charger/ocpp-pulsarplus.yaml b/templates/definition/charger/ocpp-pulsarplus.yaml
index a87597f5..f50c2119 100644
--- a/templates/definition/charger/ocpp-pulsarplus.yaml
+++ b/templates/definition/charger/ocpp-pulsarplus.yaml
@@ -2,7 +2,21 @@ template: pulsarplus
 products:
   - brand: wallbox
     description:
-      generic: Pulsar Plus/ Commander 2/ Copper SB
+      generic: Pulsar Plus / Commander 2 / Copper SB
+requirements:
+  description:
+    de: |
+      Anleitung: https://support.wallbox.com/en/knowledge-base/ocpp-activation-and-setup-guide/
+      * “OCPP aktivieren” (myWallbox app) bzw. den “OCPP-WebSocket-Verbindung” Schalter (myWallbox Portal) aktivieren
+      * URL: ws://[evcc-adresse]:8887/ (Verbindung über das lokale Netzwerk)
+      * Ladepunktidentität: beliebiger Wert (z.B. die Seriennummer der Box), der als *stationid* verwendet wird
+      * Passwort: leer lassen
+    en: |
+      Setup Guide: https://support.wallbox.com/en/knowledge-base/ocpp-activation-and-setup-guide/
+      * Switch on “Enable OCPP” (myWallbox app) or enable the “OCPP WebSocket connection” switch (myWallbox Portal)
+      * URL: ws://[evcc-adresse]:8887/ (local network connection)
+      * Charge Point Identity: Custom value (e.g. serial number of charger) which is reused in configuration as *stationid*
+      * Password: leave empty
 params:
   - preset: ocpp
 render: |
diff --git a/templates/definition/charger/ocpp.yaml b/templates/definition/charger/ocpp.yaml
index d5f41e5a..f524f205 100644
--- a/templates/definition/charger/ocpp.yaml
+++ b/templates/definition/charger/ocpp.yaml
@@ -17,7 +17,7 @@ requirements:
 
       Voraussetzungen:
       * Ggf. zuvor konfigurierte OCPP-Profile (z.B. durch eine andere Backend-Anbindung) in der Wallboxkonfiguration entfernen
-      * Backend-URL (Central System) in der Wallboxkonfiguration: `ws://<evcc-IP-Adresse>:8887/` (evtl noch um `stationid` erweitern)
+      * Backend-URL (Central System) in der Wallboxkonfiguration: `ws://[evcc-adresse]:8887/` (evtl noch um `stationid` erweitern)
       * Protokoll: OCPP-J v1.6, ocpp16j, JSON, Websocket, ws:// o.ä.
       * Keine Verschlüsselung, keine Authentifizierung, kein Passwort
       * Verbindung über das lokale Netzwerk
@@ -32,7 +32,7 @@ requirements:
 
       Requirements:
       * If necessary, remove previously configured OCPP profiles (e.g. used for a different backend connection) in the charger configuration
-      * Backend URL (Central System) in the charger configuration: `ws://<evcc-IP-address>:8887/` (possibly add `stationid`)
+      * Backend URL (Central System) in the charger configuration: `ws://[evcc-adresse]:8887/` (possibly add `stationid`)
       * Protocol: OCPP-J v1.6, ocpp16j, JSON, Websocket, ws:// or similar
       * No encryption, no authentication, no password
       * Local network connection
diff --git a/templates/definition/meter/enphase.yaml b/templates/definition/meter/enphase.yaml
index b4b832ab..2ff2f0e5 100644
--- a/templates/definition/meter/enphase.yaml
+++ b/templates/definition/meter/enphase.yaml
@@ -5,13 +5,15 @@ products:
       generic: IQ Envoy
 params:
   - name: usage
-    choice: ["grid", "pv"]
+    choice: ["grid", "pv", "battery"]
   - name: host
     required: true
   - name: token
     help:
       en: "Required if Envoy Firmware D7.x.xxx. Token is valid for one year. Instructions for obtaining a token via web UI: https://enphase.com/download/accessing-iq-gateway-local-apis-or-local-ui-token-based-authentication"
       de: "Ab Envoy Firmware D7.x.xxx notwendig. Token ist ein Jahr gültig. Anleitung (Obtaining a token via web UI): https://enphase.com/download/accessing-iq-gateway-local-apis-or-local-ui-token-based-authentication"
+  - name: capacity
+    advanced: true
 render: |
   type: custom
   {{- if eq .usage "grid" }}
@@ -26,7 +28,6 @@ render: |
     insecure: true
     {{- end }}
     jq: .consumption[] | select(.measurementType == "net-consumption").wNow
-    scale: 1
   energy:
     source: http
     uri: http://{{ .host }}/production.json
@@ -52,7 +53,6 @@ render: |
     insecure: true
     {{- end }}
     jq: .production[] | select(.measurementType == "production").wNow
-    scale: 1
   energy:
     source: http
     uri: http://{{ .host }}/production.json
@@ -66,3 +66,30 @@ render: |
     jq: .production[] | select(.measurementType == "production").whLifetime
     scale: 0.001
   {{- end }}
+  {{- if eq .usage "battery" }}
+  power:
+    source: http
+    uri: http://{{ .host }}/production.json?details=1
+    method: GET
+    {{- if .token }}
+    auth:
+      type: bearer
+      password: {{ .token }}
+    insecure: true
+    {{- end }}
+    jq: .storage[] | select(.type == "acb").wNow
+  soc:
+    source: http
+    uri: http://{{ .host }}/ivp/ensemble/inventory
+    method: GET
+    {{- if .token }}
+    auth:
+      type: bearer
+      password: {{ .token }}
+    insecure: true
+    {{- end }}
+    jq: .[].devices | map(.percentFull) | add / length
+  {{- if .capacity }}
+  capacity: {{ .capacity }} # kWh
+  {{- end }}
+  {{- end }}
diff --git a/templates/definition/meter/mtec-eb-gen2.yaml b/templates/definition/meter/mtec-eb-gen2.yaml
new file mode 100644
index 00000000..a465f14d
--- /dev/null
+++ b/templates/definition/meter/mtec-eb-gen2.yaml
@@ -0,0 +1,47 @@
+template: mtec-eb-gen2
+products:
+  - brand: M-TEC
+    description:
+      generic: Energy Butler GEN2
+params:
+  - name: usage
+    choice: ["grid", "pv", "battery"]
+  - name: modbus
+    choice: ["rs485"]
+render: |
+  type: custom
+  {{- if eq .usage "grid" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 11000 # Zähler (NVP) Leistung
+      type: holding
+      decode: int32
+  {{- end }}
+  {{- if eq .usage "pv" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 11028 # PV Leistung
+      type: holding
+      decode: uint32
+  {{- end }}
+  {{- if eq .usage "battery" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 40258 # Batterie Leistung
+      type: holding
+      decode: int32
+  soc:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 43000 # State of Charge (SOC)
+      type: holding
+      decode: uint16
+    scale: 0.01
+  {{- end }}
diff --git a/templates/definition/meter/mtec-eb-gen3.yaml b/templates/definition/meter/mtec-eb-gen3.yaml
new file mode 100644
index 00000000..34adb214
--- /dev/null
+++ b/templates/definition/meter/mtec-eb-gen3.yaml
@@ -0,0 +1,47 @@
+template: mtec-eb-gen3
+products:
+  - brand: M-TEC
+    description:
+      generic: Energy Butler GEN3
+params:
+  - name: usage
+    choice: ["grid", "pv", "battery"]
+  - name: modbus
+    choice: ["rs485"]
+render: |
+  type: custom
+  {{- if eq .usage "grid" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 11000 # Zähler (NVP) Leistung
+      type: holding
+      decode: int32
+  {{- end }}
+  {{- if eq .usage "pv" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 11028 # PV Leistung
+      type: holding
+      decode: uint32
+  {{- end }}
+  {{- if eq .usage "battery" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 30258 # Batterie Leistung
+      type: holding
+      decode: int32
+  soc:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 33000 # State of Charge (SOC)
+      type: holding
+      decode: uint16
+    scale: 0.01
+  {{- end }}
diff --git a/templates/definition/meter/sonnenbatterie_eco56.yaml b/templates/definition/meter/sonnenbatterie_eco56.yaml
new file mode 100644
index 00000000..cc51b133
--- /dev/null
+++ b/templates/definition/meter/sonnenbatterie_eco56.yaml
@@ -0,0 +1,54 @@
+template: sonnenbatterie-eco56
+products:
+  - brand: Sonnen
+    description:
+      generic: comfort, eco 5, eco 6, oem 6.5
+params:
+  - name: usage
+    choice: ["grid", "pv", "battery"]
+  - name: host
+  - name: port
+    default: 7979
+  - name: capacity
+    advanced: true
+render: |
+  type: custom
+  {{- if eq .usage "grid" }}
+  power:
+    source: http
+    uri: http://{{ .host }}:{{ .port }}/rest/devices/battery
+    jq: .M39 - .M38 # current purchase - current feed-in at the interconnection point
+  energy:
+    source: http
+    uri: http://{{ .host }}:{{ .port }}/rest/devices/battery
+    jq: .M41 # cumulated purchase since installation
+  {{- end }}
+  {{- if eq .usage "pv" }}
+  power:
+    source: http
+    uri: http://{{ .host }}:{{ .port }}/rest/devices/battery
+    jq: .M03 # current pv power
+  energy:
+    source: http
+    uri: http://{{ .host }}:{{ .port }}/rest/devices/battery
+    jq: .M37 # cumulated pv production since installation of Sonnenbatterie
+  {{- end }}
+  {{- if eq .usage "battery" }}
+  power:
+    source: http
+    uri: http://{{ .host }}:{{ .port }}/rest/devices/battery
+    # M34 current discharging power, S65 max inverter power
+    # M35 current charging power, S65 max inverter power
+    jq: (if .M34 <= .S65 then .M34 else 0 end) - (if .M35 <= .S65 then .M35 else 0 end)
+  energy:
+    source: http
+    uri: http://{{ .host }}:{{ .port }}/rest/devices/battery
+    jq: .M31 # total stored energy over lifetime
+  soc:
+    source: http
+    uri: http://{{ .host }}:{{ .port }}/rest/devices/battery
+    jq: .M30 # SOC relative to usable capacity (.M05 # display SOC)
+  {{- if .capacity }}
+  capacity: {{ .capacity }} # kWh
+  {{- end }}
+  {{- end }}
diff --git a/templates/definition/meter/youless.yaml b/templates/definition/meter/youless.yaml
new file mode 100644
index 00000000..22f39cd1
--- /dev/null
+++ b/templates/definition/meter/youless.yaml
@@ -0,0 +1,42 @@
+template: youless
+products:
+  - brand: Youless
+    description:
+      generic: Energy Monitor
+requirements:
+  description:
+    de: |
+      Zur Erfassung der PV-Produktion wird ein extern angebundenener S0-Erzeugungszähler benötigt.
+    en: |
+      An externally connected S0 generation meter is required to record the solar production.
+params:
+  - name: usage
+    choice: ["grid", "pv"]
+  - name: host
+    required: true
+render: |
+  type: custom
+  {{- if eq .usage "grid" }}
+  power:
+    source: http
+    uri: http://{{ .host }}/e
+    method: GET
+    jq: .[0]|.pwr
+  energy:
+    source: http
+    uri: http://{{ .host }}/e
+    method: GET
+    jq: .[0]|.p1+.p2
+  {{- end }}
+  {{- if eq .usage "pv" }}
+  power:
+    source: http
+    uri: http://{{ .host }}/e
+    method: GET
+    jq: .[0].ps0
+  energy:
+    source: http
+    uri: http://{{ .host }}/e
+    method: GET
+    jq: .[0].cs0
+  {{- end }}
diff --git a/templates/docs/charger/elvi_0.yaml b/templates/docs/charger/elvi_0.yaml
index 6be597ce..d5f50936 100644
--- a/templates/docs/charger/elvi_0.yaml
+++ b/templates/docs/charger/elvi_0.yaml
@@ -5,6 +5,7 @@ render:
   - default: |
       type: template
       template: elvi
+      meter: true # Deaktiveren wenn die Wallbox keinen integrierten Zähler verbaut hat (Optional)
     advanced: |
       type: template
       template: elvi
@@ -13,3 +14,5 @@ render:
       idtag: '04E6B78921BBA0' # Token-ID welche für die Freischaltung der Ladevorgänge an den Ladepunkt zurückgesendet wird (Optional)
       connecttimeout: 5m # Optional
       timeout: 2m # Optional
+      meter: true # Deaktiveren wenn die Wallbox keinen integrierten Zähler verbaut hat (Optional)
+      meterinterval: 4s # Optional
diff --git a/templates/docs/charger/ocpp_0.yaml b/templates/docs/charger/ocpp_0.yaml
index bc07979d..2bec794e 100644
--- a/templates/docs/charger/ocpp_0.yaml
+++ b/templates/docs/charger/ocpp_0.yaml
@@ -12,7 +12,7 @@ description: |
 
   Voraussetzungen:
   * Ggf. zuvor konfigurierte OCPP-Profile (z.B. durch eine andere Backend-Anbindung) in der Wallboxkonfiguration entfernen
-  * Backend-URL (Central System) in der Wallboxkonfiguration: `ws://<evcc-IP-Adresse>:8887/` (evtl noch um `stationid` erweitern)
+  * Backend-URL (Central System) in der Wallboxkonfiguration: `ws://[evcc-adresse]:8887/` (evtl noch um `stationid` erweitern)
   * Protokoll: OCPP-J v1.6, ocpp16j, JSON, Websocket, ws:// o.ä.
   * Keine Verschlüsselung, keine Authentifizierung, kein Passwort
   * Verbindung über das lokale Netzwerk
diff --git a/templates/docs/charger/pulsarplus_0.yaml b/templates/docs/charger/pulsarplus_0.yaml
index 60310c72..223fdd72 100644
--- a/templates/docs/charger/pulsarplus_0.yaml
+++ b/templates/docs/charger/pulsarplus_0.yaml
@@ -1,6 +1,13 @@
 product:
   brand: wallbox
-  description: Pulsar Plus/ Commander 2/ Copper SB
+  description: Pulsar Plus / Commander 2 / Copper SB
+description: |
+  Anleitung: https://support.wallbox.com/en/knowledge-base/ocpp-activation-and-setup-guide/
+  * “OCPP aktivieren” (myWallbox app) bzw. den “OCPP-WebSocket-Verbindung” Schalter (myWallbox Portal) aktivieren
+  * URL: ws://[evcc-adresse]:8887/ (Verbindung über das lokale Netzwerk)
+  * Ladepunktidentität: beliebiger Wert (z.B. die Seriennummer der Box), der als *stationid* verwendet wird
+  * Passwort: leer lassen
+
 render:
   - default: |
       type: template
diff --git a/templates/docs/meter/enphase_0.yaml b/templates/docs/meter/enphase_0.yaml
index 09000212..9d2ee2ca 100644
--- a/templates/docs/meter/enphase_0.yaml
+++ b/templates/docs/meter/enphase_0.yaml
@@ -9,6 +9,12 @@ render:
       usage: grid
       host: 192.0.2.2 # IP-Adresse oder Hostname
       token: # Ab Envoy Firmware D7.x.xxx notwendig. Token ist ein Jahr gültig. Anleitung (Obtaining a token via web UI): https://enphase.com/download/accessing-iq-gateway-local-apis-or-local-ui-token-based-authentication (Optional)
+    advanced: |
+      type: template
+      template: enphase
+      usage: grid
+      host: 192.0.2.2 # IP-Adresse oder Hostname
+      token: # Ab Envoy Firmware D7.x.xxx notwendig. Token ist ein Jahr gültig. Anleitung (Obtaining a token via web UI): https://enphase.com/download/accessing-iq-gateway-local-apis-or-local-ui-token-based-authentication (Optional)
   - usage: pv
     default: |
       type: template
@@ -16,3 +22,23 @@ render:
       usage: pv
       host: 192.0.2.2 # IP-Adresse oder Hostname
       token: # Ab Envoy Firmware D7.x.xxx notwendig. Token ist ein Jahr gültig. Anleitung (Obtaining a token via web UI): https://enphase.com/download/accessing-iq-gateway-local-apis-or-local-ui-token-based-authentication (Optional)
+    advanced: |
+      type: template
+      template: enphase
+      usage: pv
+      host: 192.0.2.2 # IP-Adresse oder Hostname
+      token: # Ab Envoy Firmware D7.x.xxx notwendig. Token ist ein Jahr gültig. Anleitung (Obtaining a token via web UI): https://enphase.com/download/accessing-iq-gateway-local-apis-or-local-ui-token-based-authentication (Optional)
+  - usage: battery
+    default: |
+      type: template
+      template: enphase
+      usage: battery
+      host: 192.0.2.2 # IP-Adresse oder Hostname
+      token: # Ab Envoy Firmware D7.x.xxx notwendig. Token ist ein Jahr gültig. Anleitung (Obtaining a token via web UI): https://enphase.com/download/accessing-iq-gateway-local-apis-or-local-ui-token-based-authentication (Optional)
+    advanced: |
+      type: template
+      template: enphase
+      usage: battery
+      host: 192.0.2.2 # IP-Adresse oder Hostname
+      token: # Ab Envoy Firmware D7.x.xxx notwendig. Token ist ein Jahr gültig. Anleitung (Obtaining a token via web UI): https://enphase.com/download/accessing-iq-gateway-local-apis-or-local-ui-token-based-authentication (Optional)
+      capacity: 50 # Akkukapazität in kWh (Optional)
diff --git a/templates/docs/meter/mtec-eb-gen2_0.yaml b/templates/docs/meter/mtec-eb-gen2_0.yaml
new file mode 100644
index 00000000..5f76fb89
--- /dev/null
+++ b/templates/docs/meter/mtec-eb-gen2_0.yaml
@@ -0,0 +1,58 @@
+product:
+  brand: M-TEC
+  description: Energy Butler GEN2
+render:
+  - usage: grid
+    default: |
+      type: template
+      template: mtec-eb-gen2
+      usage: grid
+
+      # RS485 via adapter (Modbus RTU)
+      modbus: rs485serial
+      id: 1
+      device: /dev/ttyUSB0 # USB-RS485 Adapter Adresse
+      baudrate: 9600 # Prüfe die Geräteeinstellungen, typische Werte sind 9600, 19200, 38400, 57600, 115200
+      comset: "8N1" # Kommunikationsparameter für den Adapter
+
+      # RS485 via TCP/IP (Modbus RTU)
+      modbus: rs485tcpip
+      id: 1
+      host: 192.0.2.2 # Hostname
+      port: 502 # Port
+  - usage: pv
+    default: |
+      type: template
+      template: mtec-eb-gen2
+      usage: pv
+
+      # RS485 via adapter (Modbus RTU)
+      modbus: rs485serial
+      id: 1
+      device: /dev/ttyUSB0 # USB-RS485 Adapter Adresse
+      baudrate: 9600 # Prüfe die Geräteeinstellungen, typische Werte sind 9600, 19200, 38400, 57600, 115200
+      comset: "8N1" # Kommunikationsparameter für den Adapter
+
+      # RS485 via TCP/IP (Modbus RTU)
+      modbus: rs485tcpip
+      id: 1
+      host: 192.0.2.2 # Hostname
+      port: 502 # Port
+  - usage: battery
+    default: |
+      type: template
+      template: mtec-eb-gen2
+      usage: battery
+
+      # RS485 via adapter (Modbus RTU)
+      modbus: rs485serial
+      id: 1
+      device: /dev/ttyUSB0 # USB-RS485 Adapter Adresse
+      baudrate: 9600 # Prüfe die Geräteeinstellungen, typische Werte sind 9600, 19200, 38400, 57600, 115200
+      comset: "8N1" # Kommunikationsparameter für den Adapter
+
+      # RS485 via TCP/IP (Modbus RTU)
+      modbus: rs485tcpip
+      id: 1
+      host: 192.0.2.2 # Hostname
+      port: 502 # Port
diff --git a/templates/docs/meter/mtec-eb-gen3_0.yaml b/templates/docs/meter/mtec-eb-gen3_0.yaml
new file mode 100644
index 00000000..850d6ee2
--- /dev/null
+++ b/templates/docs/meter/mtec-eb-gen3_0.yaml
@@ -0,0 +1,58 @@
+product:
+  brand: M-TEC
+  description: Energy Butler GEN3
+render:
+  - usage: grid
+    default: |
+      type: template
+      template: mtec-eb-gen3
+      usage: grid
+
+      # RS485 via adapter (Modbus RTU)
+      modbus: rs485serial
+      id: 1
+      device: /dev/ttyUSB0 # USB-RS485 Adapter Adresse
+      baudrate: 9600 # Prüfe die Geräteeinstellungen, typische Werte sind 9600, 19200, 38400, 57600, 115200
+      comset: "8N1" # Kommunikationsparameter für den Adapter
+
+      # RS485 via TCP/IP (Modbus RTU)
+      modbus: rs485tcpip
+      id: 1
+      host: 192.0.2.2 # Hostname
+      port: 502 # Port
+  - usage: pv
+    default: |
+      type: template
+      template: mtec-eb-gen3
+      usage: pv
+
+      # RS485 via adapter (Modbus RTU)
+      modbus: rs485serial
+      id: 1
+      device: /dev/ttyUSB0 # USB-RS485 Adapter Adresse
+      baudrate: 9600 # Prüfe die Geräteeinstellungen, typische Werte sind 9600, 19200, 38400, 57600, 115200
+      comset: "8N1" # Kommunikationsparameter für den Adapter
+
+      # RS485 via TCP/IP (Modbus RTU)
+      modbus: rs485tcpip
+      id: 1
+      host: 192.0.2.2 # Hostname
+      port: 502 # Port
+  - usage: battery
+    default: |
+      type: template
+      template: mtec-eb-gen3
+      usage: battery
+
+      # RS485 via adapter (Modbus RTU)
+      modbus: rs485serial
+      id: 1
+      device: /dev/ttyUSB0 # USB-RS485 Adapter Adresse
+      baudrate: 9600 # Prüfe die Geräteeinstellungen, typische Werte sind 9600, 19200, 38400, 57600, 115200
+      comset: "8N1" # Kommunikationsparameter für den Adapter
+
+      # RS485 via TCP/IP (Modbus RTU)
+      modbus: rs485tcpip
+      id: 1
+      host: 192.0.2.2 # Hostname
+      port: 502 # Port
diff --git a/templates/docs/meter/sonnenbatterie-eco56_0.yaml b/templates/docs/meter/sonnenbatterie-eco56_0.yaml
new file mode 100644
index 00000000..32b4bb75
--- /dev/null
+++ b/templates/docs/meter/sonnenbatterie-eco56_0.yaml
@@ -0,0 +1,44 @@
+product:
+  brand: Sonnen
+  description: comfort, eco 5, eco 6, oem 6.5
+render:
+  - usage: grid
+    default: |
+      type: template
+      template: sonnenbatterie-eco56
+      usage: grid
+      host: 192.0.2.2 # IP-Adresse oder Hostname
+      port: 7979 # Port (Optional)
+    advanced: |
+      type: template
+      template: sonnenbatterie-eco56
+      usage: grid
+      host: 192.0.2.2 # IP-Adresse oder Hostname
+      port: 7979 # Port (Optional)
+  - usage: pv
+    default: |
+      type: template
+      template: sonnenbatterie-eco56
+      usage: pv
+      host: 192.0.2.2 # IP-Adresse oder Hostname
+      port: 7979 # Port (Optional)
+    advanced: |
+      type: template
+      template: sonnenbatterie-eco56
+      usage: pv
+      host: 192.0.2.2 # IP-Adresse oder Hostname
+      port: 7979 # Port (Optional)
+  - usage: battery
+    default: |
+      type: template
+      template: sonnenbatterie-eco56
+      usage: battery
+      host: 192.0.2.2 # IP-Adresse oder Hostname
+      port: 7979 # Port (Optional)
+    advanced: |
+      type: template
+      template: sonnenbatterie-eco56
+      usage: battery
+      host: 192.0.2.2 # IP-Adresse oder Hostname
+      port: 7979 # Port (Optional)
+      capacity: 50 # Akkukapazität in kWh (Optional)
diff --git a/templates/docs/meter/youless_0.yaml b/templates/docs/meter/youless_0.yaml
new file mode 100644
index 00000000..7753a7cd
--- /dev/null
+++ b/templates/docs/meter/youless_0.yaml
@@ -0,0 +1,19 @@
+product:
+  brand: Youless
+  description: Energy Monitor
+description: |
+  Zur Erfassung der PV-Produktion wird ein extern angebundenener S0-Erzeugungszähler benötigt.
+
+render:
+  - usage: grid
+    default: |
+      type: template
+      template: youless
+      usage: grid
+      host: 192.0.2.2 # IP-Adresse oder Hostname
+  - usage: pv
+    default: |
+      type: template
+      template: youless
+      usage: pv
+      host: 192.0.2.2 # IP-Adresse oder Hostname
diff --git a/templates/evcc.io/brands.json b/templates/evcc.io/brands.json
index 54666919..e2d5b021 100644
--- a/templates/evcc.io/brands.json
+++ b/templates/evcc.io/brands.json
@@ -97,6 +97,7 @@
   "Janitza",
   "Kostal",
   "LG",
+  "M-TEC",
   "my-PV",
   "myStrom",
   "OpenEMS",
@@ -126,6 +127,7 @@
   "VARTA",
   "Victron",
   "Volkszähler",
+  "Youless",
   "ZCS Azzurro"
  ],
  "PVBattery": [
@@ -147,6 +149,7 @@
   "IGEN Tech",
   "Kostal",
   "LG",
+  "M-TEC",
   "myStrom",
   "OpenEMS",
   "Powerfox",
@@ -171,6 +174,7 @@
   "TP-Link",
   "VARTA",
   "Victron",
+  "Youless",
   "ZCS Azzurro"
  ],
  "Vehicles": [
diff --git a/util/oauth/helper.go b/util/oauth/helper.go
new file mode 100644
index 00000000..c9692f98
--- /dev/null
+++ b/util/oauth/helper.go
@@ -0,0 +1,49 @@
+package oauth
+
+import (
+	"time"
+
+	"github.com/evcc-io/evcc/util"
+	"golang.org/x/oauth2"
+)
+
+// Refresh refreshes the token every 5m. If token refresh fails 5 times, it is aborted.
+func Refresh(log *util.Logger, token *oauth2.Token, ts oauth2.TokenSource, optMaxTokenLifetime ...time.Duration) {
+	var failed int
+
+	// limit lifetime of initial token
+	limitTokenLife(token, optMaxTokenLifetime...)
+
+	for range time.Tick(5 * time.Minute) {
+		if _, err := ts.Token(); err != nil {
+			t, err := ts.Token()
+			if err != nil {
+				failed++
+				if failed > 5 {
+					log.ERROR.Printf("token refresh: %v, giving up", err)
+					return
+				}
+				log.ERROR.Printf("token refresh: %v", err)
+			}
+
+			failed = 0
+
+			// limit lifetime of new tokens
+			if t.Expiry != token.Expiry {
+				token = t
+				limitTokenLife(token, optMaxTokenLifetime...)
+			}
+		}
+	}
+}
+
+func limitTokenLife(token *oauth2.Token, optMaxTokenLifetime ...time.Duration) {
+	if len(optMaxTokenLifetime) != 1 {
+		return
+	}
+
+	maxTokenLifetime := optMaxTokenLifetime[0]
+	if time.Until(token.Expiry) > maxTokenLifetime {
+		token.Expiry = time.Now().Add(maxTokenLifetime)
+	}
+}
diff --git a/vehicle/niu/types.go b/vehicle/niu/types.go
index fbe55532..7d4c2007 100644
--- a/vehicle/niu/types.go
+++ b/vehicle/niu/types.go
@@ -51,7 +51,7 @@ type Response struct {
 				BatteryCharging int64 `json:"batteryCharging,omitempty"`
 			} `json:"compartmentA"`
 		}
-		LeftTime         string `json:"leftTime,omitempty"`
-		EstimatedMileage int64  `json:"estimatedMileage,omitempty"`
+		// LeftTime         float32 `json:"leftTime,omitempty"`
+		EstimatedMileage int64 `json:"estimatedMileage,omitempty"`
 	}
 }
diff --git a/vehicle/porsche.go b/vehicle/porsche.go
index acb0e5e2..cf024edc 100644
--- a/vehicle/porsche.go
+++ b/vehicle/porsche.go
@@ -42,12 +42,12 @@ func NewPorscheFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	log := util.NewLogger("porsche").Redact(cc.User, cc.Password, cc.VIN)
 	identity := porsche.NewIdentity(log)
 
-	err := identity.Login(porsche.OAuth2Config, cc.User, cc.Password)
+	ts, err := identity.Login(porsche.OAuth2Config, cc.User, cc.Password)
 	if err != nil {
 		return nil, fmt.Errorf("login failed: %w", err)
 	}
 
-	api := porsche.NewAPI(log, identity)
+	api := porsche.NewAPI(log, ts)
 
 	cc.VIN, err = ensureVehicle(cc.VIN, func() ([]string, error) {
 		vehicles, err := api.Vehicles()
@@ -65,13 +65,7 @@ func NewPorscheFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		return nil, errors.New("vehicle is not paired with the My Porsche account")
 	}
 
-	// get eMobility capabilities
-	emobIdentity := porsche.NewIdentity(log)
-	if err := emobIdentity.Login(porsche.EmobilityOAuth2Config, cc.User, cc.Password); err != nil {
-		return nil, fmt.Errorf("emobility login failed: %w", err)
-	}
-
-	emobApi := porsche.NewEmobilityAPI(log, emobIdentity)
+	emobApi := porsche.NewEmobilityAPI(log, ts)
 	capabilities, err := emobApi.Capabilities(cc.VIN)
 	if err != nil {
 		return nil, err
diff --git a/vehicle/porsche/api_emobility.go b/vehicle/porsche/api_emobility.go
index 7d8deacc..ba7935dc 100644
--- a/vehicle/porsche/api_emobility.go
+++ b/vehicle/porsche/api_emobility.go
@@ -27,7 +27,7 @@ func NewEmobilityAPI(log *util.Logger, identity oauth2.TokenSource) *EmobilityAP
 			Base:   v.Client.Transport,
 		},
 		Decorator: transport.DecorateHeaders(map[string]string{
-			"apikey": EmobilityOAuth2Config.ClientID,
+			"apikey": ClientID,
 		}),
 	}
 
diff --git a/vehicle/porsche/identity.go b/vehicle/porsche/identity.go
index 09aa7b98..8ac53431 100644
--- a/vehicle/porsche/identity.go
+++ b/vehicle/porsche/identity.go
@@ -12,6 +12,7 @@ import (
 
 	"github.com/PuerkitoBio/goquery"
 	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/oauth"
 	"github.com/evcc-io/evcc/util/request"
 	cv "github.com/nirasan/go-oauth-pkce-code-verifier"
 	"github.com/samber/lo"
@@ -20,28 +21,22 @@ import (
 
 const (
 	OAuthURI = "https://identity.porsche.com"
+	ClientID = "UYsK00My6bCqJdbQhTQ0PbWmcSdIAMig"
+
+	maxTokenLifetime = time.Hour
 )
 
 // https://identity.porsche.com/.well-known/openid-configuration
 var (
-	endpoint = oauth2.Endpoint{
-		AuthURL:   OAuthURI + "/authorize",
-		TokenURL:  OAuthURI + "/oauth/token",
-		AuthStyle: oauth2.AuthStyleInParams,
-	}
-
 	OAuth2Config = &oauth2.Config{
-		ClientID:    "UYsK00My6bCqJdbQhTQ0PbWmcSdIAMig",
+		ClientID:    ClientID,
 		RedirectURL: "https://my.porsche.com/",
-		Endpoint:    endpoint,
-		Scopes:      []string{"openid", "offline_access"},
-	}
-
-	EmobilityOAuth2Config = &oauth2.Config{
-		ClientID:    OAuth2Config.ClientID,
-		RedirectURL: "https://my.porsche.com/myservices/auth/auth.html",
-		Endpoint:    endpoint,
-		Scopes:      []string{"openid", "offline_access"},
+		Endpoint: oauth2.Endpoint{
+			AuthURL:   OAuthURI + "/authorize",
+			TokenURL:  OAuthURI + "/oauth/token",
+			AuthStyle: oauth2.AuthStyleInParams,
+		},
+		Scopes: []string{"openid", "offline_access"},
 	}
 )
 
@@ -49,7 +44,6 @@ var (
 type Identity struct {
 	log *util.Logger
 	*request.Helper
-	oauth2.TokenSource
 }
 
 // NewIdentity creates Porsche identity
@@ -62,10 +56,10 @@ func NewIdentity(log *util.Logger) *Identity {
 	return v
 }
 
-func (v *Identity) Login(oc *oauth2.Config, user, password string) error {
+func (v *Identity) Login(oc *oauth2.Config, user, password string) (oauth2.TokenSource, error) {
 	cv, err := cv.CreateCodeVerifier()
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	state := lo.RandomString(16, lo.AlphanumericCharset)
@@ -85,13 +79,13 @@ func (v *Identity) Login(oc *oauth2.Config, user, password string) error {
 
 	resp, err := v.Client.Get(uri)
 	if err != nil {
-		return err
+		return nil, err
 	}
 	defer resp.Body.Close()
 
 	u, err := url.Parse(resp.Header.Get("Location"))
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	query := u.Query()
@@ -112,7 +106,7 @@ func (v *Identity) Login(oc *oauth2.Config, user, password string) error {
 	uri = fmt.Sprintf("%s/usernamepassword/login", OAuthURI)
 	resp, err = v.PostForm(uri, query)
 	if err != nil {
-		return err
+		return nil, err
 	}
 	defer resp.Body.Close()
 
@@ -121,14 +115,14 @@ func (v *Identity) Login(oc *oauth2.Config, user, password string) error {
 			Description string `json:"description"`
 		}
 		if err := json.NewDecoder(resp.Body).Decode(&res); err == nil && res.Description != "" {
-			return errors.New(res.Description)
+			return nil, errors.New(res.Description)
 		}
-		return fmt.Errorf("unexpected status %d", resp.StatusCode)
+		return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
 	}
 
 	doc, err := goquery.NewDocumentFromReader(resp.Body)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	query = make(url.Values)
@@ -145,13 +139,13 @@ func (v *Identity) Login(oc *oauth2.Config, user, password string) error {
 	uri = fmt.Sprintf("%s/login/callback", OAuthURI)
 	resp, err = v.PostForm(uri, query)
 	if err != nil {
-		return err
+		return nil, err
 	}
 	resp.Body.Close()
 
 	code, err := param()
 	if err != nil {
-		return err
+		return nil, err
 	}
 
 	cctx := context.WithValue(context.Background(), oauth2.HTTPClient, v.Client)
@@ -162,21 +156,11 @@ func (v *Identity) Login(oc *oauth2.Config, user, password string) error {
 		oauth2.SetAuthURLParam("code_verifier", cv.CodeChallengePlain()),
 	)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
-	ts := oc.TokenSource(cctx, token)
-	v.TokenSource = oauth2.ReuseTokenSourceWithExpiry(token, ts, 15*time.Minute)
+	ts := oauth2.ReuseTokenSourceWithExpiry(token, oc.TokenSource(cctx, token), 15*time.Minute)
+	go oauth.Refresh(v.log, token, ts, maxTokenLifetime)
 
-	go v.refresh()
-
-	return nil
-}
-
-func (v *Identity) refresh() {
-	for range time.Tick(5 * time.Minute) {
-		if _, err := v.Token(); err != nil {
-			v.log.ERROR.Printf("token refresh: %v", err)
-		}
-	}
+	return ts, err
 }
diff --git a/vehicle/vag/vwidentity/forms.go b/vehicle/vag/vwidentity/forms.go
index 14a49255..1e06929c 100644
--- a/vehicle/vag/vwidentity/forms.go
+++ b/vehicle/vag/vwidentity/forms.go
@@ -82,8 +82,14 @@ func ParseCredentialsPage(r io.ReadCloser) (CredentialParams, error) {
 	re := regexp.MustCompile(`(?s)window._IDK\s*=\s*(.*?)[;<]`)
 	match := re.FindAllStringSubmatch(buf.String(), -1)
 
+	// clean quotes
 	tmpl := strings.ReplaceAll(match[0][1], `'`, `"`)
-	for _, v := range []string{"templateModel", "currentLocale", "csrf_parameterName", "csrf_token", "userSession", "userId", "countryOfResidence", "baseUrl", "consentBaseUrl"} {
+
+	// strip , }
+	re2 := regexp.MustCompile(`(?s),\s+}`)
+	tmpl = re2.ReplaceAllString(tmpl, "}")
+
+	for _, v := range []string{"templateModel", "disabledFeatures", "isFooterEnabled", "currentLocale", "csrf_parameterName", "csrf_token", "userSession", "userId", "countryOfResidence", "baseUrl", "consentBaseUrl"} {
 		tmpl = strings.Replace(tmpl, v, fmt.Sprintf(`"%s"`, v), 1)
 	}
 
diff --git a/vehicle/volvo-connected.go b/vehicle/volvo-connected.go
index c3f7bce9..741e8473 100644
--- a/vehicle/volvo-connected.go
+++ b/vehicle/volvo-connected.go
@@ -64,20 +64,19 @@ func NewVolvoConnectedFromConfig(other map[string]interface{}) (api.Vehicle, err
 		return nil, err
 	}
 
-	if err := identity.Login(cc.User, cc.Password); err != nil {
+	ts, err := identity.Login(cc.User, cc.Password)
+	if err != nil {
 		return nil, err
 	}
 
-	_ = identity
 	// api := connected.NewAPI(log, identity, cc.Sandbox)
-	api := connected.NewAPI(log, identity, cc.VccApiKey)
+	api := connected.NewAPI(log, ts, cc.VccApiKey)
 
 	cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)
 
 	v := &VolvoConnected{
 		embed:    &cc.embed,
 		Provider: connected.NewProvider(api, cc.VIN, cc.Cache),
-		// ProviderLogin: identity, // expose the OAuth2 login
 	}
 
 	return v, err
diff --git a/vehicle/volvo/connected/identity.go b/vehicle/volvo/connected/identity.go
index 235e939a..2a56cb4a 100644
--- a/vehicle/volvo/connected/identity.go
+++ b/vehicle/volvo/connected/identity.go
@@ -4,6 +4,7 @@ import (
 	"net/http"
 	"net/url"
 	"strings"
+	"time"
 
 	"github.com/coreos/go-oidc/v3/oidc"
 	"github.com/evcc-io/evcc/util"
@@ -17,7 +18,8 @@ var Oauth2Config = oauth2.Config{
 		AuthURL:  "https://volvoid.eu.volvocars.com/as/authorization.oauth2",
 		TokenURL: "https://volvoid.eu.volvocars.com/as/token.oauth2",
 	},
-	Scopes: []string{oidc.ScopeOpenID, "vehicle:attributes",
+	Scopes: []string{
+		oidc.ScopeOpenID, "vehicle:attributes",
 		"energy:recharge_status", "energy:battery_charge_level", "energy:electric_range", "energy:estimated_charging_time", "energy:charging_connection_status", "energy:charging_system_status",
 		"conve:fuel_status", "conve:odometer_status", "conve:environment",
 	},
@@ -29,19 +31,20 @@ const (
 )
 
 type Identity struct {
+	log *util.Logger
 	*request.Helper
-	oauth2.TokenSource
 }
 
 func NewIdentity(log *util.Logger) (*Identity, error) {
 	v := &Identity{
+		log:    log,
 		Helper: request.NewHelper(log),
 	}
 
 	return v, nil
 }
 
-func (v *Identity) Login(user, password string) error {
+func (v *Identity) Login(user, password string) (oauth2.TokenSource, error) {
 	data := url.Values{
 		"username":                {user},
 		"password":                {password},
@@ -54,15 +57,20 @@ func (v *Identity) Login(user, password string) error {
 		"Content-Type":  request.FormContent,
 		"Authorization": basicAuth,
 	})
+	if err != nil {
+		return nil, err
+	}
 
-	if err == nil {
-		var token oauth2.Token
-		if err = v.DoJSON(req, &token); err == nil {
-			v.TokenSource = oauth.RefreshTokenSource(&token, v)
-		}
+	var token oauth.Token
+	if err := v.DoJSON(req, &token); err != nil {
+		return nil, err
 	}
 
-	return err
+	oauthToken := (*oauth2.Token)(&token)
+	ts := oauth2.ReuseTokenSourceWithExpiry(oauthToken, oauth.RefreshTokenSource(oauthToken, v), 15*time.Minute)
+	go oauth.Refresh(v.log, oauthToken, ts)
+
+	return ts, nil
 }
 
 func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
@@ -76,11 +84,12 @@ func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
 		"Content-Type":  request.FormContent,
 		"Authorization": basicAuth,
 	})
+	if err != nil {
+		return nil, err
+	}
 
 	var res oauth2.Token
-	if err == nil {
-		err = v.DoJSON(req, &res)
-	}
+	err = v.DoJSON(req, &res)
 
 	return &res, err
 }


Test patch:
diff --git a/assets/js/mixins/formatter.test.js b/assets/js/mixins/formatter.test.js
index b4b804ec..2162c451 100644
--- a/assets/js/mixins/formatter.test.js
+++ b/assets/js/mixins/formatter.test.js
@@ -31,9 +31,10 @@ describe("fmtPricePerKWh", () => {
   test("should format with units", () => {
     expect(fmt.fmtPricePerKWh(0.2, "EUR")).eq("20,0 ct/kWh");
     expect(fmt.fmtPricePerKWh(0.2, "EUR", true)).eq("20,0 ct");
-    expect(fmt.fmtPricePerKWh(0.234, "USD")).eq("23,4 ct/kWh");
+    expect(fmt.fmtPricePerKWh(0.234, "USD")).eq("23,4 ¢/kWh");
     expect(fmt.fmtPricePerKWh(1234, "SEK")).eq("1.234,0 SEK/kWh");
     expect(fmt.fmtPricePerKWh(0.2, "EUR", false, false)).eq("20,0");
+    expect(fmt.fmtPricePerKWh(0.123, "CHF")).eq("12,3 rp/kWh");
   });
 });
 
@@ -41,8 +42,9 @@ describe("pricePerKWhUnit", () => {
   test("should return correct unit", () => {
     expect(fmt.pricePerKWhUnit("EUR")).eq("ct/kWh");
     expect(fmt.pricePerKWhUnit("EUR", true)).eq("ct");
-    expect(fmt.pricePerKWhUnit("USD")).eq("ct/kWh");
+    expect(fmt.pricePerKWhUnit("USD")).eq("¢/kWh");
     expect(fmt.pricePerKWhUnit("SEK")).eq("SEK/kWh");
     expect(fmt.pricePerKWhUnit("SEK", true)).eq("SEK");
+    expect(fmt.pricePerKWhUnit("CHF")).eq("rp/kWh");
   });
 });
diff --git a/vehicle/niu/types_test.go b/vehicle/niu/types_test.go
index e37768b2..3c4c39f3 100644
--- a/vehicle/niu/types_test.go
+++ b/vehicle/niu/types_test.go
@@ -28,7 +28,7 @@ func TestUnmarshalNiuToken(t *testing.T) {
 
 func TestUnmarshalNiuResponse(t *testing.T) {
 	var tok Response
-	str := `{"data":{"isCharging":0,"lockStatus":0,"isAccOn":0,"isFortificationOn":0,"isConnected":true,"position":{"lat":42.330353,"lng":11.450598},"hdop":1,"time":1617697890173,"batteries":{"compartmentA":{"bmsId":"bmsId","isConnected":true,"batteryCharging":59,"gradeBattery":"94.8"},"compartmentB":{"bmsId":"bmsId","isConnected":true,"batteryCharging":58,"gradeBattery":"95.9"}},"leftTime":"10.2","estimatedMileage":68,"gpsTimestamp":1617697890173,"infoTimestamp":1617697890173,"nowSpeed":0,"shakingValue":3,"locationType":1,"batteryDetail":true,"centreCtrlBattery":100,"ss_protocol_ver":3,"ss_online_sta":"1","gps":5,"gsm":14,"lastTrack":{"ridingTime":90,"distance":571,"time":1617269526360}},"desc":"成功","trace":"成功","status":0}`
+	str := `{"data":{"isCharging":0,"lockStatus":0,"isAccOn":0,"isFortificationOn":0,"isConnected":true,"position":{"lat":42.330353,"lng":11.450598},"hdop":1,"time":1617697890173,"batteries":{"compartmentA":{"bmsId":"bmsId","isConnected":true,"batteryCharging":59,"gradeBattery":"94.8"},"compartmentB":{"bmsId":"bmsId","isConnected":true,"batteryCharging":58,"gradeBattery":"95.9"}},"leftTime":10.2,"estimatedMileage":68,"gpsTimestamp":1617697890173,"infoTimestamp":1617697890173,"nowSpeed":0,"shakingValue":3,"locationType":1,"batteryDetail":true,"centreCtrlBattery":100,"ss_protocol_ver":3,"ss_online_sta":"1","gps":5,"gsm":14,"lastTrack":{"ridingTime":90,"distance":571,"time":1617269526360}},"desc":"成功","trace":"成功","status":0}`
 
 	if err := json.Unmarshal([]byte(str), &tok); err != nil {
 		t.Error(err)
@@ -46,7 +46,7 @@ func TestUnmarshalNiuResponse(t *testing.T) {
 		t.Error("IsCharging")
 	}
 
-	if tok.Data.LeftTime != "10.2" {
-		t.Error("LeftTime")
-	}
+	// if tok.Data.LeftTime != 10.2 {
+	// 	t.Error("LeftTime")
+	// }
 }