Repository: evcc-io/evcc
Pull request #10535 (2023-10-28 16:00:20)

Add Loxone template
Add support for Loxone Miniserver (Compact, Go)

Screenshot: Loxone Config 
![Bildschirmfoto 2023-10-28 um 17 58 07](https://github.com/evcc-io/evcc/assets/152287/bd6f83e4-149a-427a-ae3d-d2c8ddab8583)


Example configuration
```yaml
meters:
  - name: pv
    type: template
    template: loxone
    usage: pv
    host: loxone-one.local
    user: admin
    password: pw
    meterblock: PV
  - name: grid
    type: template
    template: loxone
    usage: grid
    host: loxone-one.local
    user: admin
    password: pw
    meterblock: Netz
  - name: battery
    type: template
    template: loxone
    usage: battery
    host: loxone-one.local
    user: admin
    password: pw
    meterblock: Speicher
    socblock: AI4
```

Note: "Meter for Storage" does not export SoC. You have to specify a dedicated name for the SoC value.


Patch:
diff --git a/assets/js/components/Savings.vue b/assets/js/components/Savings.vue
index 014ec37d..8163f689 100644
--- a/assets/js/components/Savings.vue
+++ b/assets/js/components/Savings.vue
@@ -212,7 +212,7 @@ export default {
 	components: { Sponsor, SavingsTile, LiveCommunity, TelemetrySettings, CustomSelect },
 	mixins: [formatter],
 	props: {
-		stats: { type: Object, default: () => ({}) },
+		statistics: { type: Object, default: () => ({}) },
 		co2Configured: Boolean,
 		sponsor: String,
 		currency: String,
@@ -256,7 +256,7 @@ export default {
 		},
 		avgPriceFormatted() {
 			const value = this.fmtPricePerKWh(
-				this.currentStats.avgPrice,
+				this.currentStatistics.avgPrice,
 				this.currency,
 				false,
 				false
@@ -264,14 +264,14 @@ export default {
 			const unit = this.pricePerKWhUnit(this.currency);
 			return { value, unit };
 		},
-		currentStats() {
-			return this.stats[this.period] || {};
+		currentStatistics() {
+			return this.statistics[this.period] || {};
 		},
 		totalCharged() {
-			return this.currentStats.chargedKWh;
+			return this.currentStatistics.chargedKWh;
 		},
 		solarPercentage() {
-			return this.currentStats.solarPercentage;
+			return this.currentStatistics.solarPercentage;
 		},
 		solarCharged() {
 			return (this.solarPercentage / 100) * this.totalCharged;
@@ -280,10 +280,10 @@ export default {
 			return this.totalCharged - this.solarCharged;
 		},
 		avgPrice() {
-			return this.currentStats.avgPrice;
+			return this.currentStatistics.avgPrice;
 		},
 		avgCo2() {
-			return this.currentStats.avgCo2;
+			return this.currentStatistics.avgCo2;
 		},
 		priceConfigured() {
 			return this.referenceGrid !== undefined;
diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue
index b1657da5..bb5bac05 100644
--- a/assets/js/components/Site.vue
+++ b/assets/js/components/Site.vue
@@ -80,7 +80,7 @@ export default {
 		auth: Object,
 
 		currency: String,
-		stats: Object,
+		statistics: Object,
 		tariffFeedIn: Number,
 		tariffGrid: Number,
 		tariffCo2: Number,
@@ -136,7 +136,7 @@ export default {
 				},
 				sponsor: this.sponsor,
 				savings: {
-					stats: this.stats,
+					statistics: this.statistics,
 					co2Configured: this.tariffCo2 !== undefined,
 					priceConfigured: this.tariffGrid !== undefined,
 					currency: this.currency,
diff --git a/charger/amperfied.go b/charger/amperfied.go
new file mode 100644
index 00000000..de14e0c6
--- /dev/null
+++ b/charger/amperfied.go
@@ -0,0 +1,322 @@
+package charger
+
+// LICENSE
+
+// Copyright (c) 2023 premultiply
+
+// This module is NOT covered by the MIT license. All rights reserved.
+
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+import (
+	"encoding/binary"
+	"fmt"
+	"time"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/modbus"
+	"github.com/evcc-io/evcc/util/sponsor"
+)
+
+// Amperfied charger implementation
+type Amperfied struct {
+	log     *util.Logger
+	conn    *modbus.Connection
+	current uint16
+	wakeup  bool
+}
+
+const (
+	ampRegChargingState  = 5    // Input
+	ampRegCurrents       = 6    // Input 6,7,8
+	ampRegTemperature    = 9    // Input
+	ampRegVoltages       = 10   // Input 10,11,12
+	ampRegPower          = 14   // Input
+	ampRegEnergy         = 17   // Input
+	ampRegTimeoutConfig  = 257  // Holding
+	ampRegRemoteLock     = 259  // Holding
+	ampRegAmpsConfig     = 261  // Holding
+	ampRegFailSafeConfig = 262  // Holding
+	ampRegRfidUID        = 2002 // Input
+)
+
+func init() {
+	registry.Add("amperfied", NewAmperfiedFromConfig)
+}
+
+// NewAmperfiedFromConfig creates a Amperfied charger from generic config
+func NewAmperfiedFromConfig(other map[string]interface{}) (api.Charger, error) {
+	cc := modbus.TcpSettings{
+		ID: 255,
+	}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	return NewAmperfied(cc.URI, cc.ID)
+}
+
+// NewAmperfied creates Amperfied charger
+func NewAmperfied(uri string, slaveID uint8) (api.Charger, error) {
+	conn, err := modbus.NewConnection(uri, "", "", 0, modbus.Tcp, slaveID)
+	if err != nil {
+		return nil, err
+	}
+
+	if !sponsor.IsAuthorized() {
+		return nil, api.ErrSponsorRequired
+	}
+
+	log := util.NewLogger("amperfied")
+	conn.Logger(log.TRACE)
+
+	wb := &Amperfied{
+		log:     log,
+		conn:    conn,
+		current: 60, // assume min current
+	}
+
+	// get failsafe timeout from charger
+	b, err := wb.conn.ReadHoldingRegisters(ampRegTimeoutConfig, 1)
+	if err != nil {
+		return nil, fmt.Errorf("failsafe timeout: %w", err)
+	}
+	if u := binary.BigEndian.Uint16(b); u > 0 {
+		go wb.heartbeat(time.Duration(u) * time.Millisecond / 2)
+	}
+
+	return wb, nil
+}
+
+func (wb *Amperfied) heartbeat(timeout time.Duration) {
+	for range time.Tick(timeout) {
+		if _, err := wb.Status(); err != nil {
+			wb.log.ERROR.Println("heartbeat:", err)
+		}
+	}
+}
+
+func (wb *Amperfied) set(reg, val uint16) error {
+	b := make([]byte, 2)
+	binary.BigEndian.PutUint16(b, val)
+
+	_, err := wb.conn.WriteMultipleRegisters(reg, 1, b)
+
+	return err
+}
+
+// Status implements the api.Charger interface
+func (wb *Amperfied) Status() (api.ChargeStatus, error) {
+	b, err := wb.conn.ReadInputRegisters(ampRegChargingState, 1)
+	if err != nil {
+		return api.StatusNone, err
+	}
+
+	sb := binary.BigEndian.Uint16(b)
+
+	if sb != 10 {
+		wb.wakeup = false
+	}
+
+	switch sb {
+	case 2, 3:
+		return api.StatusA, nil
+	case 4, 5:
+		return api.StatusB, nil
+	case 6, 7:
+		return api.StatusC, nil
+	case 8:
+		return api.StatusD, nil
+	case 9:
+		return api.StatusE, nil
+	case 10:
+		// ensure RemoteLock is disabled after wake-up
+		b, err := wb.conn.ReadHoldingRegisters(ampRegRemoteLock, 1)
+		if err != nil {
+			return api.StatusNone, err
+		}
+
+		// unlock
+		if binary.BigEndian.Uint16(b) != 1 {
+			if err := wb.set(ampRegRemoteLock, 1); err != nil {
+				return api.StatusNone, err
+			}
+		}
+
+		// keep status B2 during wakeup
+		if wb.wakeup {
+			return api.StatusB, nil
+		}
+
+		return api.StatusF, nil
+	default:
+		return api.StatusNone, fmt.Errorf("invalid status: %d", sb)
+	}
+}
+
+// Enabled implements the api.Charger interface
+func (wb *Amperfied) Enabled() (bool, error) {
+	b, err := wb.conn.ReadHoldingRegisters(ampRegAmpsConfig, 1)
+	if err != nil {
+		return false, err
+	}
+
+	cur := binary.BigEndian.Uint16(b)
+
+	enabled := cur != 0
+	if enabled {
+		wb.current = cur
+	}
+
+	return enabled, nil
+}
+
+// Enable implements the api.Charger interface
+func (wb *Amperfied) Enable(enable bool) error {
+	var cur uint16
+	if enable {
+		cur = wb.current
+	}
+
+	b := make([]byte, 2)
+	binary.BigEndian.PutUint16(b, cur)
+
+	_, err := wb.conn.WriteMultipleRegisters(ampRegAmpsConfig, 1, b)
+
+	return err
+}
+
+// MaxCurrent implements the api.Charger interface
+func (wb *Amperfied) MaxCurrent(current int64) error {
+	return wb.MaxCurrentMillis(float64(current))
+}
+
+var _ api.ChargerEx = (*Amperfied)(nil)
+
+// MaxCurrentMillis implements the api.ChargerEx interface
+func (wb *Amperfied) MaxCurrentMillis(current float64) error {
+	if current < 6 {
+		return fmt.Errorf("invalid current %.1f", current)
+	}
+
+	cur := uint16(10 * current)
+
+	b := make([]byte, 2)
+	binary.BigEndian.PutUint16(b, cur)
+
+	_, err := wb.conn.WriteMultipleRegisters(ampRegAmpsConfig, 1, b)
+	if err == nil {
+		wb.current = cur
+	}
+
+	return err
+}
+
+var _ api.Meter = (*Amperfied)(nil)
+
+// CurrentPower implements the api.Meter interface
+func (wb *Amperfied) CurrentPower() (float64, error) {
+	b, err := wb.conn.ReadInputRegisters(ampRegPower, 1)
+	if err != nil {
+		return 0, err
+	}
+
+	return float64(binary.BigEndian.Uint16(b)), nil
+}
+
+var _ api.MeterEnergy = (*Amperfied)(nil)
+
+// TotalEnergy implements the api.MeterEnergy interface
+func (wb *Amperfied) TotalEnergy() (float64, error) {
+	b, err := wb.conn.ReadInputRegisters(ampRegEnergy, 2)
+	if err != nil {
+		return 0, err
+	}
+
+	return float64(binary.BigEndian.Uint32(b)) / 1e3, nil
+}
+
+// getPhaseValues returns 3 sequential register values
+func (wb *Amperfied) getPhaseValues(reg uint16, divider float64) (float64, float64, float64, error) {
+	b, err := wb.conn.ReadInputRegisters(reg, 3)
+	if err != nil {
+		return 0, 0, 0, err
+	}
+
+	var res [3]float64
+	for i := 0; i < 3; i++ {
+		res[i] = float64(binary.BigEndian.Uint16(b[2*i:])) / divider
+	}
+
+	return res[0], res[1], res[2], nil
+}
+
+var _ api.PhaseCurrents = (*Amperfied)(nil)
+
+// Currents implements the api.PhaseCurrents interface
+func (wb *Amperfied) Currents() (float64, float64, float64, error) {
+	return wb.getPhaseValues(ampRegCurrents, 10)
+}
+
+var _ api.PhaseVoltages = (*Amperfied)(nil)
+
+// Voltages implements the api.PhaseVoltages interface
+func (wb *Amperfied) Voltages() (float64, float64, float64, error) {
+	return wb.getPhaseValues(ampRegVoltages, 1)
+}
+
+var _ api.Identifier = (*Amperfied)(nil)
+
+// identify implements the api.Identifier interface
+func (wb *Amperfied) Identify() (string, error) {
+	b, err := wb.conn.ReadInputRegisters(ampRegRfidUID, 6)
+	if err != nil {
+		return "", err
+	}
+
+	return bytesAsString(b), nil
+}
+
+var _ api.Diagnosis = (*Amperfied)(nil)
+
+// Diagnose implements the api.Diagnosis interface
+func (wb *Amperfied) Diagnose() {
+	if b, err := wb.conn.ReadInputRegisters(ampRegTemperature, 1); err == nil {
+		fmt.Printf("Temperature:\t%.1fC\n", float64(int16(binary.BigEndian.Uint16(b)))/10)
+	}
+	if b, err := wb.conn.ReadHoldingRegisters(ampRegTimeoutConfig, 1); err == nil {
+		fmt.Printf("Timeout:\t%d\n", binary.BigEndian.Uint16(b))
+	}
+	if b, err := wb.conn.ReadHoldingRegisters(ampRegRemoteLock, 1); err == nil {
+		fmt.Printf("Remote Lock:\t%d\n", binary.BigEndian.Uint16(b))
+	}
+	if b, err := wb.conn.ReadHoldingRegisters(ampRegFailSafeConfig, 1); err == nil {
+		fmt.Printf("FailSafe:\t%d\n", binary.BigEndian.Uint16(b))
+	}
+}
+
+var _ api.Resurrector = (*Amperfied)(nil)
+
+// WakeUp implements the api.Resurrector interface
+func (wb *Amperfied) WakeUp() error {
+	// force status F by locking
+	if err := wb.set(ampRegRemoteLock, 0); err == nil {
+		// Takes at least ~10 sec to return to normal operation
+		// after locking even if unlocking immediately.
+		wb.wakeup = true
+	}
+
+	// return to normal operation by unlocking after ~10 sec
+	return wb.set(ampRegRemoteLock, 1)
+}
diff --git a/charger/openevse.go b/charger/openevse.go
index bf4729fe..26c5d32e 100644
--- a/charger/openevse.go
+++ b/charger/openevse.go
@@ -103,8 +103,9 @@ func (c *OpenEVSE) setOverride() error {
 	if c.enabled {
 		state = openevse.Enabled
 	}
-	data.State = &state
-	data.MaxCurrent = &c.current
+
+	data.State = state
+	data.MaxCurrent = c.current
 
 	req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
 	if err == nil {
@@ -153,11 +154,11 @@ func (c *OpenEVSE) Status() (api.ChargeStatus, error) {
 		255: "disabled"
 	*/
 
-	switch state := *res.State; state {
+	switch state := res.State; state {
 	case 1:
 		return api.StatusA, err
 	case 2, 254, 255:
-		if *res.Vehicle == 1 {
+		if res.Vehicle == 1 {
 			return api.StatusB, err
 		}
 		return api.StatusA, err
@@ -175,7 +176,7 @@ func (c *OpenEVSE) Status() (api.ChargeStatus, error) {
 // Enabled implements the api.Charger interface
 func (c *OpenEVSE) Enabled() (bool, error) {
 	res, err := c.statusG.Get()
-	return *res.Status == openevse.Enabled, err
+	return res.Status == openevse.Enabled, err
 }
 
 // Enable implements the api.Charger interface
@@ -199,7 +200,7 @@ func (c *OpenEVSE) ChargedEnergy() (float64, error) {
 		return 0, err
 	}
 
-	return *res.SessionEnergy / 1e3, err
+	return res.SessionEnergy / 1e3, err
 }
 
 var _ api.ChargeTimer = (*OpenEVSE)(nil)
@@ -209,7 +210,8 @@ func (c *OpenEVSE) ChargingTime() (time.Duration, error) {
 	if err != nil {
 		return 0, err
 	}
-	return time.Duration(*res.SessionElapsed) * time.Second, err
+
+	return time.Duration(res.SessionElapsed) * time.Second, err
 }
 
 var _ api.MeterEnergy = (*OpenEVSE)(nil)
@@ -221,7 +223,7 @@ func (c *OpenEVSE) TotalEnergy() (float64, error) {
 		return 0, err
 	}
 
-	return *res.TotalEnergy, err
+	return res.TotalEnergy, err
 }
 
 var _ api.Meter = (*OpenEVSE)(nil)
@@ -231,7 +233,8 @@ func (c *OpenEVSE) CurrentPower() (float64, error) {
 	if err != nil {
 		return 0, err
 	}
-	return *res.Power, err
+
+	return res.Power, err
 }
 
 // phases1p3p implements the api.ChargePhases interface
diff --git a/charger/openevse/types.go b/charger/openevse/types.go
index 11a3492f..b471adc7 100644
--- a/charger/openevse/types.go
+++ b/charger/openevse/types.go
@@ -7,24 +7,24 @@ const (
 
 // Only keep the interesting properties from the status endpoint
 type Status struct {
-	Amp            *float64 `json:"amp,omitempty"`             // the value of the charge current in mA
-	Elapsed        *float64 `json:"elapsed,omitempty"`         // current session duration in seconds
-	ManualOverride *int     `json:"manual_override,omitempty"` // 1 = active, 0 = default
-	Mode           *string  `json:"mode,omitempty"`            // The current mode of the EVSE
-	Pilot          *int     `json:"pilot,omitempty"`           // the pilot value, in amps
-	Power          *float64 `json:"power,omitempty"`           // apparent power in watts
-	SessionEnergy  *float64 `json:"session_energy"`            // The total amount of energy accumulated for current session (in wh)
-	SessionElapsed *int     `json:"session_elapsed"`           // duration of this charging session in seconds
-	State          *int     `json:"state,omitempty"`           // evse state 1=A 2=B 3=C 4=D 5-11=F 254=sleeping 255=disabled
-	Status         *string  `json:"status,omitempty"`          // active, disabled, none, unknown
-	TotalEnergy    *float64 `json:"total_energy"`              // The total amount of energy accumulated (in kwh)
-	Vehicle        *int     `json:"vehicle,omitempty"`         // 0=not connected, 1=connected
-	Voltage        *float64 `json:"voltage,omitempty"`         // supplied via MQTT/Tesla/HTTP or assume a default
+	Amp            float64 `json:"amp,omitempty"`             // the value of the charge current in mA
+	Elapsed        float64 `json:"elapsed,omitempty"`         // current session duration in seconds
+	ManualOverride int     `json:"manual_override,omitempty"` // 1 = active, 0 = default
+	Mode           string  `json:"mode,omitempty"`            // The current mode of the EVSE
+	Pilot          int     `json:"pilot,omitempty"`           // the pilot value, in amps
+	Power          float64 `json:"power,omitempty"`           // apparent power in watts
+	SessionEnergy  float64 `json:"session_energy"`            // The total amount of energy accumulated for current session (in wh)
+	SessionElapsed int     `json:"session_elapsed"`           // duration of this charging session in seconds
+	State          int     `json:"state,omitempty"`           // evse state 1=A 2=B 3=C 4=D 5-11=F 254=sleeping 255=disabled
+	Status         string  `json:"status,omitempty"`          // active, disabled, none, unknown
+	TotalEnergy    float64 `json:"total_energy"`              // The total amount of energy accumulated (in kwh)
+	Vehicle        int     `json:"vehicle,omitempty"`         // 0=not connected, 1=connected
+	Voltage        float64 `json:"voltage,omitempty"`         // supplied via MQTT/Tesla/HTTP or assume a default
 }
 
 type Override struct {
-	State         *string `json:"state,omitempty"`          // Either enable charging (active) or block charging (disabled)
-	ChargeCurrent *int    `json:"charge_current,omitempty"` // Specify the active charge current in Amps >= 0
-	MaxCurrent    *int    `json:"max_current,omitempty"`    // Maximum current, primarily for load sharing situations
-	AutoRelease   *bool   `json:"auto_release,omitempty"`
+	State         string `json:"state,omitempty"`          // Either enable charging (active) or block charging (disabled)
+	ChargeCurrent int    `json:"charge_current,omitempty"` // Specify the active charge current in Amps >= 0
+	MaxCurrent    int    `json:"max_current,omitempty"`    // Maximum current, primarily for load sharing situations
+	AutoRelease   bool   `json:"auto_release,omitempty"`
 }
diff --git a/charger/openwb-2.0.go b/charger/openwb-2.0.go
index e182a8d6..bf24a16e 100644
--- a/charger/openwb-2.0.go
+++ b/charger/openwb-2.0.go
@@ -38,6 +38,8 @@ func init() {
 
 // https://openwb.de/main/wp-content/uploads/2023/10/ModbusTCP-openWB-series2-Pro-1.pdf
 
+//go:generate go run ../cmd/tools/decorate.go -f decorateOpenWB20 -b *OpenWB20 -r api.Charger -t "api.Identifier,Identify,func() (string, error)"
+
 // NewOpenWB20FromConfig creates a OpenWB20 charger from generic config
 func NewOpenWB20FromConfig(other map[string]interface{}) (api.Charger, error) {
 	cc := modbus.TcpSettings{
@@ -48,7 +50,17 @@ func NewOpenWB20FromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewOpenWB20(cc.URI, cc.ID)
+	wb, err := NewOpenWB20(cc.URI, cc.ID)
+	if err != nil {
+		return nil, err
+	}
+
+	var identify func() (string, error)
+	if _, err := wb.identify(); err == nil {
+		identify = wb.identify
+	}
+
+	return decorateOpenWB20(wb, identify), nil
 }
 
 // NewOpenWB20 creates OpenWB20 charger
@@ -180,10 +192,8 @@ func (wb *OpenWB20) WakeUp() error {
 	return err
 }
 
-var _ api.Identifier = (*OpenWB20)(nil)
-
 // Identify implements the api.Identifier interface
-func (wb *OpenWB20) Identify() (string, error) {
+func (wb *OpenWB20) identify() (string, error) {
 	b, err := wb.conn.ReadInputRegisters(openwbRegRfid, 10)
 	if err != nil {
 		return "", err
diff --git a/charger/openwb-2.0_decorators.go b/charger/openwb-2.0_decorators.go
new file mode 100644
index 00000000..4887e8db
--- /dev/null
+++ b/charger/openwb-2.0_decorators.go
@@ -0,0 +1,35 @@
+package charger
+
+// Code generated by github.com/evcc-io/evcc/cmd/tools/decorate.go. DO NOT EDIT.
+
+import (
+	"github.com/evcc-io/evcc/api"
+)
+
+func decorateOpenWB20(base *OpenWB20, identifier func() (string, error)) api.Charger {
+	switch {
+	case identifier == nil:
+		return base
+
+	case identifier != nil:
+		return &struct {
+			*OpenWB20
+			api.Identifier
+		}{
+			OpenWB20: base,
+			Identifier: &decorateOpenWB20IdentifierImpl{
+				identifier: identifier,
+			},
+		}
+	}
+
+	return nil
+}
+
+type decorateOpenWB20IdentifierImpl struct {
+	identifier func() (string, error)
+}
+
+func (impl *decorateOpenWB20IdentifierImpl) Identify() (string, error) {
+	return impl.identifier()
+}
diff --git a/core/site_api.go b/core/site_api.go
index 6be6cdeb..7df9be2d 100644
--- a/core/site_api.go
+++ b/core/site_api.go
@@ -10,6 +10,8 @@ import (
 
 var _ site.API = (*Site)(nil)
 
+var ErrBatteryNotConfigured = errors.New("battery not configured")
+
 const (
 	GridTariff    = "grid"
 	FeedinTariff  = "feedin"
@@ -29,13 +31,17 @@ func (site *Site) SetPrioritySoc(soc float64) error {
 	defer site.Unlock()
 
 	if len(site.batteryMeters) == 0 {
-		return errors.New("battery not configured")
+		return ErrBatteryNotConfigured
 	}
 
-	site.PrioritySoc = soc
-	settings.SetFloat("site.prioritySoc", site.PrioritySoc)
-	site.publish("prioritySoc", site.PrioritySoc)
+	site.log.DEBUG.Println("set priority soc:", soc)
+
+	if site.PrioritySoc != soc {
+		site.PrioritySoc = soc
+		settings.SetFloat("site.prioritySoc", site.PrioritySoc)
+		site.publish("prioritySoc", site.PrioritySoc)
 
+	}
 	return nil
 }
 
@@ -52,12 +58,16 @@ func (site *Site) SetBufferSoc(soc float64) error {
 	defer site.Unlock()
 
 	if len(site.batteryMeters) == 0 {
-		return errors.New("battery not configured")
+		return ErrBatteryNotConfigured
 	}
 
-	site.BufferSoc = soc
-	settings.SetFloat("site.bufferSoc", site.BufferSoc)
-	site.publish("bufferSoc", site.BufferSoc)
+	site.log.DEBUG.Println("set buffer soc:", soc)
+
+	if site.BufferSoc != soc {
+		site.BufferSoc = soc
+		settings.SetFloat("site.bufferSoc", site.BufferSoc)
+		site.publish("bufferSoc", site.BufferSoc)
+	}
 
 	return nil
 }
@@ -75,12 +85,16 @@ func (site *Site) SetBufferStartSoc(soc float64) error {
 	defer site.Unlock()
 
 	if len(site.batteryMeters) == 0 {
-		return errors.New("battery not configured")
+		return ErrBatteryNotConfigured
 	}
 
-	site.BufferStartSoc = soc
-	settings.SetFloat("site.bufferStartSoc", site.BufferStartSoc)
-	site.publish("bufferStartSoc", site.BufferStartSoc)
+	site.log.DEBUG.Println("set buffer start soc:", soc)
+
+	if site.BufferStartSoc != soc {
+		site.BufferStartSoc = soc
+		settings.SetFloat("site.bufferStartSoc", site.BufferStartSoc)
+		site.publish("bufferStartSoc", site.BufferStartSoc)
+	}
 
 	return nil
 }
@@ -97,8 +111,12 @@ func (site *Site) SetResidualPower(power float64) error {
 	site.Lock()
 	defer site.Unlock()
 
-	site.ResidualPower = power
-	site.publish("residualPower", site.ResidualPower)
+	site.log.DEBUG.Println("set residual power:", power)
+
+	if site.ResidualPower != power {
+		site.ResidualPower = power
+		site.publish("residualPower", site.ResidualPower)
+	}
 
 	return nil
 }
@@ -115,9 +133,13 @@ func (site *Site) SetSmartCostLimit(val float64) error {
 	site.Lock()
 	defer site.Unlock()
 
-	site.SmartCostLimit = val
-	settings.SetFloat("site.smartCostLimit", site.SmartCostLimit)
-	site.publish("smartCostLimit", site.SmartCostLimit)
+	site.log.DEBUG.Println("set smart cost limit:", val)
+
+	if site.SmartCostLimit != val {
+		site.SmartCostLimit = val
+		settings.SetFloat("site.smartCostLimit", site.SmartCostLimit)
+		site.publish("smartCostLimit", site.SmartCostLimit)
+	}
 
 	return nil
 }
diff --git a/core/stats.go b/core/stats.go
index 1c77ab2f..6f356b16 100644
--- a/core/stats.go
+++ b/core/stats.go
@@ -30,12 +30,12 @@ func (s *Stats) Update(p publisher) {
 		return
 	}
 
-	var stats = map[string]map[string]float64{
+	stats := map[string]map[string]float64{
 		"30d":   s.calculate(30),
 		"365d":  s.calculate(365),
 		"total": s.calculate(365 * 100), // 100 years
 	}
-	p.publish("stats", stats)
+	p.publish("statistics", stats)
 
 	s.updated = time.Now()
 }
diff --git a/provider/pipeline/pipeline.go b/provider/pipeline/pipeline.go
index d99389d3..fe470e99 100644
--- a/provider/pipeline/pipeline.go
+++ b/provider/pipeline/pipeline.go
@@ -8,11 +8,9 @@ import (
 	"strings"
 
 	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"
 	"github.com/volkszaehler/mbmd/meters/rs485"
 )
 
@@ -23,8 +21,6 @@ type Pipeline struct {
 	dflt   string
 	unpack string
 	decode string
-	vm     *otto.Otto
-	script string
 }
 
 type Settings struct {
@@ -33,8 +29,6 @@ type Settings struct {
 	Jq      string
 	Unpack  string
 	Decode  string
-	VM      string
-	Script  string
 }
 
 func New(log *util.Logger, cc Settings) (*Pipeline, error) {
@@ -59,10 +53,6 @@ func New(log *util.Logger, cc Settings) (*Pipeline, error) {
 		_, err = p.WithDecode(cc.Decode)
 	}
 
-	if err == nil && cc.Script != "" {
-		_, err = p.WithScript(cc.VM, cc.Script)
-	}
-
 	return p, err
 }
 
@@ -105,19 +95,6 @@ func (p *Pipeline) WithDecode(decode string) (*Pipeline, error) {
 	return p, nil
 }
 
-// WithScript adds a javascript script to process the response
-func (p *Pipeline) WithScript(vm, script string) (*Pipeline, error) {
-	regvm, err := javascript.RegisteredVM(vm, "")
-	if err != nil {
-		return nil, err
-	}
-
-	p.vm = regvm
-	p.script = script
-
-	return p, nil
-}
-
 // transform XML into JSON with attribute names getting 'attr' prefix
 func (p *Pipeline) transformXML(value []byte) []byte {
 	value = bytes.TrimSpace(value)
@@ -228,22 +205,5 @@ func (p *Pipeline) Process(in []byte) ([]byte, error) {
 		b = []byte(fmt.Sprintf("%v", v))
 	}
 
-	if p.vm != nil {
-		if err := p.vm.Set("val", string(b)); err != nil {
-			return b, err
-		}
-
-		v, err := p.vm.Eval(p.script)
-		if err != nil {
-			return b, err
-		}
-
-		s, err := v.ToString()
-		b = []byte(s)
-		if err != nil {
-			return b, err
-		}
-	}
-
 	return b, nil
 }
diff --git a/server/http_handler.go b/server/http_handler.go
index 042412f0..814b0f4c 100644
--- a/server/http_handler.go
+++ b/server/http_handler.go
@@ -8,6 +8,7 @@ import (
 	"math"
 	"net/http"
 	"strconv"
+	"strings"
 	"text/template"
 	"time"
 
@@ -16,7 +17,9 @@ import (
 	"github.com/evcc-io/evcc/core/site"
 	"github.com/evcc-io/evcc/server/assets"
 	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/jq"
 	"github.com/gorilla/mux"
+	"github.com/itchyny/gojq"
 )
 
 var ignoreState = []string{"releaseNotes"} // excessive size
@@ -164,7 +167,34 @@ func stateHandler(cache *util.Cache) http.HandlerFunc {
 		for _, k := range ignoreState {
 			delete(res, k)
 		}
+
 		encodeFloats(res)
+
+		if q := r.URL.Query().Get("jq"); q != "" {
+			q = strings.TrimPrefix(q, ".result")
+
+			query, err := gojq.Parse(q)
+			if err != nil {
+				jsonError(w, http.StatusBadRequest, err)
+				return
+			}
+
+			b, err := json.Marshal(res)
+			if err != nil {
+				jsonError(w, http.StatusBadRequest, err)
+				return
+			}
+
+			res, err := jq.Query(query, b)
+			if err != nil {
+				jsonError(w, http.StatusBadRequest, err)
+				return
+			}
+
+			jsonWrite(w, res)
+			return
+		}
+
 		jsonResult(w, res)
 	}
 }
diff --git a/server/mqtt.go b/server/mqtt.go
index 733dbcf7..ab8d21ca 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -16,9 +16,10 @@ import (
 )
 
 var deprecatedTopics = []string{
-	"activePhases", "range", "socCharge", "vehicleSoC",
-	"batterySoC", "bufferSoC", "minSoC", "prioritySoC",
-	"targetSoC", "vehicleTargetSoC",
+	"activePhases", "range", "socCharge",
+	"vehicleSoC", "batterySoC", "bufferSoC", "minSoC", "prioritySoC", "targetSoC", "vehicleTargetSoC",
+	"savingsAmount", "savingsEffectivePrice", "savingsGridCharged", "savingsSelfConsumptionCharged", "savingsSelfConsumptionPercent", "savingsTotalCharged",
+	"stats/30d", "stats/365d", "stats/total",
 }
 
 // MQTT is the MQTT server. It uses the MQTT client for publishing.
@@ -82,7 +83,7 @@ func (m *MQTT) publishComplex(topic string, retained bool, payload interface{})
 
 	case reflect.Map:
 		// loop map
-		for iter := reflect.ValueOf(m).MapRange(); iter.Next(); {
+		for iter := reflect.ValueOf(payload).MapRange(); iter.Next(); {
 			k := iter.Key().String()
 			m.publishComplex(fmt.Sprintf("%s/%s", topic, k), retained, iter.Value().Interface())
 		}
@@ -93,8 +94,10 @@ func (m *MQTT) publishComplex(topic string, retained bool, payload interface{})
 
 		// loop struct
 		for i := 0; i < typ.NumField(); i++ {
-			n := typ.Field(i).Name
-			m.publishComplex(fmt.Sprintf("%s/%s", topic, strings.ToLower(n[:1])+n[1:]), retained, val.Field(i).Interface())
+			if f := typ.Field(i); f.IsExported() {
+				n := f.Name
+				m.publishComplex(fmt.Sprintf("%s/%s", topic, strings.ToLower(n[:1])+n[1:]), retained, val.Field(i).Interface())
+			}
 		}
 
 	default:
@@ -118,6 +121,8 @@ func (m *MQTT) publish(topic string, retained bool, payload interface{}) {
 
 		// publish sum value
 		m.publishSingleValue(topic, retained, total)
+
+		return
 	}
 
 	m.publishComplex(topic, retained, payload)
diff --git a/templates/definition/charger/amperfied.yaml b/templates/definition/charger/amperfied.yaml
index ab1a7c1e..61b1121b 100644
--- a/templates/definition/charger/amperfied.yaml
+++ b/templates/definition/charger/amperfied.yaml
@@ -14,5 +14,5 @@ params:
     choice: ["tcpip"]
     id: 255
 render: |
-  type: heidelberg
+  type: amperfied
   {{- include "modbus" . }}
diff --git a/templates/definition/meter/enphase.yaml b/templates/definition/meter/enphase.yaml
index 2b94cea1..3e8bdf9b 100644
--- a/templates/definition/meter/enphase.yaml
+++ b/templates/definition/meter/enphase.yaml
@@ -23,7 +23,6 @@ render: |
   power:
     source: http
     uri: http://{{ .host }}/production.json
-    method: GET
     {{- if .token }}
     auth:
       type: bearer
@@ -36,7 +35,6 @@ render: |
   power:
     source: http
     uri: http://{{ .host }}/production.json
-    method: GET
     {{- if .token }}
     auth:
       type: bearer
@@ -47,7 +45,6 @@ render: |
   energy:
     source: http
     uri: http://{{ .host }}/production.json
-    method: GET
     {{- if .token }}
     auth:
       type: bearer
@@ -61,7 +58,6 @@ render: |
   power:
     source: http
     uri: http://{{ .host }}/production.json
-    method: GET
     {{- if .token }}
     auth:
       type: bearer
@@ -72,7 +68,6 @@ render: |
   soc:
     source: http
     uri: http://{{ .host }}/ivp/ensemble/inventory
-    method: GET
     {{- if .token }}
     auth:
       type: bearer
diff --git a/templates/definition/meter/loxone.yaml b/templates/definition/meter/loxone.yaml
new file mode 100644
index 00000000..ff9c209e
--- /dev/null
+++ b/templates/definition/meter/loxone.yaml
@@ -0,0 +1,56 @@
+template: loxone
+products:
+  - brand: Loxone
+    description:
+      generic: Miniserver
+params:
+  - name: usage
+    choice: ["grid", "pv", "battery", "charge"]
+  - name: host
+  - name: user
+  - name: password
+  - name: meterblock
+    required: true
+    description:
+      de: Zählerbaustein
+      en: Meter block
+    help:
+      de: Bezeichnung aus Loxone Config
+      en: Name from Loxone Config
+  - name: socblock
+    required: false
+    description:
+      de: Bausteinbezeichnung für Ladezustand
+      en: Function block name for state of charge
+    help:
+      de: Bezeichnung aus Loxone Config, nur für Batterie
+      en: Name from Loxone Config, only for battery
+render: |
+  type: custom
+  power:
+    source: http
+    uri: http://{{ .host }}/jdev/sps/io/{{ .meterblock }}
+    auth:
+      type: basic
+      user: {{ .user }}
+      password: {{ .password }}
+    jq: .LL.value
+    scale: 1000
+  energy:
+    source: http
+    uri: http://{{ .host }}/jdev/sps/io/{{ .meterblock }}/all
+    auth:
+      type: basic
+      user: {{ .user }}
+      password: {{ .password }}
+    jq: first(.LL[] | select(type == "object" and (.name == "Mr" or .name == "Mrc")) .value)
+  {{- if and (eq .usage "battery") .socblock }}
+  soc:
+    source: http
+    uri: http://{{ .host }}/jdev/sps/io/{{ .socblock }}
+    auth:
+      type: basic
+      user: {{ .user }}
+      password: {{ .password }}
+    jq: .LL.value
+  {{- end }}
diff --git a/templates/definition/meter/saj-h2.yaml b/templates/definition/meter/saj-h2.yaml
new file mode 100644
index 00000000..60b50cd8
--- /dev/null
+++ b/templates/definition/meter/saj-h2.yaml
@@ -0,0 +1,100 @@
+template: saj-h2
+products:
+  - brand: SAJ
+    description:
+      generic: H2 Series Hybrid Solar Inverter
+params:
+  - name: usage
+    choice: ["grid", "pv", "battery"]
+  - name: modbus
+    choice: ["rs485"]
+    baudrate: 115200
+    comset: 8N1
+  - name: capacity
+    advanced: true
+render: |
+  type: custom
+  {{- if eq .usage "grid" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 0x40A7 # TotalGridPowerWatt
+      type: holding
+      decode: int16
+  currents:
+  - source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 0x4032 # RGridCurr
+      type: holding
+      decode: int16
+    scale: 0.01
+  - source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 0x4039 # SGridCurr
+      type: holding
+      decode: int16
+    scale: 0.01
+  - source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 0x4040 # TGridCurr
+      type: holding
+      decode: int16
+    scale: 0.01
+  energy:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 0x40FD # Total_FeedInEnergy
+      type: holding
+      decode: uint32
+    scale: 0.01
+  {{- end }}
+  {{- if eq .usage "pv" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 0x40A5 # TotalPVPower
+      type: holding
+      decode: int16
+  energy:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 0x40C5 # Total_PVEnergy
+      type: holding
+      decode: uint32
+    scale: 0.01
+  {{- end }}
+  {{- if eq .usage "battery" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 0x40A6 # TotalBatteryPower
+      type: holding
+      decode: int16
+  energy:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 0x40D5 # Total_BatDisEnergy
+      type: holding
+      decode: uint32
+    scale: 0.01
+  soc:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 0xA00C # Bat1SOC
+      type: holding
+      decode: uint16
+    scale: 0.01
+  {{- if .capacity }}
+  capacity: {{ .capacity }} # kWh
+  {{- end }}
+  {{- end }}
diff --git a/templates/definition/meter/slimmelezer.yaml b/templates/definition/meter/slimmelezer.yaml
index f473addf..608e79f4 100644
--- a/templates/definition/meter/slimmelezer.yaml
+++ b/templates/definition/meter/slimmelezer.yaml
@@ -17,14 +17,12 @@ render: |
     add:
     - source: http
       uri: http://{{ .host }}/sensor/power_delivered
-      method: GET
       headers:
       - content-type: application/json
       jq: .value
       scale: 1000
     - source: http
       uri: http://{{ .host }}/sensor/power_returned
-      method: GET
       headers:
       - content-type: application/json
       jq: .value
@@ -34,32 +32,27 @@ render: |
     add:
     - source: http
       uri: http://{{ .host }}/sensor/energy_delivered_tariff1
-      method: GET
       headers:
       - content-type: application/json
       jq: .value
     - source: http
       uri: http://{{ .host }}/sensor/energy_delivered_tariff2
-      method: GET
       headers:
       - content-type: application/json
       jq: .value
   currents:
   - source: http
     uri: http://{{ .host }}/sensor/current_l1
-    method: GET
     headers:
     - content-type: application/json
     jq: .value
   - source: http
     uri: http://{{ .host }}/sensor/current_l2
-    method: GET
     headers:
     - content-type: application/json
     jq: .value
   - source: http
     uri: http://{{ .host }}/sensor/current_l3
-    method: GET
     headers:
     - content-type: application/json
     jq: .value
@@ -68,14 +61,12 @@ render: |
     add:
     - source: http
       uri: http://{{ .host }}/sensor/power_delivered_l1
-      method: GET
       headers:
       - content-type: application/json
       jq: .value
       scale: 1000
     - source: http
       uri: http://{{ .host }}/sensor/power_returned_l1
-      method: GET
       headers:
       - content-type: application/json
       jq: .value
@@ -84,14 +75,12 @@ render: |
     add:
     - source: http
       uri: http://{{ .host }}/sensor/power_delivered_l2
-      method: GET
       headers:
       - content-type: application/json
       jq: .value
       scale: 1000
     - source: http
       uri: http://{{ .host }}/sensor/power_returned_l2
-      method: GET
       headers:
       - content-type: application/json
       jq: .value
@@ -100,14 +89,12 @@ render: |
     add:
     - source: http
       uri: http://{{ .host }}/sensor/power_delivered_l3
-      method: GET
       headers:
       - content-type: application/json
       jq: .value
       scale: 1000
     - source: http
       uri: http://{{ .host }}/sensor/power_returned_l3
-      method: GET
       headers:
       - content-type: application/json
       jq: .value
diff --git a/templates/definition/meter/solarlog.yaml b/templates/definition/meter/solarlog.yaml
index 93135468..0491f54b 100644
--- a/templates/definition/meter/solarlog.yaml
+++ b/templates/definition/meter/solarlog.yaml
@@ -19,8 +19,8 @@ params:
     default: 502
 render: |
   type: custom
-  power:
   {{- if eq .usage "grid" }}
+  power:
     source: calc
     add:
     - source: modbus
@@ -40,6 +40,7 @@ render: |
         decode: uint32s
   {{- end }}
   {{- if eq .usage "pv" }}
+  power:
     source: modbus
     uri: {{ .host }}:{{ .port }}
     id: 1
@@ -47,4 +48,13 @@ render: |
       address: 3502 # Pac
       type: input
       decode: uint32s
+  energy:
+    source: modbus
+    uri: {{ .host }}:{{ .port }}
+    id: 1
+    register:
+      address: 3516 # total yield
+      type: input
+      decode: uint32s
+    scale: 0.001
   {{- end }}
diff --git a/templates/definition/meter/storaxe.yaml b/templates/definition/meter/storaxe.yaml
index a86703d7..949f0117 100644
--- a/templates/definition/meter/storaxe.yaml
+++ b/templates/definition/meter/storaxe.yaml
@@ -16,7 +16,7 @@ render: |
     {{- include "modbus" . | indent 2 }}
     register:
       address: 2 # RealPower
-      type: holding
+      type: input
       decode: int16
     scale: 100
   energy:
@@ -24,27 +24,56 @@ render: |
     {{- include "modbus" . | indent 2 }}
     register:
       address: 115 # EnergyExportedAC
-      type: holding
+      type: input
       decode: uint32
+  voltages:
+    - source: modbus
+      {{- include "modbus" . | indent 4 }}
+      register:
+        address: 7 # ACVoltageL1
+        type: input
+        decode: int16
+      scale: 0.1
+    - source: modbus
+      {{- include "modbus" . | indent 4 }}
+      register:
+        address: 8 # ACVoltageL2
+        type: input
+        decode: int16
+      scale: 0.1
+    - source: modbus
+      {{- include "modbus" . | indent 4 }}
+      register:
+        address: 9 # ACVoltageL3
+        type: input
+        decode: int16
+      scale: 0.1  
   currents:
     - source: modbus
       {{- include "modbus" . | indent 4 }}
       register:
         address: 10 # ACCurrentL1
-        type: holding
+        type: input
         decode: int16
       scale: 0.1
     - source: modbus
       {{- include "modbus" . | indent 4 }}
       register:
         address: 11 # ACCurrentL2
-        type: holding
+        type: input
         decode: int16
       scale: 0.1
     - source: modbus
       {{- include "modbus" . | indent 4 }}
       register:
         address: 12 # ACCurrentL3
-        type: holding
+        type: input
         decode: int16
       scale: 0.1
+  soc:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 126 # SXSSOC
+      type: input
+      decode: int16
diff --git a/templates/definition/meter/youless.yaml b/templates/definition/meter/youless.yaml
index de8cc30a..65f820bc 100644
--- a/templates/definition/meter/youless.yaml
+++ b/templates/definition/meter/youless.yaml
@@ -19,23 +19,19 @@ render: |
   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/definition/vehicle/evnotify.yaml b/templates/definition/vehicle/evnotify.yaml
index 331c38f1..1583078b 100644
--- a/templates/definition/vehicle/evnotify.yaml
+++ b/templates/definition/vehicle/evnotify.yaml
@@ -29,6 +29,5 @@ render: |
   soc:
     source: http
     uri: https://app.evnotify.de/soc?akey={{ urlEncode .akey }}&token={{ urlEncode .token }} # evNotify Server + AKEY
-    method: GET
     jq: .soc_display
   {{ include "vehicle-identify" . }}
diff --git a/templates/evcc.io/brands.json b/templates/evcc.io/brands.json
index 1f8bfbb2..e5773e83 100644
--- a/templates/evcc.io/brands.json
+++ b/templates/evcc.io/brands.json
@@ -109,6 +109,7 @@
   "Janitza",
   "Kostal",
   "LG",
+  "Loxone",
   "M-TEC",
   "my-PV",
   "myStrom",
@@ -119,6 +120,7 @@
   "Qcells",
   "RCT",
   "Saia-Burgess Controls",
+  "SAJ",
   "SAX",
   "Schneider Electric",
   "SENEC",
@@ -168,12 +170,14 @@
   "IGEN Tech",
   "Kostal",
   "LG",
+  "Loxone",
   "M-TEC",
   "myStrom",
   "OpenEMS",
   "Powerfox",
   "Qcells",
   "RCT",
+  "SAJ",
   "SAX",
   "SENEC",
   "Senergy",
diff --git a/tests/stats.sql b/tests/statistics.sql
similarity index 100%
rename from tests/stats.sql
rename to tests/statistics.sql
diff --git a/util/jq/jq.go b/util/jq/jq.go
index c58241a3..c8a1fd61 100644
--- a/util/jq/jq.go
+++ b/util/jq/jq.go
@@ -8,7 +8,7 @@ import (
 	"github.com/itchyny/gojq"
 )
 
-// Query executes a compiled jq query against given input. It expects a single result only.
+// Query executes a compiled jq query against given json. It expects a single result only.
 func Query(query *gojq.Query, input []byte) (interface{}, error) {
 	var j interface{}
 	if err := json.Unmarshal(input, &j); err != nil {


Test patch:
diff --git a/tests/stats.evcc.yaml b/tests/statistics.evcc.yaml
similarity index 100%
rename from tests/stats.evcc.yaml
rename to tests/statistics.evcc.yaml
diff --git a/tests/stats.spec.js b/tests/statistics.spec.js
similarity index 96%
rename from tests/stats.spec.js
rename to tests/statistics.spec.js
index 6c16beb4..9b555033 100644
--- a/tests/stats.spec.js
+++ b/tests/statistics.spec.js
@@ -2,7 +2,7 @@ const { test, expect } = require("@playwright/test");
 const { start, stop } = require("./evcc");
 
 test.beforeAll(async () => {
-  await start("stats.evcc.yaml", "stats.sql");
+  await start("statistics.evcc.yaml", "statistics.sql");
 });
 test.afterAll(async () => {
   await stop();
@@ -32,7 +32,7 @@ test.describe("footer", async () => {
   });
 });
 
-test.describe.skip("stats values", async () => {
+test.describe.skip("statistics values", async () => {
   test("last 30 days", async ({ page }) => {
     await page.goto("/");
     await page.getByTestId("savings-button").click();