Repository: evcc-io/evcc
Pull request #10553 (2023-10-29 11:15:13)

Add battery discharge control logic
Fix https://github.com/evcc-io/evcc/issues/10379, generic part of #10379 - does not yet contain any hardware specific implementations

Includes Definition of
- Battery Mode (`api.BatteryMode`)
- Battery Controller interface (`api.BatteryControl`) 

Site's main control loop was extended to calculate the required battery mode. (`site.UpdateBatteryMode()` in main `site.Run()`).
Battery Meters are checked for implementation of `api.BatteryControl` and if supported they will get instructed to change mode via `BatteryControl.SetBatteryMode()`.
Control logic is unit tested.

Site status is reflected in /api/state (both if feature is enabled, and if so also the current expected battery mode).

The feature is optional and disabled by default. It can be enabled by adding 

```
site:
  batteryDischargeControl: true

```
to the config.

@andig I could use some help with the signature of `site.UpdateBatteryMode()`. I wanted to use site.loadpoints directly instead of having a parameter. However, for the love it $DEITY I could not get it to work to inject the loadpoints from the unit tests. The "slice of pointers of interface" was making my head hurt already, but when combined with the mocked loadpoint.API... 🤯  I am sure there is a way to do it, but it is beyond me how to do it. I would appreciate if you could help me get this sorted out. 

andig: > Aber jetzt wo ich genau hinschaue - kurz Off Topic. Hier ist generell das Handling der Loadpoints anders als ich erwarten würde. Wenn ich es richtig sehe, wird pro Intervall immer nur 1 nächster Loadpoint aus dem Channel geholt. Bei mehreren Loadpoints werden diese also immer im Wechsel reihum abgefragt. Das "Intervall" für den einzelnen Loadpoint ist dann <anzahl loadpoints>*<intervall>. Ist das wirklich so gewünscht?

Ursprünglich gabs nur 1 LP und 1 Intervall. Damit nix schwingt brauchen wir ja den Netzbezug- und damit muss jeder LP die Chance haben seinen Regelvorgang abzuschließen. Daher ist dann eben das Intervall *zwischen* den Ladepunkten relevant. Hier 30s zu suggerieren ab 10 LP a 3s abzufragen wäre auch doof :/ Eine (andere) Idee könnte aber sein, bei Ladepunkten im steady state schneller zum nächsten weiter zu geben wenn wir das für große Installationen dynamisieren wollten.
premultiply: Das halte ich sogar für eine ziemlich gute Idee. Das verkürzte Delay könnte man immer dann einsetzen wenn es vorher seitens evcc keine Änderung der Regelgröße gab.
andig: Lasst uns das woanders diskutieren- wir müssen natürlich das Cloudgedöhns im Hinterkopf behalten :/
GrimmiMeloni: Danke euch für die Erläuterung. 

Patch:
diff --git a/api/api.go b/api/api.go
index 4d502922..2ef44502 100644
--- a/api/api.go
+++ b/api/api.go
@@ -9,7 +9,7 @@ import (
 	"time"
 )
 
-//go:generate mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff
+//go:generate mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff,BatteryController
 
 // ChargeMode is the charge operation mode. Valid values are off, now, minpv and pv
 type ChargeMode string
@@ -134,6 +134,12 @@ type CurrentGetter interface {
 	GetMaxCurrent() (float64, error)
 }
 
+// BatteryController optionally allows to control home battery (dis)charging behaviour
+type BatteryController interface {
+	SetBatteryMode(BatteryMode) error
+	GetBatteryMode() BatteryMode
+}
+
 // Charger provides current charging status and enable/disable charging
 type Charger interface {
 	ChargeState
diff --git a/api/batterymode.go b/api/batterymode.go
new file mode 100644
index 00000000..511fa5f0
--- /dev/null
+++ b/api/batterymode.go
@@ -0,0 +1,12 @@
+package api
+
+// BatteryMode is the home battery operation mode. Valid values are normal, locked and charge
+type BatteryMode int
+
+//go:generate enumer -type BatteryMode
+const (
+	BatteryUnknown BatteryMode = iota
+	BatteryNormal
+	BatteryLocked
+	BatteryCharge
+)
diff --git a/api/batterymode_enumer.go b/api/batterymode_enumer.go
new file mode 100644
index 00000000..d78db37a
--- /dev/null
+++ b/api/batterymode_enumer.go
@@ -0,0 +1,86 @@
+// Code generated by "enumer -type BatteryMode"; DO NOT EDIT.
+
+package api
+
+import (
+	"fmt"
+	"strings"
+)
+
+const _BatteryModeName = "BatteryUnknownBatteryNormalBatteryLockedBatteryCharge"
+
+var _BatteryModeIndex = [...]uint8{0, 14, 27, 40, 53}
+
+const _BatteryModeLowerName = "batteryunknownbatterynormalbatterylockedbatterycharge"
+
+func (i BatteryMode) String() string {
+	if i < 0 || i >= BatteryMode(len(_BatteryModeIndex)-1) {
+		return fmt.Sprintf("BatteryMode(%d)", i)
+	}
+	return _BatteryModeName[_BatteryModeIndex[i]:_BatteryModeIndex[i+1]]
+}
+
+// An "invalid array index" compiler error signifies that the constant values have changed.
+// Re-run the stringer command to generate them again.
+func _BatteryModeNoOp() {
+	var x [1]struct{}
+	_ = x[BatteryUnknown-(0)]
+	_ = x[BatteryNormal-(1)]
+	_ = x[BatteryLocked-(2)]
+	_ = x[BatteryCharge-(3)]
+}
+
+var _BatteryModeValues = []BatteryMode{BatteryUnknown, BatteryNormal, BatteryLocked, BatteryCharge}
+
+var _BatteryModeNameToValueMap = map[string]BatteryMode{
+	_BatteryModeName[0:14]:       BatteryUnknown,
+	_BatteryModeLowerName[0:14]:  BatteryUnknown,
+	_BatteryModeName[14:27]:      BatteryNormal,
+	_BatteryModeLowerName[14:27]: BatteryNormal,
+	_BatteryModeName[27:40]:      BatteryLocked,
+	_BatteryModeLowerName[27:40]: BatteryLocked,
+	_BatteryModeName[40:53]:      BatteryCharge,
+	_BatteryModeLowerName[40:53]: BatteryCharge,
+}
+
+var _BatteryModeNames = []string{
+	_BatteryModeName[0:14],
+	_BatteryModeName[14:27],
+	_BatteryModeName[27:40],
+	_BatteryModeName[40:53],
+}
+
+// BatteryModeString retrieves an enum value from the enum constants string name.
+// Throws an error if the param is not part of the enum.
+func BatteryModeString(s string) (BatteryMode, error) {
+	if val, ok := _BatteryModeNameToValueMap[s]; ok {
+		return val, nil
+	}
+
+	if val, ok := _BatteryModeNameToValueMap[strings.ToLower(s)]; ok {
+		return val, nil
+	}
+	return 0, fmt.Errorf("%s does not belong to BatteryMode values", s)
+}
+
+// BatteryModeValues returns all values of the enum
+func BatteryModeValues() []BatteryMode {
+	return _BatteryModeValues
+}
+
+// BatteryModeStrings returns a slice of all String values of the enum
+func BatteryModeStrings() []string {
+	strs := make([]string, len(_BatteryModeNames))
+	copy(strs, _BatteryModeNames)
+	return strs
+}
+
+// IsABatteryMode returns "true" if the value is listed in the enum definition. "false" otherwise
+func (i BatteryMode) IsABatteryMode() bool {
+	for _, v := range _BatteryModeValues {
+		if i == v {
+			return true
+		}
+	}
+	return false
+}
diff --git a/api/mock.go b/api/mock.go
index c46974f9..78bdeed3 100644
--- a/api/mock.go
+++ b/api/mock.go
@@ -1,5 +1,5 @@
 // Code generated by MockGen. DO NOT EDIT.
-// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff)
+// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,PhaseSwitcher,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery,Tariff,BatteryController)
 
 // Package api is a generated GoMock package.
 package api
@@ -541,3 +541,54 @@ func (mr *MockTariffMockRecorder) Type() *gomock.Call {
 	mr.mock.ctrl.T.Helper()
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Type", reflect.TypeOf((*MockTariff)(nil).Type))
 }
+
+// MockBatteryController is a mock of BatteryController interface.
+type MockBatteryController struct {
+	ctrl     *gomock.Controller
+	recorder *MockBatteryControllerMockRecorder
+}
+
+// MockBatteryControllerMockRecorder is the mock recorder for MockBatteryController.
+type MockBatteryControllerMockRecorder struct {
+	mock *MockBatteryController
+}
+
+// NewMockBatteryController creates a new mock instance.
+func NewMockBatteryController(ctrl *gomock.Controller) *MockBatteryController {
+	mock := &MockBatteryController{ctrl: ctrl}
+	mock.recorder = &MockBatteryControllerMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockBatteryController) EXPECT() *MockBatteryControllerMockRecorder {
+	return m.recorder
+}
+
+// GetBatteryMode mocks base method.
+func (m *MockBatteryController) GetBatteryMode() BatteryMode {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetBatteryMode")
+	ret0, _ := ret[0].(BatteryMode)
+	return ret0
+}
+
+// GetBatteryMode indicates an expected call of GetBatteryMode.
+func (mr *MockBatteryControllerMockRecorder) GetBatteryMode() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBatteryMode", reflect.TypeOf((*MockBatteryController)(nil).GetBatteryMode))
+}
+
+// SetBatteryMode mocks base method.
+func (m *MockBatteryController) SetBatteryMode(arg0 BatteryMode) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "SetBatteryMode", arg0)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// SetBatteryMode indicates an expected call of SetBatteryMode.
+func (mr *MockBatteryControllerMockRecorder) SetBatteryMode(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBatteryMode", reflect.TypeOf((*MockBatteryController)(nil).SetBatteryMode), arg0)
+}
diff --git a/api/rates.go b/api/rates.go
index 2a942a03..afe05fd3 100644
--- a/api/rates.go
+++ b/api/rates.go
@@ -2,6 +2,7 @@ package api
 
 import (
 	"errors"
+	"slices"
 	"time"
 )
 
