Repository: evcc-io/evcc
Pull request #10658 (2023-11-05 10:10:38)

Modbus proxy: make readonly ignore writes, add deny option (BC)
Valid settings for `readonly` are:

- `true`: writes are ignored (change in behaviour- BC)
- `deny`: writes result in errror
- `false`: writes are passed through

Cleaner interface depends on https://github.com/go-yaml/yaml/issues/1001


Patch:
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/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 97e3a724..a5faaa13 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()
 
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/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/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/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)