@@ -20,6 +21,13 @@ func (r Rate) IsEmpty() bool {
 // Rates is a slice of (future) tariff rates
 type Rates []Rate
 
+// Sort rates by start time
+func (r Rates) Sort() {
+	slices.SortStableFunc(r, func(i, j Rate) int {
+		return i.Start.Compare(j.Start)
+	})
+}
+
 // Current returns the rates current rate or error
 func (r Rates) Current(now time.Time) (Rate, error) {
 	for _, rr := range r {
diff --git a/charger/ocpp.go b/charger/ocpp.go
index d54e47d3..9b599edc 100644
--- a/charger/ocpp.go
+++ b/charger/ocpp.go
@@ -1,11 +1,11 @@
 package charger
 
 import (
+	"cmp"
 	"errors"
 	"fmt"
 	"math"
 	"slices"
-	"sort"
 	"strconv"
 	"strings"
 	"time"
@@ -178,12 +178,12 @@ func NewOCPP(id string, connector int, idtag string,
 			if err == nil {
 				// log unsupported configuration keys
 				if len(resp.UnknownKey) > 0 {
-					c.log.ERROR.Printf("unsupported keys: %v", sort.StringSlice(resp.UnknownKey))
+					c.log.ERROR.Printf("unsupported keys: %v", resp.UnknownKey)
 				}
 
 				// sort configuration keys for printing
-				sort.Slice(resp.ConfigurationKey, func(i, j int) bool {
-					return resp.ConfigurationKey[i].Key < resp.ConfigurationKey[j].Key
+				slices.SortFunc(resp.ConfigurationKey, func(i, j core.ConfigurationKey) int {
+					return cmp.Compare(i.Key, j.Key)
 				})
 
 				rw := map[bool]string{false: "r/w", true: "r/o"}
diff --git a/charger/openwb.go b/charger/openwb.go
index ff257ca3..eecc569e 100644
--- a/charger/openwb.go
+++ b/charger/openwb.go
@@ -77,7 +77,7 @@ func NewOpenWB(log *util.Logger, mqttconf mqtt.Config, id int, topic string, p1p
 	// check if loadpoint configured
 	configured := boolG(fmt.Sprintf("%s/lp/%d/%s", topic, id, openwb.ConfiguredTopic))
 	if isConfigured, err := configured(); err != nil || !isConfigured {
-		return nil, fmt.Errorf("openWB loadpoint %d is not configured", id)
+		return nil, fmt.Errorf("loadpoint %d is not configured", id)
 	}
 
 	// adapt plugged/charging to status
diff --git a/charger/vestel.go b/charger/vestel.go
index 6545d034..65e8c2d2 100644
--- a/charger/vestel.go
+++ b/charger/vestel.go
@@ -97,7 +97,7 @@ func NewVestel(uri string, id uint8) (*Vestel, error) {
 	}
 
 	// get failsafe timeout from charger
-	b, err := wb.conn.ReadInputRegisters(vestelRegFailsafeTimeout, 1)
+	b, err := wb.conn.ReadHoldingRegisters(vestelRegFailsafeTimeout, 1)
 	if err != nil {
 		return nil, fmt.Errorf("failsafe timeout: %w", err)
 	}
diff --git a/cmd/root.go b/cmd/root.go
index 65d9ee18..3afe6195 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -179,7 +179,13 @@ func runRoot(cmd *cobra.Command, args []string) {
 	// setup modbus proxy
 	if err == nil {
 		for _, cfg := range conf.ModbusProxy {
-			if err = modbus.StartProxy(cfg.Port, cfg.Settings, cfg.ReadOnly); err != nil {
+			var mode modbus.ReadOnlyMode
+			mode, err = modbus.ReadOnlyModeString(cfg.ReadOnly)
+			if err != nil {
+				break
+			}
+
+			if err = modbus.StartProxy(cfg.Port, cfg.Settings, mode); err != nil {
 				break
 			}
 		}
diff --git a/cmd/setup.go b/cmd/setup.go
index 134b4344..3e218ab2 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -113,7 +113,7 @@ type goConfig struct {
 
 type proxyConfig struct {
 	Port            int
-	ReadOnly        bool
+	ReadOnly        string
 	modbus.Settings `mapstructure:",squash"`
 }
 
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 9e9f1844..c7079a7c 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -606,6 +606,13 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 		lp.publish(chargerIcon, nil)
 	}
 
+	// vehicle
+	lp.publish(vehiclePresent, false)
+	lp.publish(vehicleTitle, "")
+	lp.publish(vehicleIcon, "")
+	lp.publish(vehicleCapacity, 0.0)
+	lp.publish(vehicleOdometer, 0.0)
+
 	// assign and publish default vehicle
 	if lp.defaultVehicle != nil {
 		lp.setActiveVehicle(lp.defaultVehicle)
@@ -1561,6 +1568,9 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered, batt
 	mode := lp.GetMode()
 	lp.publish("mode", mode)
 
+	// update and publish plan without being short-circuited by modes etc.
+	plannerActive := lp.plannerActive()
+
 	// execute loading strategy
 	switch {
 	case !lp.connected():
@@ -1591,7 +1601,7 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered, batt
 		err = lp.fastCharging()
 
 	// minimum or target charging
-	case lp.minSocNotReached() || lp.plannerActive():
+	case lp.minSocNotReached() || plannerActive:
 		err = lp.fastCharging()
 		lp.resetPhaseTimer()
 		lp.elapsePVTimer() // let PV mode disable immediately afterwards
diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go
index 226f505b..f7c698c8 100644
--- a/core/loadpoint/api.go
+++ b/core/loadpoint/api.go
@@ -100,6 +100,8 @@ type API interface {
 	// charge progress
 	//
 
+	// GetPlanActive returns the active state of the planner
+	GetPlanActive() bool
 	// GetRemainingDuration is the estimated remaining charging duration
 	GetRemainingDuration() time.Duration
 	// GetRemainingEnergy is the remaining charge energy in Wh
diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go
index a2c2addb..64a1dd8c 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -205,6 +205,20 @@ func (mr *MockAPIMockRecorder) GetPlan(arg0, arg1 interface{}) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), arg0, arg1)
 }
 
+// GetPlanActive mocks base method.
+func (m *MockAPI) GetPlanActive() bool {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetPlanActive")
+	ret0, _ := ret[0].(bool)
+	return ret0
+}
+
+// GetPlanActive indicates an expected call of GetPlanActive.
+func (mr *MockAPIMockRecorder) GetPlanActive() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanActive", reflect.TypeOf((*MockAPI)(nil).GetPlanActive))
+}
+
 // GetPriority mocks base method.
 func (m *MockAPI) GetPriority() int {
 	m.ctrl.T.Helper()
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index 97e3a724..2c633d80 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -220,6 +220,9 @@ func (lp *Loadpoint) SetTargetTime(finishAt time.Time) error {
 
 	lp.Lock()
 	defer lp.Unlock()
+
+	lp.log.DEBUG.Println("set target time:", finishAt.Round(time.Second).Local())
+
 	lp.setTargetTime(finishAt)
 	lp.persistVehicleSettings()
 
@@ -374,6 +377,13 @@ func (lp *Loadpoint) GetMaxPower() float64 {
 	return Voltage * lp.GetMaxCurrent() * float64(lp.maxActivePhases())
 }
 
+// GetPlanActive returns the active state of the planner
+func (lp *Loadpoint) GetPlanActive() bool {
+	lp.Lock()
+	defer lp.Unlock()
+	return lp.planActive
+}
+
 // SetRemainingDuration sets the estimated remaining charging duration
 func (lp *Loadpoint) SetRemainingDuration(chargeRemainingDuration time.Duration) {
 	lp.Lock()
diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go
index 94002aa0..cadaa585 100644
--- a/core/loadpoint_plan.go
+++ b/core/loadpoint_plan.go
@@ -2,7 +2,6 @@ package core
 
 import (
 	"fmt"
-	"slices"
 	"time"
 
 	"github.com/evcc-io/evcc/api"
@@ -61,7 +60,7 @@ func (lp *Loadpoint) GetPlan(targetTime time.Time, maxPower float64) (time.Durat
 	plan, err := lp.planner.Plan(requiredDuration, targetTime)
 
 	// sort plan by time
-	slices.SortStableFunc(plan, planner.SortByTime)
+	plan.Sort()
 
 	return requiredDuration, plan, err
 }
@@ -72,8 +71,12 @@ func (lp *Loadpoint) plannerActive() (active bool) {
 		lp.setPlanActive(active)
 	}()
 
-	maxPower := lp.GetMaxPower()
+	var planStart time.Time
+	defer func() {
+		lp.publish(planProjectedStart, planStart)
+	}()
 
+	maxPower := lp.GetMaxPower()
 	requiredDuration, plan, err := lp.GetPlan(lp.GetTargetTime(), maxPower)
 	if err != nil {
 		lp.log.ERROR.Println("planner:", err)
@@ -85,14 +88,12 @@ func (lp *Loadpoint) plannerActive() (active bool) {
 		return false
 	}
 
-	planStart := planner.Start(plan)
-	lp.publish(planProjectedStart, planStart)
-
 	var requiredString string
 	if req := requiredDuration.Round(time.Second); req > planner.Duration(plan).Round(time.Second) {
 		requiredString = fmt.Sprintf(" (required: %v)", req)
 	}
 
+	planStart = planner.Start(plan)
 	lp.log.DEBUG.Printf("plan: charge %v%s starting at %v until %v (power: %.0fW, avg cost: %.3f)",
 		planner.Duration(plan).Round(time.Second), requiredString, planStart.Round(time.Second).Local(), lp.targetTime.Round(time.Second).Local(),
 		maxPower, planner.AverageCost(plan))
diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go
index e3a696d0..2ec6539a 100644
--- a/core/loadpoint_vehicle.go
+++ b/core/loadpoint_vehicle.go
@@ -162,7 +162,7 @@ func (lp *Loadpoint) setActiveVehicle(vehicle api.Vehicle) {
 		lp.publish(vehiclePresent, false)
 		lp.publish(vehicleTitle, "")
 		lp.publish(vehicleIcon, "")
-		lp.publish(vehicleCapacity, int64(0))
+		lp.publish(vehicleCapacity, 0.0)
 		lp.publish(vehicleOdometer, 0.0)
 	}
 
diff --git a/core/planner/helper.go b/core/planner/helper.go
index 0692f32b..ef0be143 100644
--- a/core/planner/helper.go
+++ b/core/planner/helper.go
@@ -6,6 +6,7 @@ import (
 	"github.com/evcc-io/evcc/api"
 )
 
+// Start returns the earliest slot's start time
 func Start(plan api.Rates) time.Time {
 	var start time.Time
 	for _, slot := range plan {
@@ -16,6 +17,7 @@ func Start(plan api.Rates) time.Time {
 	return start
 }
 
+// Duration returns the sum of all slot's durations
 func Duration(plan api.Rates) time.Duration {
 	var duration time.Duration
 	for _, slot := range plan {
@@ -25,6 +27,7 @@ func Duration(plan api.Rates) time.Duration {
 	return duration
 }
 
+// AverageCost returns the time-weighted average cost
 func AverageCost(plan api.Rates) float64 {
 	var cost float64
 	var duration time.Duration
diff --git a/core/planner/sort.go b/core/planner/sort.go
index fa0813bb..e0b967c2 100644
--- a/core/planner/sort.go
+++ b/core/planner/sort.go
@@ -2,11 +2,6 @@ package planner
 
 import "github.com/evcc-io/evcc/api"
 
-// SortByTime is a sortFunc for slices.Sort
-func SortByTime(i, j api.Rate) int {
-	return i.Start.Compare(j.Start)
-}
-
 // sortByCost is a sortFunc for slices.Sort
 func sortByCost(i, j api.Rate) int {
 	switch {
diff --git a/core/site.go b/core/site.go
index 78607574..f69a0dcc 100644
--- a/core/site.go
+++ b/core/site.go
@@ -67,6 +67,7 @@ type Site struct {
 	BufferStartSoc                    float64      `mapstructure:"bufferStartSoc"`                    // start charging on battery above this Soc
 	MaxGridSupplyWhileBatteryCharging float64      `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value
 	SmartCostLimit                    float64      `mapstructure:"smartCostLimit"`                    // always charge if cost is below this value
+	BatteryDischargeControl           bool         `mapstructure:"batteryDischargeControl"`           // shall discharge of home battery be adjusted
 
 	// meters
 	gridMeter     api.Meter   // Grid usage meter
@@ -81,10 +82,11 @@ type Site struct {
 	stats       *Stats                   // Stats
 
 	// cached state
-	gridPower    float64 // Grid power
-	pvPower      float64 // PV power
-	batteryPower float64 // Battery charge power
-	batterySoc   float64 // Battery soc
+	gridPower    float64         // Grid power
+	pvPower      float64         // PV power
+	batteryPower float64         // Battery charge power
+	batterySoc   float64         // Battery soc
+	batteryMode  api.BatteryMode // Battery discharge currently enabled
 
 	publishCache map[string]any // store last published values to avoid unnecessary republishing
 }
@@ -224,6 +226,7 @@ func NewSite() *Site {
 		log:          util.NewLogger("site"),
 		publishCache: make(map[string]any),
 		Voltage:      230, // V
+		batteryMode:  api.BatteryNormal,
 	}
 
 	return lp
@@ -251,6 +254,9 @@ func (site *Site) restoreSettings() {
 	if v, err := settings.Float("site.smartCostLimit"); err == nil {
 		site.SmartCostLimit = v
 	}
+	if v, err := settings.Bool("site.batteryDischargeControl"); err == nil {
+		site.BatteryDischargeControl = v
+	}
 }
 
 func meterCapabilities(name string, meter interface{}) string {
@@ -769,7 +775,6 @@ func (site *Site) update(lp Updater) {
 	}
 
 	if sitePower, batteryBuffered, batteryStart, err := site.sitePower(totalChargePower, flexiblePower); err == nil {
-
 		// ignore negative pvPower values as that means it is not an energy source but consumption
 		homePower := site.gridPower + max(0, site.pvPower) + site.batteryPower - totalChargePower
 		homePower = max(homePower, 0)
@@ -791,6 +796,10 @@ func (site *Site) update(lp Updater) {
 		site.log.ERROR.Println(err)
 	}
 
+	if site.BatteryDischargeControl {
+		site.updateBatteryMode(site.Loadpoints())
+	}
+
 	site.stats.Update(site)
 }
 
@@ -814,6 +823,8 @@ func (site *Site) prepare() {
 	site.publish("currency", site.tariffs.Currency.String())
 
 	site.publish("vehicles", vehicleTitles(site.GetVehicles()))
+	site.publish("batteryDischargeControl", site.BatteryDischargeControl)
+	site.publish("batteryMode", site.batteryMode)
 }
 
 // Prepare attaches communication channels to site and loadpoints
diff --git a/core/site_battery.go b/core/site_battery.go
new file mode 100644
index 00000000..ca46ccfe
--- /dev/null
+++ b/core/site_battery.go
@@ -0,0 +1,49 @@
+package core
+
+import (
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/core/loadpoint"
+)
+
+// getBatteryMode returns the battery mode
+func (site *Site) getBatteryMode() api.BatteryMode {
+	site.Lock()
+	defer site.Unlock()
+	return site.batteryMode
+}
+
+// setBatteryMode sets the battery mode
+func (site *Site) setBatteryMode(batMode api.BatteryMode) {
+	site.Lock()
+	defer site.Unlock()
+	site.batteryMode = batMode
+}
+
+func (site *Site) updateBatteryMode(loadpoints []loadpoint.API) {
+	// determine expected state
+	batMode := api.BatteryNormal
+	for _, lp := range loadpoints {
+		if lp.GetStatus() == api.StatusC && (lp.GetMode() == api.ModeNow || lp.GetPlanActive()) {
+			batMode = api.BatteryLocked
+			break
+		}
+	}
+
+	if batMode == site.getBatteryMode() {
+		return
+	}
+
+	// update batteries
+	for _, meter := range site.batteryMeters {
+		if batCtrl, ok := meter.(api.BatteryController); ok {
+			if err := batCtrl.SetBatteryMode(batMode); err != nil {
+				site.log.ERROR.Println("battery mode:", err)
+				return
+			}
+		}
+	}
+
+	// update state and publish
+	site.setBatteryMode(batMode)
+	site.publish("batteryMode", batMode)
+}
diff --git a/evcc.dist.yaml b/evcc.dist.yaml
index 5ed71c87..6ecbef10 100644
--- a/evcc.dist.yaml
+++ b/evcc.dist.yaml
@@ -49,7 +49,7 @@ modbusproxy:
   #  - port: 5200
   #    uri: solar-edge:502
   #    # rtu: true
-  #    # readonly: true
+  #    # readonly: true # use `deny` to raise modbus errors
 
 # meter definitions
 # name can be freely chosen and is used as reference when assigning meters to site and loadpoints
diff --git a/go.mod b/go.mod
index 7920c631..95af0e54 100644
--- a/go.mod
+++ b/go.mod
@@ -89,7 +89,7 @@ require (
 	github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c
 	github.com/volkszaehler/mbmd v0.0.0-20231001155117-da2566b6849c
 	github.com/writeas/go-strip-markdown/v2 v2.1.1
-	gitlab.com/bboehmke/sunny v0.15.1-0.20211022160056-2fba1c86ade6
+	gitlab.com/bboehmke/sunny v0.16.0
 	golang.org/x/crypto/x509roots/fallback v0.0.0-20231030152948-74c2ba9521f1
 	golang.org/x/exp v0.0.0-20231006140011-7918f672742d
 	golang.org/x/net v0.17.0
diff --git a/go.sum b/go.sum
index bae8a34f..442fb6a2 100644
--- a/go.sum
+++ b/go.sum
@@ -780,8 +780,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-gitlab.com/bboehmke/sunny v0.15.1-0.20211022160056-2fba1c86ade6 h1:73pM5aQqFkQfmXyGf3+xeWWg98J03xtDIkA5TE37ERU=
-gitlab.com/bboehmke/sunny v0.15.1-0.20211022160056-2fba1c86ade6/go.mod h1:F5AIuL7kYteSJFR5E+YEocxIdpyCXmtDciFmMQVjP88=
+gitlab.com/bboehmke/sunny v0.16.0 h1:arcU5MNupJ9KELOcSC82mYPGP/friBop+PxtybhqRwo=
+gitlab.com/bboehmke/sunny v0.16.0/go.mod h1:F5AIuL7kYteSJFR5E+YEocxIdpyCXmtDciFmMQVjP88=
 gitlab.com/c0b/go-ordered-json v0.0.0-20201030195603-febf46534d5a h1:DxppxFKRqJ8WD6oJ3+ZXKDY0iMONQDl5UTg2aTyHh8k=
 gitlab.com/c0b/go-ordered-json v0.0.0-20201030195603-febf46534d5a/go.mod h1:NREvu3a57BaK0R1+ztrEzHWiZAihohNLQ6trPxlIqZI=
 go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
diff --git a/i18n/da.toml b/i18n/da.toml
index 43a113b0..aeb27f52 100644
--- a/i18n/da.toml
+++ b/i18n/da.toml
@@ -3,17 +3,17 @@ batteryLevel = "Batteriniveau"
 capacity = "{energy} af {total}"
 legendBottomName = "Prioritér hjemmet"
 legendBottomSubline = "Anvendes ikke til opladning"
-legendMiddleName = "Bil først"
+legendMiddleName = "Køretøj først"
 legendMiddleSubline = "Hjem efterfølgende"
 legendTitle = "Hvordan skal solenergi benyttes?"
 legendTopAutostart = "starter automatisk"
 legendTopName = "batterie understøttet opladning"
 legendTopSubline = "uden afbrydelser"
-modalTitle = "batterie indstillinger"
+modalTitle = "batteri indstillinger"
 note = "OBS: Disse indstillinger gælder kun for solar modus. Indstillingerne ændres tilsvarende."
 
 [batterySettings.bufferStart]
-full = "næsten fuld"
+full = "næsten fyldt"
 low = "en smule fyldt"
 medium = "halv fyldt"
 never = "kun med nok solar overskud"
@@ -25,7 +25,7 @@ example = "Eksempel"
 optional = "valgfrit"
 
 [config.main]
-addVehicle = "Tilføj bil"
+addVehicle = "Tilføj køretøj"
 edit = "rediger"
 title = "Opsætning"
 vehicles = "Mine biler"
@@ -40,7 +40,7 @@ validate = "validerer"
 
 [config.vehicle]
 cancel = "Annuller"
-delete = "Slet bilen"
+delete = "Slet køretøjet"
 generic = "Andre integrationer"
 offline = "Generisk bil"
 online = "Biler med online API"
@@ -62,25 +62,37 @@ greenShareSub1 = "strøm leveret af"
 greenShareSub2 = "sol, og batteriopbevaring"
 power = "Opladningsstrøm"
 powerSub1 = "{activeClients} af {totalClients} deltagere"
-powerSub2 = "opladning ..."
+powerSub2 = "oplader ..."
 tabTitle = "Live fællesskab"
 
 [footer.savings]
+co2Saved = "{value} gemt"
+co2Title = "CO₂ Udledning"
+configurePriceCo2 = "Lær hvordan man indstiller pris og CO₂ data."
 footerLong = "{percent}% Solenergi"
 footerShort = "{percent}% Solenergi"
 modalTitle = "Oversigt over ladeenergi"
+moneySaved = "{value} gemt"
 percentGrid = "{grid} kWh Net"
 percentSelf = "{self} kWh solenergi"
 percentTitle = "Solenergi"
+periodLabel = "Periode:"
 priceFeedIn = "{feedInPrice} net tarif"
 priceGrid = "{gridPrice} Elnet"
 priceTitle = "Energipris"
+referenceGrid = "el-nettet"
+referenceLabel = "Referencedata:"
 savingsComparedToGrid = "Sammenlignet med nettet"
 savingsTitle = "Opsparing"
 savingsTotalEnergy = "{total} kWh opladet"
 since = "siden {siden}"
 tabTitle = "Mine data"
 
+[footer.savings.period]
+30d = "seneste 30 dage"
+365d = "seneste 365 dage"
+total = "hele tiden"
+
 [footer.sponsor]
 becomeSponsor = "Bliv sponsor"
 confetti = "Klar til konfetti?"
@@ -243,6 +255,8 @@ title = "Planlagt tid"
 today = "i dag"
 tomorrow = "i morgen"
 update = "Opdatering"
+vehicleCapacityDocs = "Lær at indstille det."
+vehicleCapacityRequired = "Batteries kapaciteten i køretøjet skal kendes for at estimere opladningstiden."
 
 [main.targetChargePlan]
 chargeDuration = "Opladningstid"
@@ -277,8 +291,8 @@ vehicleTarget = "Køretøjsgrænse: {soc}%"
 
 [main.vehicleStatus]
 charging = "Opladning..."
-cheapEnergyCharging = "Oplader med billig energi: {price} (limit < {limit})"
-cleanEnergyCharging = "Oplader med CO2 neutral energi: {co2} (limit < {limit})"
+cheapEnergyCharging = "Oplader med billig energi: {price} (limit {limit})"
+cleanEnergyCharging = "Oplader med CO2 neutral energi: {co2} (limit {limit})"
 climating = "Forkonditionering registreret."
 connected = "Forbundet."
 disconnected = "Afbrudt."
diff --git a/server/modbus/handler.go b/server/modbus/handler.go
index 035ef9fa..caae01aa 100644
--- a/server/modbus/handler.go
+++ b/server/modbus/handler.go
@@ -13,7 +13,7 @@ import (
 
 type handler struct {
 	log      *util.Logger
-	readOnly bool
+	readOnly ReadOnlyMode
 	conn     *modbus.Connection
 }
 
@@ -104,8 +104,13 @@ func (h *handler) HandleDiscreteInputs(req *mbserver.DiscreteInputsRequest) ([]b
 
 func (h *handler) HandleCoils(req *mbserver.CoilsRequest) ([]bool, error) {
 	if req.IsWrite {
-		if h.readOnly {
+		switch h.readOnly {
+		case ReadOnlyDeny:
+			h.log.TRACE.Printf("deny: write coils: id %d addr %d qty %d val %v", req.UnitId, req.Addr, req.Quantity, req.Args)
 			return nil, mbserver.ErrIllegalFunction
+		case ReadOnlyTrue:
+			h.log.TRACE.Printf("ignore: write coils: id %d addr %d qty %d val %v", req.UnitId, req.Addr, req.Quantity, req.Args)
+			return req.Args, nil
 		}
 
 		if req.WriteFuncCode == gridx.FuncCodeWriteSingleCoil {
@@ -138,8 +143,13 @@ func (h *handler) HandleInputRegisters(req *mbserver.InputRegistersRequest) ([]u
 
 func (h *handler) HandleHoldingRegisters(req *mbserver.HoldingRegistersRequest) ([]uint16, error) {
 	if req.IsWrite {
-		if h.readOnly {
+		switch h.readOnly {
+		case ReadOnlyDeny:
+			h.log.TRACE.Printf("deny: write holdings: id %d addr %d qty %d val %0x", req.UnitId, req.Addr, req.Quantity, asBytes(req.Args))
 			return nil, mbserver.ErrIllegalFunction
+		case ReadOnlyTrue:
+			h.log.TRACE.Printf("ignore: write holdings: id %d addr %d qty %d val %0x", req.UnitId, req.Addr, req.Quantity, asBytes(req.Args))
+			return req.Args, nil
 		}
 
 		if req.WriteFuncCode == gridx.FuncCodeWriteSingleRegister {
diff --git a/server/modbus/proxy.go b/server/modbus/proxy.go
index dc964bad..096b6d7a 100644
--- a/server/modbus/proxy.go
+++ b/server/modbus/proxy.go
@@ -11,7 +11,7 @@ import (
 	"github.com/evcc-io/evcc/util/sponsor"
 )
 
-func StartProxy(port int, config modbus.Settings, readOnly bool) error {
+func StartProxy(port int, config modbus.Settings, readOnly ReadOnlyMode) error {
 	conn, err := modbus.NewConnection(config.URI, config.Device, config.Comset, config.Baudrate, modbus.ProtocolFromRTU(config.RTU), config.ID)
 	if err != nil {
 		return err
diff --git a/server/modbus/readonlymode.go b/server/modbus/readonlymode.go
new file mode 100644
index 00000000..c7b5d732
--- /dev/null
+++ b/server/modbus/readonlymode.go
@@ -0,0 +1,11 @@
+package modbus
+
+// go:generate enumer -type ReadOnlyMode -trimprefix ReadOnly -transform=lower
+
+type ReadOnlyMode int
+
+const (
+	ReadOnlyFalse ReadOnlyMode = iota
+	ReadOnlyDeny
+	ReadOnlyTrue
+)
diff --git a/server/modbus/readonlymode_enumer.go b/server/modbus/readonlymode_enumer.go
new file mode 100644
index 00000000..f19ea5ed
--- /dev/null
+++ b/server/modbus/readonlymode_enumer.go
@@ -0,0 +1,90 @@
+// Code generated by "enumer -type ReadOnlyMode -trimprefix ReadOnly -transform=lower"; DO NOT EDIT.
+
+package modbus
+
+import (
+	"fmt"
+	"strings"
+)
+
+const _ReadOnlyModeName = "falsedenytrue"
+
+var _ReadOnlyModeIndex = [...]uint8{0, 5, 9, 13}
+
+const _ReadOnlyModeLowerName = "falsedenytrue"
+
+func (i ReadOnlyMode) String() string {
+	if i < 0 || i >= ReadOnlyMode(len(_ReadOnlyModeIndex)-1) {
+		return fmt.Sprintf("ReadOnlyMode(%d)", i)
+	}
+	return _ReadOnlyModeName[_ReadOnlyModeIndex[i]:_ReadOnlyModeIndex[i+1]]
+}
+
+// An "invalid array index" compiler error signifies that the constant values have changed.
+// Re-run the stringer command to generate them again.
+func _ReadOnlyModeNoOp() {
+	var x [1]struct{}
+	_ = x[ReadOnlyFalse-(0)]
+	_ = x[ReadOnlyDeny-(1)]
+	_ = x[ReadOnlyTrue-(2)]
+}
+
+var _ReadOnlyModeValues = []ReadOnlyMode{ReadOnlyFalse, ReadOnlyDeny, ReadOnlyTrue}
+
+var _ReadOnlyModeNameToValueMap = map[string]ReadOnlyMode{
+	_ReadOnlyModeName[0:5]:       ReadOnlyFalse,
+	_ReadOnlyModeLowerName[0:5]:  ReadOnlyFalse,
+	_ReadOnlyModeName[5:9]:       ReadOnlyDeny,
+	_ReadOnlyModeLowerName[5:9]:  ReadOnlyDeny,
+	_ReadOnlyModeName[9:13]:      ReadOnlyTrue,
+	_ReadOnlyModeLowerName[9:13]: ReadOnlyTrue,
+}
+
+var _ReadOnlyModeNames = []string{
+	_ReadOnlyModeName[0:5],
+	_ReadOnlyModeName[5:9],
+	_ReadOnlyModeName[9:13],
+}
+
+// ReadOnlyModeString retrieves an enum value from the enum constants string name.
+// Throws an error if the param is not part of the enum.
+func ReadOnlyModeString(s string) (ReadOnlyMode, error) {
+	if val, ok := _ReadOnlyModeNameToValueMap[s]; ok {
+		return val, nil
+	}
+
+	if val, ok := _ReadOnlyModeNameToValueMap[strings.ToLower(s)]; ok {
+		return val, nil
+	}
+
+	switch s {
+	case "", "0":
+		return ReadOnlyFalse, nil
+	case "1":
+		return ReadOnlyTrue, nil
+	}
+
+	return 0, fmt.Errorf("%s does not belong to ReadOnlyMode values", s)
+}
+
+// ReadOnlyModeValues returns all values of the enum
+func ReadOnlyModeValues() []ReadOnlyMode {
+	return _ReadOnlyModeValues
+}
+
+// ReadOnlyModeStrings returns a slice of all String values of the enum
+func ReadOnlyModeStrings() []string {
+	strs := make([]string, len(_ReadOnlyModeNames))
+	copy(strs, _ReadOnlyModeNames)
+	return strs
+}
+
+// IsAReadOnlyMode returns "true" if the value is listed in the enum definition. "false" otherwise
+func (i ReadOnlyMode) IsAReadOnlyMode() bool {
+	for _, v := range _ReadOnlyModeValues {
+		if i == v {
+			return true
+		}
+	}
+	return false
+}
diff --git a/tariff/awattar.go b/tariff/awattar.go
index 5b94f43c..83f35954 100644
--- a/tariff/awattar.go
+++ b/tariff/awattar.go
@@ -85,6 +85,7 @@ func (t *Awattar) run(done chan error) {
 			}
 			t.data = append(t.data, ar)
 		}
+		t.data.Sort()
 
 		t.mux.Unlock()
 	}
diff --git a/tariff/electricitymaps.go b/tariff/electricitymaps.go
index 95ff072b..b45a52f2 100644
--- a/tariff/electricitymaps.go
+++ b/tariff/electricitymaps.go
@@ -3,6 +3,7 @@ package tariff
 import (
 	"errors"
 	"fmt"
+	"slices"
 	"strings"
 	"sync"
 	"time"
@@ -20,7 +21,7 @@ type ElectricityMaps struct {
 	mux     sync.Mutex
 	uri     string
 	zone    string
-	data    []CarbonIntensitySlot
+	data    api.Rates
 	updated time.Time
 }
 
@@ -102,7 +103,18 @@ func (t *ElectricityMaps) run(done chan error) {
 
 		t.mux.Lock()
 		t.updated = time.Now()
-		t.data = res.Forecast
+
+		t.data = make(api.Rates, 0, len(res.Forecast))
+		for _, r := range res.Forecast {
+			ar := api.Rate{
+				Start: r.Datetime.Local(),
+				End:   r.Datetime.Add(time.Hour).Local(),
+				Price: r.CarbonIntensity,
+			}
+			t.data = append(t.data, ar)
+		}
+		t.data.Sort()
+
 		t.mux.Unlock()
 	}
 }
@@ -110,18 +122,7 @@ func (t *ElectricityMaps) run(done chan error) {
 func (t *ElectricityMaps) Rates() (api.Rates, error) {
 	t.mux.Lock()
 	defer t.mux.Unlock()
-
-	res := make(api.Rates, 0, len(t.data))
-	for _, r := range t.data {
-		ar := api.Rate{
-			Start: r.Datetime.Local(),
-			End:   r.Datetime.Add(time.Hour).Local(),
-			Price: r.CarbonIntensity,
-		}
-		res = append(res, ar)
-	}
-
-	return res, outdatedError(t.updated, time.Hour)
+	return slices.Clone(t.data), outdatedError(t.updated, time.Hour)
 }
 
 // Type implements the api.Tariff interface
diff --git a/tariff/elering.go b/tariff/elering.go
index 3dd1a766..262e0825 100644
--- a/tariff/elering.go
+++ b/tariff/elering.go
@@ -98,6 +98,7 @@ func (t *Elering) run(done chan error) {
 			}
 			t.data = append(t.data, ar)
 		}
+		t.data.Sort()
 
 		t.mux.Unlock()
 	}
diff --git a/tariff/energinet.go b/tariff/energinet.go
index c5ec71c1..886db485 100644
--- a/tariff/energinet.go
+++ b/tariff/energinet.go
@@ -95,6 +95,7 @@ func (t *Energinet) run(done chan error) {
 			}
 			t.data = append(t.data, ar)
 		}
+		t.data.Sort()
 
 		t.mux.Unlock()
 	}
diff --git a/tariff/entsoe.go b/tariff/entsoe.go
index ba144042..38c0239f 100644
--- a/tariff/entsoe.go
+++ b/tariff/entsoe.go
@@ -169,6 +169,7 @@ func (t *Entsoe) run(done chan error) {
 			}
 			t.data = append(t.data, ar)
 		}
+		t.data.Sort()
 
 		t.mux.Unlock()
 	}
diff --git a/tariff/gruenstromindex.go b/tariff/gruenstromindex.go
index 5615a08d..5a64eb58 100644
--- a/tariff/gruenstromindex.go
+++ b/tariff/gruenstromindex.go
@@ -128,6 +128,7 @@ func (t *GrünStromIndex) run(done chan error) {
 				End:   time.UnixMilli(r.Timeframe.End).Local(),
 			})
 		}
+		t.data.Sort()
 
 		t.mux.Unlock()
 	}
diff --git a/tariff/ngeso.go b/tariff/ngeso.go
index 5db8de87..19c58018 100644
--- a/tariff/ngeso.go
+++ b/tariff/ngeso.go
@@ -3,6 +3,7 @@ package tariff
 import (
 	"errors"
 	"net/http"
+	"slices"
 	"sync"
 	"time"
 
@@ -11,7 +12,6 @@ import (
 	"github.com/evcc-io/evcc/tariff/ngeso"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
-	"slices"
 )
 
 type Ngeso struct {
@@ -110,6 +110,7 @@ func (t *Ngeso) run(done chan error) {
 			}
 			t.data = append(t.data, ar)
 		}
+		t.data.Sort()
 
 		t.mux.Unlock()
 	}
diff --git a/tariff/octopus.go b/tariff/octopus.go
index 33ff2278..0dbb61ca 100644
--- a/tariff/octopus.go
+++ b/tariff/octopus.go
@@ -90,6 +90,7 @@ func (t *Octopus) run(done chan error) {
 			}
 			t.data = append(t.data, ar)
 		}
+		t.data.Sort()
 
 		t.mux.Unlock()
 	}
diff --git a/tariff/tibber.go b/tariff/tibber.go
index b1978b0e..f2866cb5 100644
--- a/tariff/tibber.go
+++ b/tariff/tibber.go
@@ -109,6 +109,7 @@ func (t *Tibber) run(done chan error) {
 		pi := res.Viewer.Home.CurrentSubscription.PriceInfo
 		t.data = make(api.Rates, 0, len(pi.Today)+len(pi.Tomorrow))
 		t.data = append(t.rates(pi.Today), t.rates(pi.Tomorrow)...)
+		t.data.Sort()
 
 		t.mux.Unlock()
 	}
diff --git a/templates/definition/meter/saj-h2.yaml b/templates/definition/meter/saj-h2.yaml
index 60b50cd8..6270c390 100644
--- a/templates/definition/meter/saj-h2.yaml
+++ b/templates/definition/meter/saj-h2.yaml
@@ -7,7 +7,7 @@ params:
   - name: usage
     choice: ["grid", "pv", "battery"]
   - name: modbus
-    choice: ["rs485"]
+    choice: ["rs485", "tcpip"]
     baudrate: 115200
     comset: 8N1
   - name: capacity
diff --git a/vehicle/porsche/identity.go b/vehicle/porsche/identity.go
index 8b037dec..73c276fb 100644
--- a/vehicle/porsche/identity.go
+++ b/vehicle/porsche/identity.go
@@ -76,6 +76,10 @@ func (v *Identity) login() (*oauth2.Token, error) {
 	}
 	defer resp.Body.Close()
 
+	if resp.StatusCode != http.StatusFound {
+		return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
+	}
+
 	// username
 	u, err := url.Parse(resp.Header.Get("Location"))
 	if err != nil {
diff --git a/vehicle/vw/provider.go b/vehicle/vw/provider.go
index 9aa7af6f..853b7f84 100644
--- a/vehicle/vw/provider.go
+++ b/vehicle/vw/provider.go
@@ -1,9 +1,10 @@
 package vw
 
 import (
+	"cmp"
 	"fmt"
 	"os"
-	"sort"
+	"slices"
 	"strconv"
 	"strings"
 	"text/tabwriter"
@@ -186,8 +187,8 @@ func (v *Provider) Diagnose() {
 
 	tw := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0)
 
-	sort.Slice(rr.OperationList.ServiceInfo, func(i, j int) bool {
-		return rr.OperationList.ServiceInfo[i].ServiceId < rr.OperationList.ServiceInfo[j].ServiceId
+	slices.SortFunc(rr.OperationList.ServiceInfo, func(i, j ServiceInfo) int {
+		return cmp.Compare(i.ServiceId, j.ServiceId)
 	})
 
 	for _, si := range rr.OperationList.ServiceInfo {


Test patch:
diff --git a/charger/template_test.go b/charger/template_test.go
index 6ef45085..d33ff9ef 100644
--- a/charger/template_test.go
+++ b/charger/template_test.go
@@ -17,8 +17,10 @@ var acceptable = []string{
 	"hciconfig provided no response",
 	"connect: no route to host",
 	"connect: connection refused",
+	"connector already registered: 1", // ocpp
 	"error connecting: Network Error",
 	"i/o timeout",
+	"loadpoint 1 is not configured", // openWB
 	"recv timeout",
 	"(Client.Timeout exceeded while awaiting headers)",
 	"can only have either uri or device", // modbus
diff --git a/core/planner/sort_test.go b/core/planner/sort_test.go
index 6059dd6a..bac3dd48 100644
--- a/core/planner/sort_test.go
+++ b/core/planner/sort_test.go
@@ -32,7 +32,7 @@ func TestRatesSortByTime(t *testing.T) {
 
 	r := testRates(clock)
 
-	slices.SortStableFunc(r, SortByTime)
+	r.Sort()
 	assert.Equal(t, clock.Now(), r[0].Start)
 	assert.Equal(t, clock.Now().Add(time.Hour), r[1].Start) // late slots first
 	assert.Equal(t, clock.Now().Add(2*time.Hour), r[2].Start)
diff --git a/core/site_battery_test.go b/core/site_battery_test.go
new file mode 100644
index 00000000..1c356fe6
--- /dev/null
+++ b/core/site_battery_test.go
@@ -0,0 +1,96 @@
+package core
+
+import (
+	"testing"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/core/loadpoint"
+	"github.com/evcc-io/evcc/util"
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestBatteryDischarge(t *testing.T) {
+	ctrl := gomock.NewController(t)
+
+	tcs := []struct {
+		chargeStatus api.ChargeStatus
+		planActive   bool
+		expBatMode   api.BatteryMode
+		mode         api.ChargeMode
+	}{
+		{api.StatusB, false, api.BatteryNormal, api.ModeOff},   // mode off -> bat enabled
+		{api.StatusB, false, api.BatteryNormal, api.ModeNow},   // mode now, not charging -> bat enabled
+		{api.StatusC, false, api.BatteryLocked, api.ModeNow},   // mode now, charging -> bat disabled
+		{api.StatusB, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, not charging -> bat enabled
+		{api.StatusC, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, charging -> bat enabled
+		{api.StatusB, false, api.BatteryNormal, api.ModePV},    // mode PV, not charging -> bat enabled
+		{api.StatusC, false, api.BatteryNormal, api.ModePV},    // mode PV, charging, no planner -> bat enabled
+		{api.StatusC, true, api.BatteryLocked, api.ModePV},     // mode PV, charging, planner active -> bat disabled
+	}
+
+	log := util.NewLogger("foo")
+
+	for _, tc := range tcs {
+		batCtrl := struct {
+			*api.MockBatteryController
+			*api.MockMeter
+		}{
+			api.NewMockBatteryController(ctrl),
+			api.NewMockMeter(ctrl),
+		}
+		batCtrl.MockBatteryController.EXPECT().SetBatteryMode(tc.expBatMode).Times(1)
+
+		s := &Site{
+			log:                     log,
+			BatteryDischargeControl: true,
+			batteryMeters:           []api.Meter{batCtrl},
+		}
+
+		lp := loadpoint.NewMockAPI(ctrl)
+		lp.EXPECT().GetStatus().Return(tc.chargeStatus).AnyTimes()
+		lp.EXPECT().GetMode().Return(tc.mode).AnyTimes()
+		lp.EXPECT().GetPlanActive().Return(tc.planActive).AnyTimes()
+
+		loadpoints := []loadpoint.API{lp}
+		s.updateBatteryMode(loadpoints)
+		assert.Equal(t, tc.expBatMode, s.getBatteryMode(), tc)
+	}
+}
+
+// test that BatteryControllers are only called if batterymode changes
+func TestBatteryModeNoUpdate(t *testing.T) {
+	ctrl := gomock.NewController(t)
+
+	batCtrl := struct {
+		*api.MockBatteryController
+		*api.MockMeter
+	}{
+		api.NewMockBatteryController(ctrl),
+		api.NewMockMeter(ctrl),
+	}
+	batCtrl.MockBatteryController.EXPECT().SetBatteryMode(api.BatteryLocked).Times(1)
+
+	lp := loadpoint.NewMockAPI(ctrl)
+	lp.EXPECT().GetStatus().Return(api.StatusC).Times(2)
+	lp.EXPECT().GetMode().Return(api.ModeNow).Times(2)
+	lp.EXPECT().GetPlanActive().Times(0)
+	loadpoints := []loadpoint.API{lp}
+
+	s := &Site{
+		batteryMode:             api.BatteryNormal,
+		batteryMeters:           []api.Meter{batCtrl},
+		BatteryDischargeControl: true,
+		log:                     util.NewLogger("foo"),
+	}
+
+	s.updateBatteryMode(loadpoints) // first call should call BatteryController
+	s.updateBatteryMode(loadpoints) // this one should not
+
+	// adjust mocks to simulate charge stop, should cause batMode udpate
+	lp.EXPECT().GetStatus().Return(api.StatusB).Times(1)
+	lp.EXPECT().GetMode().Return(api.ModeNow).Times(0)
+	batCtrl.MockBatteryController.EXPECT().SetBatteryMode(api.BatteryNormal).Times(1)
+
+	s.updateBatteryMode(loadpoints) // this one should have updated again
+}
diff --git a/util/templates/render_testing.go b/util/templates/render_testing.go
index af96b7ee..22baff0d 100644
--- a/util/templates/render_testing.go
+++ b/util/templates/render_testing.go
@@ -35,51 +35,55 @@ func TestClass(t *testing.T, class Class, instantiate func(t *testing.T, values
 	for _, tmpl := range ByClass(class) {
 		tmpl := tmpl
 
-		t.Run(tmpl.Template, func(t *testing.T) {
-			// set default values for all params
-			values := tmpl.Defaults(TemplateRenderModeUnitTest)
+		// set default values for all params
+		values := tmpl.Defaults(TemplateRenderModeUnitTest)
 
-			// set the template value which is needed for rendering
-			values["template"] = tmpl.Template
-
-			// set modbus default test values
-			if values[ParamModbus] != nil {
-				modbusChoices := tmpl.ModbusChoices()
-				// we only test one modbus setup
-				if slices.Contains(modbusChoices, ModbusChoiceTCPIP) {
-					values[ModbusKeyTCPIP] = true
-				} else {
-					values[ModbusKeyRS485TCPIP] = true
-				}
-				tmpl.ModbusValues(TemplateRenderModeUnitTest, values)
+		// set modbus default test values
+		if values[ParamModbus] != nil {
+			modbusChoices := tmpl.ModbusChoices()
+			// we only test one modbus setup
+			if slices.Contains(modbusChoices, ModbusChoiceTCPIP) {
+				values[ModbusKeyTCPIP] = true
+			} else {
+				values[ModbusKeyRS485TCPIP] = true
 			}
+			tmpl.ModbusValues(TemplateRenderModeUnitTest, values)
+		}
+
+		// set the template value which is needed for rendering
+		values["template"] = tmpl.Template
+		// https://github.com/evcc-io/evcc/pull/10272 - override example IP (192.0.2.2)
+		values["host"] = "localhost"
+
+		usages := tmpl.Usages()
+		if len(usages) == 0 {
+			t.Run(tmpl.Template, func(t *testing.T) {
+				t.Parallel()
 
-			usages := tmpl.Usages()
-			if len(usages) == 0 {
 				test(t, tmpl, values, func(values map[string]interface{}) {
 					instantiate(t, values)
 				})
+			})
 
-				return
-			}
+			return
+		}
 
-			for _, u := range usages {
-				// create a copy of the map for parallel execution
-				usageValues := make(map[string]interface{}, len(values)+1)
-				if err := copier.Copy(&usageValues, values); err != nil {
-					panic(err)
-				}
-				usageValues[ParamUsage] = u
+		for _, u := range usages {
+			// create a copy of the map for parallel execution
+			usageValues := make(map[string]interface{}, len(values)+1)
+			if err := copier.Copy(&usageValues, values); err != nil {
+				panic(err)
+			}
+			usageValues[ParamUsage] = u
 
-				// subtest for each usage
-				t.Run(u, func(t *testing.T) {
-					t.Parallel()
+			// subtest for each usage
+			t.Run(u, func(t *testing.T) {
+				t.Parallel()
 
-					test(t, tmpl, usageValues, func(values map[string]interface{}) {
-						instantiate(t, values)
-					})
+				test(t, tmpl, usageValues, func(values map[string]interface{}) {
+					instantiate(t, values)
 				})
-			}
-		})
+			})
+		}
 	}
 }