@@ -93,6 +115,7 @@
diff --git a/cmd/root.go b/cmd/root.go
index dfeda1da78..570654fc4d 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -469,6 +469,7 @@ func runRoot(cmd *cobra.Command, args []string) {
valueChan <- util.Param{Key: keys.Timezone, Val: time.Now().Format("MST -07:00")}
valueChan <- util.Param{Key: keys.Experimental, Val: isExperimental()}
valueChan <- util.Param{Key: keys.Optimizer, Val: isOptimizer()}
+ valueChan <- util.Param{Key: keys.OptimizerAutomatic, Val: core.OptimizerAutomatic()}
valueChan <- util.Param{Key: keys.Mcp, Val: isMcp()}
// run shutdown functions on stop
diff --git a/core/keys/global.go b/core/keys/global.go
index 103ce6c856..c47b25072e 100644
--- a/core/keys/global.go
+++ b/core/keys/global.go
@@ -32,6 +32,7 @@ const (
Plant = "plant"
Telemetry = "telemetry"
Optimizer = "optimizer"
+ OptimizerAutomatic = "optimizerAutomatic" // optimizer automatic level: off, battery, full
Mcp = "mcp"
DemoMode = "demoMode"
Remote = "remote"
diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go
index aa5a85c460..ef8d1c0b5f 100644
--- a/core/keys/loadpoint.go
+++ b/core/keys/loadpoint.go
@@ -51,6 +51,8 @@ const (
Suggestion = "suggestion" // optimizer's advisory suggestion for the current slot
// smart charging
+ OptimizerControlled = "optimizerControlled" // optimizer decides start/stop
+
SmartCostActive = "smartCostActive" // smart cost active
SmartCostLimit = "smartCostLimit" // smart cost limit, fast charge when costs are below
SmartCostNextStart = "smartCostNextStart" // smart cost next start, time of next fast charging
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 7cb3eb879c..1e14a1c8a7 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -23,6 +23,7 @@ import (
"github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/core/soc"
+ "github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/core/wrapper"
"github.com/evcc-io/evcc/messenger"
"github.com/evcc-io/evcc/util"
@@ -165,6 +166,11 @@ type Loadpoint struct {
planOverrunSent bool // notification has been sent already
planLocked PlanLock // locked plan
+ // optimizer
+ suggestion *types.Suggestion // optimizer suggestion for the current slot
+ suggestionUpdated time.Time // time the suggestion was received
+ optimizerPlan optimizerPlan // optimizer charging schedule
+
// cached state
status api.ChargeStatus // Charger status
chargePower float64 // Charging power
@@ -405,13 +411,13 @@ func (lp *Loadpoint) restoreSettings() {
lp.setLimitEnergy(v)
}
if v, err := lp.settings.Float(keys.SmartCostLimit); err == nil {
- lp.SetSmartCostLimit(&v)
+ lp.setSmartCostLimit(&v)
}
if v, err := lp.settings.Float(keys.SolarShare); err == nil {
lp.SetSolarShare(v)
}
if v, err := lp.settings.Float(keys.SmartFeedInPriorityLimit); err == nil {
- lp.SetSmartFeedInPriorityLimit(&v)
+ lp.setSmartFeedInPriorityLimit(&v)
}
if v, err := lp.settings.Int(keys.BatteryBoostLimit); err == nil {
lp.SetBatteryBoostLimit(int(v))
@@ -688,7 +694,9 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() {
lp.triggerOptimizer()
}
-// triggerOptimizer re-runs the optimizer when the loadpoint's profile changed
+// triggerOptimizer re-runs the optimizer when the loadpoint's profile changed.
+// A burst of setters, e.g. on disconnect, is coalesced by optimizerUpdateAsync's
+// TryLock- only the first call gets through while a run is in flight.
func (lp *Loadpoint) triggerOptimizer() {
if lp.site != nil {
lp.site.Optimize()
@@ -2443,10 +2451,18 @@ NO_DIM:
mode := lp.GetMode()
lp.publish(keys.Mode, mode)
+ lp.publish(keys.OptimizerControlled, lp.optimizerControlled())
// update and publish plan without being short-circuited by modes etc.
plannerActive := lp.plannerActive()
+ // the optimizer schedules the plan itself, the planner only backstops it.
+ // A stalled optimizer has no suggestion and releases the planner again.
+ suggestion := lp.activeSuggestion()
+ if suggestion != nil {
+ plannerActive = lp.planDeadlineCritical()
+ }
+
// update and publish min soc not reached state
minSocNotReached := lp.minSocNotReached()
lp.publish(keys.MinSocNotReached, minSocNotReached)
@@ -2492,8 +2508,17 @@ NO_DIM:
err = lp.fastCharging()
case mode == api.ModeSmart:
+ // optimizer decides start/stop and level, replacing the price limits
+ if suggestion != nil {
+ if handled, e := lp.optimizerCharging(suggestion); handled {
+ err = e
+ break
+ }
+ // surplus regime or stop: the pv loop below tracks the measured surplus
+ }
+
// cheap tariff
- if smartCostActive {
+ if suggestion == nil && smartCostActive {
rate, _ := consumption.At(time.Now())
lp.log.DEBUG.Printf("smart consumption active: %.2f", rate.Value)
err = lp.fastCharging()
@@ -2502,7 +2527,7 @@ NO_DIM:
}
// attractive feedin
- if smartFeedInPriorityActive {
+ if suggestion == nil && smartFeedInPriorityActive {
rate, _ := feedin.At(time.Now())
lp.log.DEBUG.Printf("smart feed-in active: %.2f", rate.Value)
@@ -2519,6 +2544,12 @@ NO_DIM:
break
}
+ // the optimizer plans the home battery- bufferSoc and bufferStartSoc must not
+ // keep charging off the battery against its stop (#34606)
+ if suggestion != nil {
+ batteryBuffered, batteryStart = false, false
+ }
+
targetCurrent := lp.pvMaxCurrent(sitePower, batteryPower, batteryBuffered, batteryStart)
if targetCurrent == 0 && lp.vehicleClimateActive() {
diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go
index 74dcee503a..cfdd899cb4 100644
--- a/core/loadpoint/api.go
+++ b/core/loadpoint/api.go
@@ -159,6 +159,8 @@ type API interface {
SocBasedPlanning() bool
// GetPlan creates a charging plan
GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration, continuous bool) api.Rates
+ // EffectivePlan returns the charging plan in effect, its average power and true if the optimizer created it
+ EffectivePlan(planTime time.Time, requiredDuration time.Duration, strategy api.PlanStrategy) (api.Rates, float64, bool)
// GetSocConfig returns the soc poll settings
GetSocConfig() SocConfig
@@ -208,7 +210,7 @@ type API interface {
// GetSmartCostLimit return the smart cost limit
GetSmartCostLimit() *float64
// SetSmartCostLimit sets the smart cost limit
- SetSmartCostLimit(limit *float64)
+ SetSmartCostLimit(limit *float64) error
// GetSolarShare gets the solar share
GetSolarShare() float64
@@ -217,7 +219,7 @@ type API interface {
// GetSmartFeedInPriorityLimit return the smart feed-in limit
GetSmartFeedInPriorityLimit() *float64
// SetSmartFeedInPriorityLimit sets the smart feed-in limit
- SetSmartFeedInPriorityLimit(limit *float64)
+ SetSmartFeedInPriorityLimit(limit *float64) error
//
// power and energy
diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go
index 5999ef8821..26ff13a3a3 100644
--- a/core/loadpoint/config.go
+++ b/core/loadpoint/config.go
@@ -1,6 +1,8 @@
package loadpoint
import (
+ "cmp"
+ "errors"
"time"
"github.com/evcc-io/evcc/api"
@@ -71,9 +73,13 @@ func SplitConfig(payload map[string]any) (DynamicConfig, map[string]any, error)
func (payload DynamicConfig) Apply(lp API) error {
lp.SetTitle(payload.Title)
lp.SetPriority(payload.Priority)
- lp.SetSmartCostLimit(payload.SmartCostLimit)
- lp.SetSmartFeedInPriorityLimit(payload.SmartFeedInPriorityLimit)
+ // limits the optimizer controls are rejected, the remaining config still applies
+ limitErr := errors.Join(
+ lp.SetSmartCostLimit(payload.SmartCostLimit),
+ lp.SetSmartFeedInPriorityLimit(payload.SmartFeedInPriorityLimit),
+ )
lp.SetSolarShare(payload.SolarShare)
+
lp.SetThresholds(payload.Thresholds)
lp.SetPlanEnergy(payload.PlanTime, payload.PlanEnergy)
lp.SetPlanStrategy(payload.PlanStrategy)
@@ -122,5 +128,5 @@ func (payload DynamicConfig) Apply(lp API) error {
}
}
- return err
+ return cmp.Or(err, limitErr)
}
diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go
index 931de437ef..ad319a6a67 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -109,6 +109,22 @@ func (mr *MockAPIMockRecorder) EffectiveMinPower() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectiveMinPower", reflect.TypeOf((*MockAPI)(nil).EffectiveMinPower))
}
+// EffectivePlan mocks base method.
+func (m *MockAPI) EffectivePlan(planTime time.Time, requiredDuration time.Duration, strategy api.PlanStrategy) (api.Rates, float64, bool) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "EffectivePlan", planTime, requiredDuration, strategy)
+ ret0, _ := ret[0].(api.Rates)
+ ret1, _ := ret[1].(float64)
+ ret2, _ := ret[2].(bool)
+ return ret0, ret1, ret2
+}
+
+// EffectivePlan indicates an expected call of EffectivePlan.
+func (mr *MockAPIMockRecorder) EffectivePlan(planTime, requiredDuration, strategy any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePlan", reflect.TypeOf((*MockAPI)(nil).EffectivePlan), planTime, requiredDuration, strategy)
+}
+
// EffectivePlanId mocks base method.
func (m *MockAPI) EffectivePlanId() int {
m.ctrl.T.Helper()
@@ -1128,9 +1144,11 @@ func (mr *MockAPIMockRecorder) SetPriority(arg0 any) *gomock.Call {
}
// SetSmartCostLimit mocks base method.
-func (m *MockAPI) SetSmartCostLimit(limit *float64) {
+func (m *MockAPI) SetSmartCostLimit(limit *float64) error {
m.ctrl.T.Helper()
- m.ctrl.Call(m, "SetSmartCostLimit", limit)
+ ret := m.ctrl.Call(m, "SetSmartCostLimit", limit)
+ ret0, _ := ret[0].(error)
+ return ret0
}
// SetSmartCostLimit indicates an expected call of SetSmartCostLimit.
@@ -1140,9 +1158,11 @@ func (mr *MockAPIMockRecorder) SetSmartCostLimit(limit any) *gomock.Call {
}
// SetSmartFeedInPriorityLimit mocks base method.
-func (m *MockAPI) SetSmartFeedInPriorityLimit(limit *float64) {
+func (m *MockAPI) SetSmartFeedInPriorityLimit(limit *float64) error {
m.ctrl.T.Helper()
- m.ctrl.Call(m, "SetSmartFeedInPriorityLimit", limit)
+ ret := m.ctrl.Call(m, "SetSmartFeedInPriorityLimit", limit)
+ ret0, _ := ret[0].(error)
+ return ret0
}
// SetSmartFeedInPriorityLimit indicates an expected call of SetSmartFeedInPriorityLimit.
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index 532aea9983..d0567e8cb7 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -208,6 +208,7 @@ func (lp *Loadpoint) SetMode(mode api.ChargeMode) {
}
lp.requestUpdate()
+ lp.triggerOptimizer()
}
}
@@ -381,6 +382,7 @@ func (lp *Loadpoint) SetLimitSoc(soc int) {
if lp.limitSoc != soc {
lp.setLimitSoc(soc)
lp.requestUpdate()
+ lp.triggerOptimizer()
}
}
@@ -442,6 +444,7 @@ func (lp *Loadpoint) SetLimitEnergy(energy float64) {
if lp.limitEnergy != energy {
lp.setLimitEnergy(energy)
lp.requestUpdate()
+ lp.triggerOptimizer()
}
}
@@ -496,6 +499,7 @@ func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error {
if lp.planEnergy != energy || !lp.planTime.Equal(finishAt) {
lp.setPlanEnergy(finishAt, energy)
lp.requestUpdate()
+ lp.triggerOptimizer()
}
return nil
@@ -799,6 +803,11 @@ func (lp *Loadpoint) GetChargePowerFlexibility(rates api.Rates) float64 {
return 0
}
+ // an optimizer setpoint does not yield to a higher priority loadpoint
+ if s := lp.activeSuggestion(); s != nil && s.Action == actionCharge && !lp.surplusRegime(s) {
+ return 0
+ }
+
if loadpoint.SurplusFlexible(lp) {
return lp.GetChargePower()
}
@@ -985,15 +994,36 @@ func (lp *Loadpoint) StartVehicleDetection() {
lp.startVehicleDetection()
}
-// GetSmartCostLimit gets the smart cost limit
+// GetSmartCostLimit gets the smart cost limit. The optimizer replaces it in
+// automatic mode, so it reads as unset and none of its consumers apply it.
func (lp *Loadpoint) GetSmartCostLimit() *float64 {
+ if lp.optimizerControlled() {
+ return nil
+ }
+
lp.RLock()
defer lp.RUnlock()
return lp.smartCostLimit
}
// SetSmartCostLimit sets the smart cost limit
-func (lp *Loadpoint) SetSmartCostLimit(val *float64) {
+func (lp *Loadpoint) SetSmartCostLimit(val *float64) error {
+ if lp.optimizerControlled() {
+ // the getter reads nil in automatic mode- a config round-trip writing it
+ // back must not discard the stored limit
+ if val != nil {
+ return ErrOptimizerAutomatic
+ }
+
+ return nil
+ }
+
+ lp.setSmartCostLimit(val)
+
+ return nil
+}
+
+func (lp *Loadpoint) setSmartCostLimit(val *float64) {
lp.Lock()
defer lp.Unlock()
@@ -1028,15 +1058,34 @@ func (lp *Loadpoint) SetSolarShare(val float64) {
}
}
-// GetSmartFeedInPriorityLimit gets the smart feed-in limit
+// GetSmartFeedInPriorityLimit gets the smart feed-in limit. The optimizer
+// replaces it in automatic mode, so it reads as unset.
func (lp *Loadpoint) GetSmartFeedInPriorityLimit() *float64 {
+ if lp.optimizerControlled() {
+ return nil
+ }
+
lp.RLock()
defer lp.RUnlock()
return lp.smartFeedInPriorityLimit
}
// SetSmartFeedInPriorityLimit sets the smart cost feed-in
-func (lp *Loadpoint) SetSmartFeedInPriorityLimit(val *float64) {
+func (lp *Loadpoint) SetSmartFeedInPriorityLimit(val *float64) error {
+ if lp.optimizerControlled() {
+ if val != nil {
+ return ErrOptimizerAutomatic
+ }
+
+ return nil
+ }
+
+ lp.setSmartFeedInPriorityLimit(val)
+
+ return nil
+}
+
+func (lp *Loadpoint) setSmartFeedInPriorityLimit(val *float64) {
lp.Lock()
defer lp.Unlock()
diff --git a/core/loadpoint_boost_test.go b/core/loadpoint_boost_test.go
index 71597db132..9ff9990b4d 100644
--- a/core/loadpoint_boost_test.go
+++ b/core/loadpoint_boost_test.go
@@ -18,12 +18,21 @@ type mockSite struct {
maxDischargePower *float64
residualPower float64
optimized int
+ automatic bool
}
func (m *mockSite) Optimize() {
m.optimized++
}
+func (m *mockSite) Automatic() bool {
+ return m.automatic
+}
+
+func (m *mockSite) AutomaticLoadpoints() bool {
+ return m.automatic
+}
+
func (m *mockSite) GetBatteryMaxDischargePower() *float64 {
return m.maxDischargePower
}
diff --git a/core/loadpoint_optimizer.go b/core/loadpoint_optimizer.go
new file mode 100644
index 0000000000..1bfd27918a
--- /dev/null
+++ b/core/loadpoint_optimizer.go
@@ -0,0 +1,186 @@
+package core
+
+import (
+ "math"
+ "time"
+
+ "github.com/evcc-io/evcc/api"
+ "github.com/evcc-io/evcc/core/planner"
+ "github.com/evcc-io/evcc/core/types"
+)
+
+// setSuggestion stores the optimizer suggestion for the current slot
+func (lp *Loadpoint) setSuggestion(s *types.Suggestion) {
+ lp.Lock()
+ defer lp.Unlock()
+
+ lp.suggestion = s
+ lp.suggestionUpdated = lp.clock.Now()
+}
+
+// setOptimizerPlan stores the charging schedule of the last solve
+func (lp *Loadpoint) setOptimizerPlan(plan optimizerPlan) {
+ lp.Lock()
+ defer lp.Unlock()
+
+ lp.optimizerPlan = plan
+}
+
+// OptimizerPlan returns the optimizer's remaining charging schedule up to the
+// plan time and its average power, nil if the optimizer is not in charge. Later
+// slots serve surplus, not the plan.
+func (lp *Loadpoint) OptimizerPlan(planTime time.Time) (api.Rates, float64) {
+ if lp.activeSuggestion() == nil {
+ return nil, 0
+ }
+
+ lp.RLock()
+ defer lp.RUnlock()
+
+ now := lp.clock.Now()
+
+ var rates api.Rates
+ var energy float64
+ for i, slot := range lp.optimizerPlan.rates {
+ // keep what is left until the plan time, like the planner does: the schedule
+ // is from the last solve and its goal slot may end after the plan time
+ clipped := slot
+ if clipped.Start.Before(now) {
+ clipped.Start = now
+ }
+ if clipped.End.After(planTime) {
+ clipped.End = planTime
+ }
+ if !clipped.Start.Before(clipped.End) {
+ continue
+ }
+
+ rates = append(rates, clipped)
+ energy += lp.optimizerPlan.energy[i] * float64(clipped.End.Sub(clipped.Start)) / float64(slot.End.Sub(slot.Start))
+ }
+
+ if len(rates) == 0 {
+ return nil, 0
+ }
+
+ return rates, energy / planner.Duration(rates).Hours()
+}
+
+// optimizerControlled indicates that the optimizer decides for this loadpoint.
+// Heating devices and switch sockets cannot be modelled and keep their limits,
+// as do vehicles without known capacity- optimizerRequest skips them, too.
+func (lp *Loadpoint) optimizerControlled() bool {
+ if lp.site == nil || !lp.site.AutomaticLoadpoints() ||
+ lp.chargerHasFeature(api.Heating) || lp.chargerHasFeature(api.IntegratedDevice) {
+ return false
+ }
+
+ v := lp.GetVehicle()
+ return v != nil && v.Capacity() > 0
+}
+
+// activeSuggestion returns the optimizer's start/stop decision for the current slot,
+// nil if the optimizer does not control this loadpoint
+func (lp *Loadpoint) activeSuggestion() *types.Suggestion {
+ if !lp.optimizerControlled() {
+ return nil
+ }
+
+ lp.RLock()
+ defer lp.RUnlock()
+
+ // a stalled optimizer must not keep the loadpoint gated
+ if lp.suggestion == nil || lp.clock.Since(lp.suggestionUpdated) > suggestionMaxAge {
+ return nil
+ }
+
+ return lp.suggestion
+}
+
+// planDeadlineCritical returns true if the plan goal can only be reached by
+// charging now. Backstops a plan the optimizer can no longer satisfy.
+func (lp *Loadpoint) planDeadlineCritical() bool {
+ planTime := lp.EffectivePlanTime()
+ if planTime.IsZero() {
+ return false
+ }
+
+ goal, _ := lp.GetPlanGoal()
+ if goal <= 0 {
+ return false
+ }
+
+ required := lp.GetPlanRequiredDuration(goal, lp.EffectiveMaxPower())
+
+ // past the plan time the goal was missed- keep charging like plannerActive does
+ if remaining := lp.clock.Until(planTime); remaining > 0 {
+ return required >= remaining
+ }
+
+ return required > 0
+}
+
+// surplusRegime indicates that the suggested charge power matches the forecast
+// surplus, leaving the loadpoint to the pv loop instead of pinning it to a setpoint.
+// Full power is grid-fed by definition and never counts as surplus.
+func (lp *Loadpoint) surplusRegime(s *types.Suggestion) bool {
+ full := s.Charge >= lp.EffectiveMaxPower()-suggestionThreshold
+ return s.Action == actionCharge && !full && math.Abs(s.Grid) <= suggestionThreshold
+}
+
+// optimizerCharging applies the optimizer's charging decision. It returns false
+// if the loadpoint is to follow pv surplus instead, leaving current and phases
+// to the regular pv control loop. Otherwise current and phases follow the
+// suggested power, only the level is the optimizer's decision.
+func (lp *Loadpoint) optimizerCharging(s *types.Suggestion) (bool, error) {
+ if lp.surplusRegime(s) {
+ // the charge power matches the forecast surplus, which only holds on average-
+ // the pv loop tracks the measured one, so its timers must keep running
+ if lp.pvTimer.Equal(elapsed) {
+ // elapsed by a previous grid-fed slot, would disable on the first dip
+ lp.resetPVTimer()
+ }
+
+ lp.log.DEBUG.Printf("optimizer: charge (%.0fW), following pv surplus", s.Charge)
+ return false, nil
+ }
+
+ if s.Action != actionCharge && !lp.GetAlwaysCharge().Active() {
+ // a stop rests on the forecast surplus, which may be too low- the pv loop still
+ // enables on measured surplus. A timer elapsed by a grid-fed slot stops at once.
+ if !lp.enabled && lp.pvTimer.Equal(elapsed) {
+ lp.resetPVTimer()
+ }
+
+ lp.log.DEBUG.Println("optimizer: stop, following measured pv surplus")
+ return false, nil
+ }
+
+ lp.elapsePVTimer() // let PV mode disable immediately afterwards
+
+ // full power, or more than the active phases can deliver: fastCharging scales
+ // up and owns the phase timer, resetting it here would restart the delay every cycle
+ full := s.Charge >= lp.EffectiveMaxPower()-suggestionThreshold
+ if s.Action == actionCharge && (full || s.Charge > currentToPower(lp.effectiveMaxCurrent(), lp.ActivePhases())) {
+ lp.log.DEBUG.Printf("optimizer: charge (%.0fW), full power", s.Charge)
+ return true, lp.fastCharging()
+ }
+
+ lp.resetPhaseTimer()
+
+ if s.Action == actionCharge {
+ // a limited setpoint, e.g. a minimum demand or a grid import limit
+ if current := powerToCurrent(s.Charge, lp.ActivePhases()); current >= lp.effectiveMinCurrent() {
+ lp.log.DEBUG.Printf("optimizer: charge (%.0fW)", s.Charge)
+ return true, lp.setLimit(current)
+ }
+
+ // below what the active phases can deliver, minCharging scales down instead
+ lp.log.DEBUG.Printf("optimizer: charge (%.0fW), minimum power", s.Charge)
+ return true, lp.minCharging()
+ }
+
+ // always charge keeps its minimum power, the optimizer plans with it
+ lp.log.DEBUG.Println("optimizer: stop, keeping minimum power")
+ return true, lp.minCharging()
+}
diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go
index 613e729a73..49b2a1dd1d 100644
--- a/core/loadpoint_plan.go
+++ b/core/loadpoint_plan.go
@@ -135,6 +135,19 @@ func (lp *Loadpoint) GetPlan(targetTime time.Time, requiredDuration, preconditio
return lp.planner.Plan(requiredDuration, precondition, targetTime, continuous)
}
+// EffectivePlan returns the charging plan in effect. While in control the
+// optimizer schedules the plan itself, returned with its average power and
+// true. Everything else is the planner's.
+func (lp *Loadpoint) EffectivePlan(planTime time.Time, requiredDuration time.Duration, strategy api.PlanStrategy) (api.Rates, float64, bool) {
+ if requiredDuration > 0 {
+ if plan, power := lp.OptimizerPlan(planTime); plan != nil {
+ return plan, power, true
+ }
+ }
+
+ return lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous), 0, false
+}
+
// plannerActive checks if the charging plan has a currently active slot
func (lp *Loadpoint) plannerActive() (active bool) {
defer func() {
@@ -186,11 +199,14 @@ func (lp *Loadpoint) plannerActive() (active bool) {
strategy := lp.getEffectivePlanStrategy()
- plan = lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous)
+ plan, power, optimized := lp.EffectivePlan(planTime, requiredDuration, strategy)
if plan == nil {
lp.log.DEBUG.Println("!! plan: plan nil")
return false
}
+ if optimized {
+ maxPower = power
+ }
var overrun string
if excessDuration := requiredDuration - lp.clock.Until(planTime); excessDuration > 0 {
@@ -216,6 +232,16 @@ func (lp *Loadpoint) plannerActive() (active bool) {
activeSlot := planner.SlotAt(lp.clock.Now(), plan)
active = !activeSlot.End.IsZero()
+ // the optimizer starts and stops by itself, the planner's rules below don't
+ // apply. Its plan is active during a slot and while the deadline is critical.
+ if optimized {
+ active = active || lp.planDeadlineCritical()
+ if active && lp.planLocked.Id == 0 && isSocBased {
+ lp.lockPlanGoal(planTime, int(goal), lp.getPlanId())
+ }
+ return active
+ }
+
if active {
// short slots cost an extra start/stop cycle, defer them if a later slot follows anyway
if slotRemaining := lp.clock.Until(activeSlot.End); !lp.planActive && slotRemaining < tariff.SlotDuration-time.Minute && planner.SlotHasLaterSuccessor(activeSlot, plan) {
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index 53dbadbd5d..b36992565f 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -84,7 +84,8 @@ func attachListeners(t *testing.T, lp *Loadpoint) {
}
uiChan, pushChan, lpChan := createChannels(t)
- lp.Prepare(new(Site), uiChan, pushChan, lpChan)
+ // the optimizer runs async- without a logger its panic recovery panics again
+ lp.Prepare(&Site{log: util.NewLogger("site")}, uiChan, pushChan, lpChan)
}
func TestNew(t *testing.T) {
diff --git a/core/optimizer_automatic_test.go b/core/optimizer_automatic_test.go
new file mode 100644
index 0000000000..6a4b7333ea
--- /dev/null
+++ b/core/optimizer_automatic_test.go
@@ -0,0 +1,551 @@
+package core
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ evbus "github.com/asaskevich/EventBus"
+ "github.com/benbjohnson/clock"
+ "github.com/evcc-io/evcc/api"
+ "github.com/evcc-io/evcc/core/keys"
+ coresettings "github.com/evcc-io/evcc/core/settings"
+ "github.com/evcc-io/evcc/core/types"
+ "github.com/evcc-io/evcc/db/settings"
+ "github.com/evcc-io/evcc/messenger"
+ "github.com/evcc-io/evcc/util"
+ "github.com/evcc-io/evcc/util/config"
+ "github.com/evcc-io/evcc/util/sponsor"
+ "github.com/stretchr/testify/assert"
+ "go.uber.org/mock/gomock"
+)
+
+// enableAutomatic puts the optimizer in control for the duration of the test
+func enableAutomatic(t *testing.T) {
+ t.Helper()
+
+ subject := sponsor.Subject
+ sponsor.Subject = "test"
+
+ for _, k := range []string{keys.Experimental, keys.Optimizer} {
+ settings.SetBool(k, true)
+ }
+ settings.SetString(keys.OptimizerAutomatic, OptimizerAutomaticFull)
+
+ t.Cleanup(func() {
+ sponsor.Subject = subject
+ for _, k := range []string{keys.Experimental, keys.Optimizer} {
+ settings.SetBool(k, false)
+ }
+ settings.SetString(keys.OptimizerAutomatic, OptimizerAutomaticOff)
+ })
+}
+
+// TestAutomaticLevels verifies that the battery level controls the battery only
+func TestAutomaticLevels(t *testing.T) {
+ enableAutomatic(t)
+ site := &Site{}
+
+ for _, tc := range []struct {
+ stored string
+ battery, loadpoints bool
+ }{
+ {OptimizerAutomaticOff, false, false},
+ {OptimizerAutomaticBattery, true, false},
+ {OptimizerAutomaticFull, true, true},
+ {"true", true, true}, // boolean switch before the levels
+ {"false", false, false},
+ } {
+ settings.SetString(keys.OptimizerAutomatic, tc.stored)
+ assert.Equal(t, tc.battery, site.Automatic(), tc.stored)
+ assert.Equal(t, tc.loadpoints, site.AutomaticLoadpoints(), tc.stored)
+ }
+}
+
+func automaticLoadpoint(t *testing.T, ac api.AlwaysCharge, automatic bool) (*Loadpoint, *api.MockCharger, *gomock.Controller) {
+ t.Helper()
+
+ ctrl := gomock.NewController(t)
+ charger := api.NewMockCharger(ctrl)
+
+ Voltage = 230
+
+ lp := &Loadpoint{
+ log: util.NewLogger("foo"),
+ bus: evbus.New(),
+ clock: clock.NewMock(),
+ charger: charger,
+ chargeMeter: newChargeMeter(&Null{}),
+ chargeRater: &Null{},
+ chargeTimer: &Null{},
+ wakeUpTimer: NewTimer(),
+ minCurrent: minA,
+ maxCurrent: maxA,
+ phases: 1,
+ status: api.StatusC,
+ mode: api.ModeSmart,
+ alwaysCharge: ac,
+ }
+
+ // only vehicles with known capacity can be modelled by the optimizer
+ lp.vehicle = modelledVehicle(ctrl)
+
+ attachListeners(t, lp)
+
+ // attachListeners assigns a real site
+ lp.site = &mockSite{automatic: automatic}
+
+ charger.EXPECT().Status().Return(api.StatusC, nil)
+ charger.EXPECT().Enabled().Return(true, nil)
+
+ return lp, charger, ctrl
+}
+
+// modelledVehicle returns a vehicle the optimizer can model as storage
+func modelledVehicle(ctrl *gomock.Controller) api.Vehicle {
+ v := api.NewMockVehicle(ctrl)
+ v.EXPECT().Capacity().Return(50.0).AnyTimes()
+ v.EXPECT().Phases().Return(0).AnyTimes()
+ v.EXPECT().Features().Return(nil).AnyTimes()
+ v.EXPECT().OnIdentified().Return(api.ActionConfig{}).AnyTimes()
+ v.EXPECT().GetTitle().Return("").AnyTimes()
+ v.EXPECT().Icon().Return("").AnyTimes()
+ v.EXPECT().Identifiers().Return(nil).AnyTimes()
+ v.EXPECT().Soc().Return(50.0, nil).AnyTimes()
+ return v
+}
+
+func TestOptimizerGate(t *testing.T) {
+ enableAutomatic(t)
+
+ // the 1p test loadpoint tops out at 3680W
+ full := types.Suggestion{Action: actionCharge, Charge: 3680, Grid: 1000}
+ stop := types.Suggestion{Action: actionStop}
+
+ tc := []struct {
+ mode api.ChargeMode
+ ac api.AlwaysCharge
+ s types.Suggestion
+ buffered bool // battery above bufferSoc/bufferStartSoc, discharging into the vehicle
+ expect func(h *api.MockCharger)
+ }{
+ // optimizer starts and stops smart charging, replacing the price limits.
+ // Stop hands over to the pv loop, which disables at once after a grid-fed slot
+ {api.ModeSmart, api.AlwaysChargeOff, full, false, func(h *api.MockCharger) { h.EXPECT().MaxCurrent(int64(maxA)) }},
+ {api.ModeSmart, api.AlwaysChargeOff, stop, false, func(h *api.MockCharger) { h.EXPECT().Enable(false) }},
+
+ // the optimizer plans the battery, its buffer must not keep the vehicle charging (#34606)
+ {api.ModeSmart, api.AlwaysChargeOff, stop, true, func(h *api.MockCharger) { h.EXPECT().Enable(false) }},
+
+ // always charge keeps its minimum power when the optimizer stops
+ {api.ModeSmart, api.AlwaysChargeOn, full, false, func(h *api.MockCharger) { h.EXPECT().MaxCurrent(int64(maxA)) }},
+ {api.ModeSmart, api.AlwaysChargeOn, stop, false, nil}, // already at min current
+
+ // a grid-fed power below the maximum is applied as current
+ {api.ModeSmart, api.AlwaysChargeOff, types.Suggestion{Action: actionCharge, Charge: 2300, Grid: 1000}, false, func(h *api.MockCharger) { h.EXPECT().MaxCurrent(int64(10)) }},
+
+ // off and fast remain the user's decision
+ {api.ModeOff, api.AlwaysChargeOff, full, false, func(h *api.MockCharger) { h.EXPECT().Enable(false) }},
+ {api.ModeNow, api.AlwaysChargeOff, stop, false, func(h *api.MockCharger) { h.EXPECT().MaxCurrent(int64(maxA)) }},
+ }
+
+ for _, tc := range tc {
+ t.Log(tc)
+
+ lp, charger, ctrl := automaticLoadpoint(t, tc.ac, true)
+ lp.mode = tc.mode
+ lp.clock.(*clock.Mock).Add(time.Hour) // elapsed must lie in the past
+ lp.pvTimer = elapsed // previous grid-fed slot
+ lp.setSuggestion(&tc.s)
+
+ if tc.expect != nil {
+ tc.expect(charger)
+ }
+
+ // grid import above min power, no measured surplus
+ if tc.buffered {
+ lp.Update(1972, 1737, nil, nil, true, true, 0, nil, nil, nil)
+ } else {
+ lp.Update(2000, 0, nil, nil, false, false, 0, nil, nil, nil)
+ }
+
+ ctrl.Finish()
+ }
+}
+
+// TestOptimizerSurplusRegime covers a charge power matched to the forecast surplus:
+// control falls through to the pv loop and its enable/disable timer keeps running
+// instead of being elapsed on every cycle
+func TestOptimizerSurplusRegime(t *testing.T) {
+ enableAutomatic(t)
+
+ lp, charger, ctrl := automaticLoadpoint(t, api.AlwaysChargeOff, true)
+ lp.Disable.Delay = time.Minute
+ lp.offeredCurrent = maxA
+
+ // a previous grid-fed slot left the pv timer elapsed
+ lp.pvTimer = elapsed
+
+ lp.setSuggestion(&types.Suggestion{Action: actionCharge, Charge: 2300})
+
+ // surplus dip: the loadpoint steps down to min current while the disable
+ // timer runs instead of disabling right away
+ charger.EXPECT().MaxCurrent(int64(minA))
+ lp.Update(4000, 0, nil, nil, false, false, 0, nil, nil, nil)
+
+ assert.False(t, lp.pvTimer.Equal(elapsed), "pv timer must not stay elapsed")
+ assert.False(t, lp.pvTimer.IsZero(), "pv disable timer must be running")
+
+ ctrl.Finish()
+}
+
+// TestOptimizerStopFollowsSurplus covers a stop based on a forecast surplus that is
+// too low: the pv loop still enables on measured surplus after the enable delay
+func TestOptimizerStopFollowsSurplus(t *testing.T) {
+ enableAutomatic(t)
+
+ stop := &types.Suggestion{Action: actionStop}
+
+ lp := NewLoadpoint(util.NewLogger("foo"), nil)
+ lp.site = &mockSite{automatic: true}
+ lp.vehicle = modelledVehicle(gomock.NewController(t))
+
+ // disabled: a timer elapsed by a grid-fed slot must not skip the enable delay
+ lp.pvTimer = elapsed
+ handled, err := lp.optimizerCharging(stop)
+ assert.NoError(t, err)
+ assert.False(t, handled, "stop must leave the decision to the pv loop")
+ assert.True(t, lp.pvTimer.IsZero(), "enable delay must apply")
+
+ // enabled after a grid-fed slot: disable without measured surplus at once
+ lp.enabled = true
+ lp.pvTimer = elapsed
+ handled, _ = lp.optimizerCharging(stop)
+ assert.False(t, handled)
+ assert.True(t, lp.pvTimer.Equal(elapsed))
+}
+
+// TestOptimizerFlexibility covers a loadpoint the optimizer pins to a setpoint:
+// it does not yield to a higher priority loadpoint, so its power is not flexible
+func TestOptimizerFlexibility(t *testing.T) {
+ enableAutomatic(t)
+ Voltage = 230
+
+ for _, tc := range []struct {
+ s types.Suggestion
+ want float64
+ }{
+ {types.Suggestion{Action: actionCharge, Charge: 3680, Grid: 1000}, 0}, // full power
+ {types.Suggestion{Action: actionCharge, Charge: 2300, Grid: 1000}, 0}, // grid-fed setpoint
+ {types.Suggestion{Action: actionCharge, Charge: 2300}, 2700}, // surplus regime, pv loop yields
+ {types.Suggestion{Action: actionStop}, 2700}, // stop, pv loop yields
+ } {
+ lp := NewLoadpoint(util.NewLogger("foo"), nil)
+ lp.mode = api.ModeSmart
+ lp.status = api.StatusC
+ lp.chargePower = 2700
+ lp.phases = 1
+ lp.vehicle = modelledVehicle(gomock.NewController(t))
+ lp.site = &mockSite{automatic: true}
+ lp.setSuggestion(&tc.s)
+
+ assert.Equal(t, tc.want, lp.GetChargePowerFlexibility(nil), tc.s)
+ }
+}
+
+func TestOptimizerGateInactive(t *testing.T) {
+ enableAutomatic(t)
+
+ // automatic disabled: pv surplus decides, the suggestion is advisory only
+ lp, _, ctrl := automaticLoadpoint(t, api.AlwaysChargeOff, false)
+ lp.setSuggestion(&types.Suggestion{Action: actionCharge})
+
+ assert.Nil(t, lp.activeSuggestion())
+
+ // pv is balanced at zero site power, a gated loadpoint would go to max current
+ lp.Update(0, 0, nil, nil, false, false, 0, nil, nil, nil)
+
+ ctrl.Finish()
+}
+
+func TestOptimizerGateStale(t *testing.T) {
+ enableAutomatic(t)
+
+ // a stalled optimizer must not keep the loadpoint gated
+ lp, _, ctrl := automaticLoadpoint(t, api.AlwaysChargeOff, true)
+ lp.setSuggestion(&types.Suggestion{Action: actionCharge})
+ lp.suggestionUpdated = lp.clock.Now().Add(-suggestionMaxAge - time.Minute)
+
+ assert.Nil(t, lp.activeSuggestion())
+
+ lp.Update(0, 0, nil, nil, false, false, 0, nil, nil, nil)
+
+ ctrl.Finish()
+}
+
+func TestSmartCostLimitUnavailable(t *testing.T) {
+ enableAutomatic(t)
+
+ limit := 0.2
+
+ lp := &Loadpoint{
+ log: util.NewLogger("foo"),
+ clock: clock.NewMock(),
+ settings: coresettings.NewDatabaseSettingsAdapter("test"),
+ }
+ lp.site = &mockSite{automatic: true}
+ lp.vehicle = modelledVehicle(gomock.NewController(t))
+
+ assert.ErrorIs(t, lp.SetSmartCostLimit(&limit), ErrOptimizerAutomatic)
+ assert.ErrorIs(t, lp.SetSmartFeedInPriorityLimit(&limit), ErrOptimizerAutomatic)
+ assert.Nil(t, lp.GetSmartCostLimit())
+
+ // clearing is a no-op, so a config round-trip does not discard the stored limit
+ assert.NoError(t, lp.SetSmartCostLimit(nil))
+
+ // loadpoints the optimizer cannot model keep their limits
+ lp.charger = struct {
+ api.Charger
+ api.FeatureDescriber
+ }{FeatureDescriber: &featureCharger{features: []api.Feature{api.Heating}}}
+
+ assert.NoError(t, lp.SetSmartCostLimit(&limit))
+ assert.Equal(t, &limit, lp.GetSmartCostLimit())
+
+ // a vehicle without known capacity cannot be modelled either
+ lp.charger = nil
+ lp.vehicle = nil
+
+ assert.NoError(t, lp.SetSmartFeedInPriorityLimit(&limit))
+ assert.Equal(t, &limit, lp.GetSmartFeedInPriorityLimit())
+}
+
+func TestBatteryModeAutomatic(t *testing.T) {
+ enableAutomatic(t)
+
+ ctrl := gomock.NewController(t)
+ batCon := batteryControllerMock(ctrl)
+
+ var bat api.Meter = &struct {
+ api.Meter
+ api.BatteryController
+ }{
+ BatteryController: batCon,
+ }
+
+ site := &Site{
+ log: util.NewLogger("foo"),
+ batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{Name: "bat"}, bat)},
+ }
+
+ // optimizer decides to grid charge, replacing the grid charge limit
+ site.setSuggestions(map[string]types.Suggestion{
+ batteryKey("bat"): {Action: api.BatteryCharge.String()},
+ })
+
+ batCon.EXPECT().SetBatteryMode(api.BatteryCharge)
+ site.updateBatteryMode(false, false, api.Rate{})
+ assert.Equal(t, api.BatteryCharge, site.GetBatteryMode())
+
+ // an expired solve releases the battery
+ site.clearSuggestions()
+
+ batCon.EXPECT().SetBatteryMode(api.BatteryNormal)
+ site.updateBatteryMode(false, false, api.Rate{})
+ assert.Equal(t, api.BatteryNormal, site.GetBatteryMode())
+
+ ctrl.Finish()
+}
+
+func TestBatteryGridChargeLimitUnavailable(t *testing.T) {
+ enableAutomatic(t)
+
+ ctrl := gomock.NewController(t)
+ batCon := api.NewMockBatteryController(ctrl)
+
+ var bat api.Meter = &struct {
+ api.Meter
+ api.BatteryController
+ }{
+ BatteryController: batCon,
+ }
+
+ site := &Site{
+ log: util.NewLogger("foo"),
+ batteryMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{Name: "bat"}, bat)},
+ }
+
+ limit := 0.2
+ assert.ErrorIs(t, site.SetBatteryGridChargeLimit(&limit), ErrOptimizerAutomatic)
+ assert.ErrorIs(t, site.SetBatteryDischargeControl(true), ErrOptimizerAutomatic)
+}
+
+// TestOptimizerPhaseScaleUp covers a grid-fed charge on 1p behind a circuit:
+// the scale up delay must run across control cycles instead of restarting on each
+func TestOptimizerPhaseScaleUp(t *testing.T) {
+ for _, charge := range []float64{11040, 9200} { // full power, and more than 1p delivers
+ t.Run(fmt.Sprintf("%.0fW", charge), func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ clck := clock.NewMock()
+ clck.Add(time.Hour)
+
+ Voltage = 230
+
+ lp := NewLoadpoint(util.NewLogger("foo"), nil)
+ lp.clock = clck
+ lp.minCurrent = minA
+ lp.maxCurrent = maxA
+ lp.Enable.Delay = 3 * time.Minute
+ lp.phases = 1
+ lp.status = api.StatusC
+ lp.chargePower = 3680
+ lp.wakeUpTimer = NewTimer()
+
+ plainCharger := api.NewMockCharger(ctrl)
+ phaseCharger := api.NewMockPhaseSwitcher(ctrl)
+ lp.charger = struct {
+ *api.MockCharger
+ *api.MockPhaseSwitcher
+ }{plainCharger, phaseCharger}
+
+ plainCharger.EXPECT().Enabled().Return(true, nil).AnyTimes()
+ plainCharger.EXPECT().Enable(gomock.Any()).Return(nil).AnyTimes()
+ plainCharger.EXPECT().MaxCurrent(gomock.Any()).Return(nil).AnyTimes()
+
+ circuit := api.NewMockCircuit(ctrl)
+ lp.circuit = circuit
+ circuit.EXPECT().GetMaxPower().Return(0.0).AnyTimes()
+ circuit.EXPECT().ValidatePower(gomock.Any(), gomock.Any()).DoAndReturn(func(_, new float64) float64 { return new }).AnyTimes()
+ circuit.EXPECT().ValidateCurrent(gomock.Any(), gomock.Any()).DoAndReturn(func(_, new float64) float64 { return new }).AnyTimes()
+
+ s := &types.Suggestion{Action: actionCharge, Charge: charge, Grid: charge}
+
+ handled, err := lp.optimizerCharging(s)
+ assert.True(t, handled)
+ assert.NoError(t, err)
+ started := lp.phaseTimer
+ assert.False(t, started.IsZero(), "scale up timer must be running")
+
+ // next cycle keeps the timer
+ clck.Add(time.Minute)
+ _, err = lp.optimizerCharging(s)
+ assert.NoError(t, err)
+ assert.Equal(t, started, lp.phaseTimer, "scale up timer must not restart")
+
+ // delay elapsed
+ clck.Add(lp.Enable.Delay)
+ phaseCharger.EXPECT().Phases1p3p(3).Return(nil)
+ _, err = lp.optimizerCharging(s)
+ assert.NoError(t, err)
+ assert.Equal(t, 3, lp.GetPhases())
+ })
+ }
+}
+
+func TestOptimizerPlanClipsAtPlanTime(t *testing.T) {
+ lp := &Loadpoint{
+ log: util.NewLogger("foo"),
+ clock: clock.NewMock(),
+ vehicle: modelledVehicle(gomock.NewController(t)),
+ site: &mockSite{automatic: true},
+ }
+
+ now := lp.clock.Now()
+ slot := func(i int) api.Rate {
+ return api.Rate{Start: now.Add(time.Duration(i) * 15 * time.Minute), End: now.Add(time.Duration(i+1) * 15 * time.Minute)}
+ }
+
+ // two plan slots, one surplus slot after the plan time
+ lp.setSuggestion(&types.Suggestion{Action: actionCharge})
+ lp.setOptimizerPlan(optimizerPlan{
+ rates: api.Rates{slot(0), slot(1), slot(8)},
+ energy: []float64{500, 250, 1000},
+ })
+
+ plan, power := lp.OptimizerPlan(now.Add(time.Hour))
+ assert.Equal(t, api.Rates{slot(0), slot(1)}, plan)
+ assert.InDelta(t, 1500, power, 1e-6)
+
+ // plan time inside the second slot: 500Wh plus a third of 250Wh in 20 minutes
+ planTime := now.Add(20 * time.Minute)
+ plan, power = lp.OptimizerPlan(planTime)
+ assert.Equal(t, api.Rates{slot(0), {Start: slot(1).Start, End: planTime}}, plan)
+ assert.InDelta(t, 1750, power, 1e-6)
+
+ plan, _ = lp.OptimizerPlan(now)
+ assert.Nil(t, plan)
+
+ // 20 minutes later the first slot has ended and the second is half done
+ lp.clock.(*clock.Mock).Add(20 * time.Minute)
+ plan, power = lp.OptimizerPlan(now.Add(time.Hour))
+ assert.Equal(t, api.Rates{{Start: lp.clock.Now(), End: slot(1).End}}, plan)
+ assert.InDelta(t, 1000, power, 1e-6)
+}
+
+// TestOptimizerPlanActive covers the optimizer's schedule replacing the planner:
+// the plan is active during its slots and once the deadline is critical, the
+// planner's continuation rules don't apply
+func TestOptimizerPlanActive(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ clck := clock.NewMock()
+
+ Voltage = 230
+
+ lp := &Loadpoint{
+ log: util.NewLogger("foo"),
+ clock: clck,
+ charger: api.NewMockCharger(ctrl),
+ pushChan: make(chan messenger.Event, 1),
+ vehicle: modelledVehicle(ctrl),
+ site: &mockSite{automatic: true},
+ status: api.StatusC,
+ phases: 1,
+ minCurrent: minA,
+ maxCurrent: maxA,
+ vehicleSoc: 50,
+ }
+
+ now := clck.Now()
+ planTime := now.Add(4 * time.Hour)
+ slot := func(i int) api.Rate {
+ return api.Rate{Start: now.Add(time.Duration(i) * 15 * time.Minute), End: now.Add(time.Duration(i+1) * 15 * time.Minute)}
+ }
+ optimize := func() {
+ // plan of 5kWh, 1.4h at 3680W- the lock is dropped whenever the plan goes inactive
+ lp.planLocked = PlanLock{Time: planTime, Soc: 60, Id: 1}
+ lp.setSuggestion(&types.Suggestion{Action: actionCharge})
+ lp.setOptimizerPlan(optimizerPlan{
+ rates: api.Rates{slot(0), slot(4), slot(14)},
+ energy: []float64{920, 920, 920},
+ })
+ }
+
+ // goal reached: nothing to plan
+ optimize()
+ plan, _, optimized := lp.EffectivePlan(planTime, 0, api.PlanStrategy{})
+ assert.Nil(t, plan)
+ assert.False(t, optimized)
+
+ // first slot
+ assert.True(t, lp.plannerActive())
+
+ // between slots the planner would avoid the restart
+ clck.Add(20 * time.Minute)
+ optimize()
+ assert.False(t, lp.plannerActive())
+
+ // second slot
+ clck.Add(45 * time.Minute)
+ optimize()
+ assert.True(t, lp.plannerActive())
+
+ // deadline critical ahead of the last slot
+ clck.Add(2 * time.Hour)
+ optimize()
+ assert.True(t, lp.plannerActive())
+
+ // stalled optimizer falls back to the planner
+ clck.Add(time.Hour)
+ _, _, optimized = lp.EffectivePlan(planTime, time.Hour, api.PlanStrategy{})
+ assert.False(t, optimized)
+}
diff --git a/core/optimizer_schedule_test.go b/core/optimizer_schedule_test.go
index c05fb8f7dd..455648509c 100644
--- a/core/optimizer_schedule_test.go
+++ b/core/optimizer_schedule_test.go
@@ -99,6 +99,7 @@ func TestApplyOptimizerResultSchedule(t *testing.T) {
require.True(t, ok)
assert.Equal(t, api.BatteryCharge.String(), s.Action)
assert.InDelta(t, tc.power, s.Charge, 1e-3)
+ assert.InDelta(t, tc.power, s.Grid, 1e-3)
require.NotNil(t, site.battery.Forecast)
require.NotNil(t, site.battery.Forecast.Highest)
assert.Equal(t, tc.highest, site.battery.Forecast.Highest.Time)
@@ -115,3 +116,25 @@ func TestApplyOptimizerResultSchedule(t *testing.T) {
})
}
}
+
+func TestLoadpointPlan(t *testing.T) {
+ now := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
+ schedule := optimizerSchedule{
+ timestamps: []time.Time{now.Add(-15 * time.Minute), now, now.Add(15 * time.Minute), now.Add(30 * time.Minute)},
+ dt: []int{900, 900, 900, 900},
+ }
+
+ // expired slot, charging, noise, charging
+ res := optimizer.BatteryResult{ChargingPower: []float32{1000, 500, 5, 250}}
+ prices := []float32{0.1e-3, 0.2e-3, 0.3e-3, 0.4e-3}
+
+ plan := loadpointPlan(res, prices, schedule, now)
+
+ require.Len(t, plan.rates, 2)
+ assert.Equal(t, now, plan.rates[0].Start)
+ assert.Equal(t, now.Add(15*time.Minute), plan.rates[0].End)
+ assert.InDelta(t, 0.2, plan.rates[0].Value, 1e-6)
+ assert.Equal(t, now.Add(30*time.Minute), plan.rates[1].Start)
+ assert.InDelta(t, 0.4, plan.rates[1].Value, 1e-6)
+ assert.Equal(t, []float64{500, 250}, plan.energy)
+}
diff --git a/core/site.go b/core/site.go
index 64534ec9c1..26043e43c8 100644
--- a/core/site.go
+++ b/core/site.go
@@ -467,7 +467,7 @@ func (site *Site) restoreSettings() error {
}
}
if v, err := settings.Bool(keys.BatteryDischargeControl); err == nil {
- if err := site.SetBatteryDischargeControl(v); err != nil && !errors.Is(err, ErrBatteryControlNotAvailable) {
+ if err := site.setBatteryDischargeControl(v); err != nil && !errors.Is(err, ErrBatteryControlNotAvailable) {
return err
}
}
@@ -482,7 +482,7 @@ func (site *Site) restoreSettings() error {
}
}
if v, err := settings.Float(keys.BatteryGridChargeLimit); err == nil {
- if err := site.SetBatteryGridChargeLimit(&v); err != nil && !errors.Is(err, ErrBatteryControlNotAvailable) {
+ if err := site.setBatteryGridChargeLimit(&v); err != nil && !errors.Is(err, ErrBatteryControlNotAvailable) {
return err
}
}
@@ -1101,6 +1101,38 @@ func optimizerEnabled() bool {
return exp && opt
}
+// optimizer automatic levels: what the optimizer controls instead of only advising
+const (
+ OptimizerAutomaticOff = "off"
+ OptimizerAutomaticBattery = "battery" // home battery only
+ OptimizerAutomaticFull = "full" // home battery and loadpoints
+)
+
+var OptimizerAutomaticLevels = []string{OptimizerAutomaticOff, OptimizerAutomaticBattery, OptimizerAutomaticFull}
+
+// OptimizerAutomatic returns the configured optimizer automatic level
+func OptimizerAutomatic() string {
+ switch v, _ := settings.String(keys.OptimizerAutomatic); v {
+ case OptimizerAutomaticBattery, OptimizerAutomaticFull:
+ return v
+ case "true", "1":
+ // stored by the boolean switch before the levels existed
+ return OptimizerAutomaticFull
+ default:
+ return OptimizerAutomaticOff
+ }
+}
+
+// Automatic returns true if the optimizer controls the home battery instead of only advising
+func (site *Site) Automatic() bool {
+ return OptimizerAutomatic() != OptimizerAutomaticOff && optimizerEnabled() && sponsor.IsAuthorized()
+}
+
+// AutomaticLoadpoints returns true if the optimizer controls the loadpoints as well
+func (site *Site) AutomaticLoadpoints() bool {
+ return OptimizerAutomatic() == OptimizerAutomaticFull && site.Automatic()
+}
+
// sitePowerResult is the outcome of the site power calculation
type sitePowerResult struct {
// measured state, including the estimates for missing meters
@@ -1271,7 +1303,6 @@ func (site *Site) update(lp updater) {
// don't resurrect the pre-disable solve on re-enable
site.setLastOptimizerSolve(nil)
}
- go site.optimizerUpdateAsync(tariff.SlotDuration)
site.updatePower(lp, state, totalChargePower, consumption, feedin)
}
@@ -1509,6 +1540,9 @@ func (site *Site) loopLoadpoints(next chan<- updater) {
active := site.activeLoadpoints()
for {
+ // optimizer runs on its own cadence, checked once per loadpoint cycle
+ go site.optimizerUpdateAsync(false)
+
if len(active) == 0 {
logOnce.Do(func() {
site.log.INFO.Println("no loadpoints configured, running in meter-only mode")
diff --git a/core/site/api.go b/core/site/api.go
index 874bec682e..f309425e2d 100644
--- a/core/site/api.go
+++ b/core/site/api.go
@@ -20,6 +20,10 @@ type API interface {
ActiveLoadpoints() iter.Seq2[int, loadpoint.API]
Vehicles() Vehicles
Optimize()
+ // Automatic returns true if the optimizer controls the home battery instead of only advising
+ Automatic() bool
+ // AutomaticLoadpoints returns true if the optimizer controls the loadpoints as well
+ AutomaticLoadpoints() bool
// Meta
GetTitle() string
diff --git a/core/site_api.go b/core/site_api.go
index 6511ed50f6..1c1456fa02 100644
--- a/core/site_api.go
+++ b/core/site_api.go
@@ -23,6 +23,9 @@ var (
ErrBatteryNotConfigured = errors.New("battery not configured")
ErrBatteryControlNotAvailable = errors.New("battery control not available")
ErrBatteryGridDischargeNotAvailable = errors.New("battery grid discharge not available")
+
+ // ErrOptimizerAutomatic marks settings the optimizer decides on its own
+ ErrOptimizerAutomatic = errors.New("not available in automatic mode")
)
// filterConfigurableDevices filters references to configurable devices of the given handler
@@ -46,7 +49,7 @@ func filterConfigurableCurtailers(ref []string) []string {
// Optimize updates the optimizer
func (site *Site) Optimize() {
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
}
// GetTitle returns the title
@@ -423,7 +426,7 @@ func (site *Site) SetGridExportLimit(power float64) error {
site.publish(keys.GridExportLimit, power)
// re-run the optimizer so the new limit takes effect immediately
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
}
return nil
@@ -453,7 +456,7 @@ func (site *Site) SetProfilePercentile(percentile *float64) error {
site.publish(keys.ProfilePercentile, percentile)
// re-run the optimizer so the new profile takes effect immediately
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
return nil
}
@@ -465,8 +468,13 @@ func (site *Site) GetTariff(tariff api.TariffUsage) api.Tariff {
return site.tariffs.Get(tariff)
}
-// GetBatteryDischargeControl returns the battery control mode (no discharge only)
+// GetBatteryDischargeControl returns the battery control mode (no discharge only).
+// The optimizer replaces it in automatic mode, so it reads as disabled.
func (site *Site) GetBatteryDischargeControl() bool {
+ if site.Automatic() {
+ return false
+ }
+
site.RLock()
defer site.RUnlock()
return site.batteryDischargeControl
@@ -474,6 +482,14 @@ func (site *Site) GetBatteryDischargeControl() bool {
// SetBatteryDischargeControl sets the battery control mode (no discharge only)
func (site *Site) SetBatteryDischargeControl(val bool) error {
+ if site.Automatic() {
+ return ErrOptimizerAutomatic
+ }
+
+ return site.setBatteryDischargeControl(val)
+}
+
+func (site *Site) setBatteryDischargeControl(val bool) error {
site.log.DEBUG.Println("set battery discharge control:", val)
if !site.hasBatteryControl() {
@@ -518,7 +534,7 @@ func (site *Site) SetBatteryGridDischarge(val bool) error {
if changed {
// re-run the optimizer so the new discharge mode takes effect immediately
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
// drop the limit, it is meaningless without the opt-in
if !val {
@@ -549,17 +565,30 @@ func (site *Site) SetSolarAdjusted(val bool) {
site.publish(keys.SolarAdjusted, val)
// re-run the optimizer so the adjusted forecast takes effect immediately
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
}
}
+// The optimizer replaces the limit in automatic mode, so it reads as unset.
func (site *Site) GetBatteryGridChargeLimit() *float64 {
+ if site.Automatic() {
+ return nil
+ }
+
site.RLock()
defer site.RUnlock()
return site.batteryGridChargeLimit
}
func (site *Site) SetBatteryGridChargeLimit(val *float64) error {
+ if site.Automatic() {
+ return ErrOptimizerAutomatic
+ }
+
+ return site.setBatteryGridChargeLimit(val)
+}
+
+func (site *Site) setBatteryGridChargeLimit(val *float64) error {
site.log.DEBUG.Println("set grid charge limit:", printPtr("%.3f", val))
if !site.hasBatteryControl() {
@@ -581,7 +610,7 @@ func (site *Site) SetBatteryGridChargeLimit(val *float64) error {
}
// re-run the optimizer so the new limit takes effect immediately
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
}
return nil
@@ -654,7 +683,7 @@ func (site *Site) SetOptimizerChargingStrategy(strategy string) error {
site.publish(keys.OptimizerChargingStrategy, strategy)
// re-run the optimizer so the new strategy takes effect immediately
- go site.optimizerUpdateAsync(0)
+ go site.optimizerUpdateAsync(true)
}
return nil
diff --git a/core/site_battery.go b/core/site_battery.go
index 84008c9396..ccc5152f00 100644
--- a/core/site_battery.go
+++ b/core/site_battery.go
@@ -116,6 +116,17 @@ func (site *Site) requiredBatteryMode(batteryGridChargeActive, batteryGridDischa
if extMode != batMode {
res = extMode
}
+ case site.Automatic() && site.unmodelledCharging():
+ // the suggestion ignores loads the optimizer cannot model as storage
+ res = keepUnlessModified(api.BatteryHold)
+ case site.Automatic():
+ // optimizer decides, replacing grid charge limit and discharge control
+ if mode, ok := site.batterySuggestionMode(); ok {
+ res = keepUnlessModified(mode)
+ } else if batteryModeModified(batMode) {
+ // no suggestion: release the battery
+ res = api.BatteryNormal
+ }
case batteryGridChargeActive:
// independent limits (buy vs feed-in rate) can both be active at once;
// charge wins to avoid buying and immediately selling
@@ -136,6 +147,50 @@ func (site *Site) requiredBatteryMode(batteryGridChargeActive, batteryGridDischa
return res
}
+// unmodelledCharging reports a loadpoint charging at full power that the optimizer
+// does not control: it cannot model it as storage (unknown vehicle capacity, see
+// optimizerRequest) or only the battery is automatic. Its battery suggestion does
+// not account for that load, so the battery must be held.
+func (site *Site) unmodelledCharging() bool {
+ for _, lp := range site.activeLoadpoints() {
+ if lp.optimizerControlled() {
+ continue
+ }
+
+ if lp.GetStatus() == api.StatusC && lp.IsFastChargingActive() {
+ return true
+ }
+ }
+
+ return false
+}
+
+// batterySuggestionMode returns the optimizer's mode for the first controllable battery.
+// TODO apply per battery once the site tracks more than a single battery mode
+func (site *Site) batterySuggestionMode() (api.BatteryMode, bool) {
+ for _, dev := range site.batteryMeters {
+ if dev == nil {
+ continue
+ }
+
+ s := site.suggestion(batteryKey(dev.Config().Name), site.GetBatteryMode().String())
+ if s == nil {
+ continue
+ }
+
+ mode, err := api.BatteryModeString(s.Action)
+ if err != nil {
+ // unknown action, release the battery
+ site.log.DEBUG.Printf("battery %s: cannot apply suggestion %s", deviceTitleOrName(dev), s.Action)
+ return api.BatteryNormal, true
+ }
+
+ return mode, true
+ }
+
+ return api.BatteryUnknown, false
+}
+
// batterySocLimitReached reports whether the battery has reached the soc bound
// that should stop the requested mode: the max soc when charging, or the min
// soc reserve when discharging to grid. A configured limit of 0 disables the
diff --git a/core/site_optimizer.go b/core/site_optimizer.go
index dd0fa05c2d..b5bbcf4e03 100644
--- a/core/site_optimizer.go
+++ b/core/site_optimizer.go
@@ -131,7 +131,7 @@ type batteryResult struct {
Empty time.Time `json:"empty,omitzero"`
}
-// suggestionThreshold ignores numerical noise around zero power (W)
+// suggestionThreshold ignores numerical noise in power comparisons (W)
const suggestionThreshold = 50
// advisory actions for a loadpoint/vehicle slot; battery actions use api.BatteryMode
@@ -172,27 +172,42 @@ func suggestionEvent(detail batteryDetail, s types.Suggestion) messenger.Event {
// Because the optimization is linear, the slot is at an operating-range extreme, so it
// maps cleanly onto the discrete battery mode / loadpoint intent that control would later apply.
// An idle battery is interpreted from the grid flow: importing means discharge is withheld
-// (hold), exporting means charging is withheld (holdcharge).
-func currentSlotSuggestion(detail batteryDetail, res optimizer.BatteryResult, slot int, gridImporting, gridExporting bool, slotHours float64) types.Suggestion {
+// (hold), exporting means charging is withheld (holdcharge). At its soc bound nothing is
+// withheld, the bound does it, so the battery stays in normal mode.
+func currentSlotSuggestion(detail batteryDetail, req optimizer.BatteryConfig, res optimizer.BatteryResult, slot int, gridImport, gridExport float32, slotHours float64) types.Suggestion {
if slot < 0 || slotHours <= 0 || slot >= len(res.ChargingPower) || slot >= len(res.DischargingPower) {
return types.Suggestion{}
}
charge := float64(res.ChargingPower[slot]) / slotHours
discharge := float64(res.DischargingPower[slot]) / slotHours
+ gridImporting := gridImport > 0
+ gridExporting := gridExport > 0
- s := types.Suggestion{Charge: charge, Discharge: discharge}
+ s := types.Suggestion{
+ Charge: charge,
+ Discharge: discharge,
+ Grid: float64(gridImport-gridExport) / slotHours,
+ }
if detail.Type == batteryTypeBattery {
idle := charge <= suggestionThreshold && discharge <= suggestionThreshold
+
+ var empty, full bool
+ if slot < len(res.StateOfCharge) {
+ soc := res.StateOfCharge[slot]
+ empty = soc <= req.SMin
+ full = req.SMax > 0 && soc >= req.SMax
+ }
+
switch {
case charge > suggestionThreshold && gridImporting:
// charging while importing means grid charging
s.Action = api.BatteryCharge.String()
- case idle && gridImporting:
+ case idle && gridImporting && !empty:
// idle while importing: discharge is deliberately withheld
s.Action = api.BatteryHold.String()
- case idle && gridExporting:
+ case idle && gridExporting && !full:
// idle while exporting: surplus is exported instead of charged
s.Action = api.BatteryHoldCharge.String()
case discharge > suggestionThreshold && gridExporting:
@@ -225,6 +240,41 @@ func loadpointCurrentAction(lp *Loadpoint) string {
return actionStop
}
+// suggestionMaxAge releases a loadpoint gated by a solve the site stopped
+// refreshing; the site itself expires its cached solve in reapplySuggestions
+const suggestionMaxAge = 2 * tariff.SlotDuration
+
+// optimizerPlan is a loadpoint's charging schedule from the last solve
+type optimizerPlan struct {
+ rates api.Rates // charging slots valued at the grid import price
+ energy []float64 // charge energy per slot, Wh
+}
+
+// loadpointPlan extracts the remaining charging slots of a battery result
+func loadpointPlan(res optimizer.BatteryResult, prices []float32, schedule optimizerSchedule, now time.Time) optimizerPlan {
+ var plan optimizerPlan
+
+ for slot := range schedule.endsAfter(now) {
+ if slot >= len(res.ChargingPower) || slot >= len(prices) {
+ break
+ }
+
+ energy := float64(res.ChargingPower[slot])
+ if energy/schedule.duration(slot).Hours() <= suggestionThreshold {
+ continue
+ }
+
+ plan.energy = append(plan.energy, energy)
+ plan.rates = append(plan.rates, api.Rate{
+ Start: schedule.timestamps[slot],
+ End: schedule.end(slot),
+ Value: float64(prices[slot]) * 1e3, // per Wh to per kWh
+ })
+ }
+
+ return plan
+}
+
// setSuggestions replaces the suggestions applied on each publish
func (site *Site) setSuggestions(suggestions map[string]types.Suggestion) {
site.Lock()
@@ -258,18 +308,23 @@ func (site *Site) suggestion(key, currentAction string) *types.Suggestion {
return &s
}
-// publishSuggestions publishes the loadpoints' suggestions
+// publishSuggestions publishes the loadpoints' suggestions and hands them to the
+// loadpoints, where they act as start/stop gate while the optimizer is in control
func (site *Site) publishSuggestions() {
for id, lp := range site.loadpoints {
if lp == nil {
continue
}
+ s := site.suggestion(loadpointKey(id), loadpointCurrentAction(lp))
+
var val any
- if s := site.suggestion(loadpointKey(id), loadpointCurrentAction(lp)); s != nil {
+ if s != nil {
val = *s
}
site.publishLoadpoint(id, keys.Suggestion, val)
+
+ lp.setSuggestion(s)
}
}
@@ -377,12 +432,17 @@ const slotsPerHour = float64(time.Hour / tariff.SlotDuration)
// startup); the slot gate is left open so the next cycle retries.
var errOptimizerNotReady = errors.New("battery measurements not ready")
+// optimizerInterval is the refresh cadence in automatic mode. It divides the
+// slot duration so every slot starts on a fresh result. Advisory suggestions
+// only need one run per slot.
+const optimizerInterval = 5 * time.Minute
+
// optimizerUpdateAsync runs the optimizer unless the last run is younger than
-// minAge. Pass 0 to force a run, e.g. when a changed setting should take effect
-// without waiting for the next slot. It is a no-op when the optimizer is not
+// the mode's interval. Pass force to run regardless, e.g. when a changed setting
+// should take effect immediately. It is a no-op when the optimizer is not
// active or a run is already in progress; the running update reflects the
-// change on its next slot.
-func (site *Site) optimizerUpdateAsync(minAge time.Duration) {
+// change on its next run.
+func (site *Site) optimizerUpdateAsync(force bool) {
if !sponsor.IsAuthorized() || !optimizerEnabled() {
return
}
@@ -392,10 +452,15 @@ func (site *Site) optimizerUpdateAsync(minAge time.Duration) {
}
defer site.optimizerMu.Unlock()
- if minAge == 0 {
+ interval := tariff.SlotDuration
+ if site.Automatic() {
+ interval = optimizerInterval
+ }
+
+ if force {
// keep the gate open so a not-ready run is retried on the next cycle
site.optimizerUpdated = time.Time{}
- } else if time.Since(site.optimizerUpdated) < minAge {
+ } else if time.Since(site.optimizerUpdated) < interval {
return
}
@@ -413,11 +478,10 @@ func (site *Site) optimizerUpdateAsync(minAge time.Duration) {
site.optimizerUpdated = time.Now()
+ // a failed run keeps the advice: dropping it would release the controlled
+ // devices for one cycle. reapplySuggestions expires the cached solve.
if err != nil {
site.log.ERROR.Println("optimizer:", err)
-
- // stale advice must not linger
- site.clearSuggestions()
}
}()
@@ -739,8 +803,13 @@ func (site *Site) reapplySuggestions(now time.Time) {
func (site *Site) applyOptimizerResult(req optimizer.OptimizationInput, details requestDetails, res optimizer.OptimizationResult, schedule optimizerSchedule, now time.Time, completed time.Time) {
slot := schedule.activeSlot(now)
slotHours := schedule.duration(slot).Hours()
- gridImporting := slot >= 0 && slot < len(res.GridImport) && res.GridImport[slot] > 0
- gridExporting := slot >= 0 && slot < len(res.GridExport) && res.GridExport[slot] > 0
+ var gridImport, gridExport float32
+ if slot >= 0 && slot < len(res.GridImport) {
+ gridImport = res.GridImport[slot]
+ }
+ if slot >= 0 && slot < len(res.GridExport) {
+ gridExport = res.GridExport[slot]
+ }
var batteries []batteryResult
suggestions := make(map[string]types.Suggestion, len(req.Batteries))
@@ -759,7 +828,7 @@ func (site *Site) applyOptimizerResult(req optimizer.OptimizationInput, details
}),
})
- suggestion := currentSlotSuggestion(detail, batRes, slot, gridImporting, gridExporting, slotHours)
+ suggestion := currentSlotSuggestion(detail, batReq, batRes, slot, gridImport, gridExport, slotHours)
if suggestion.Action == "" {
continue
}
@@ -767,6 +836,16 @@ func (site *Site) applyOptimizerResult(req optimizer.OptimizationInput, details
// uncontrollable devices can't act on a suggestion
if key := detail.key(); key != "" && detail.controllable {
suggestions[key] = suggestion
+
+ // the schedule is gated by the suggestion, so it needs no clearing.
+ // Without the plan goal in the model its slots are no plan.
+ if detail.loadpoint != nil {
+ var plan optimizerPlan
+ if batReq.SGoal != nil {
+ plan = loadpointPlan(batRes, req.TimeSeries.PN, schedule, now)
+ }
+ site.loadpoints[*detail.loadpoint].setOptimizerPlan(plan)
+ }
}
}
diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go
index d14afd572b..276ee898de 100644
--- a/core/site_optimizer_test.go
+++ b/core/site_optimizer_test.go
@@ -659,44 +659,60 @@ func TestBlendMeasured(t *testing.T) {
func TestCurrentSlotSuggestion(t *testing.T) {
// slotHours 1 makes the per-slot Wh values map 1:1 to W
for _, tc := range []struct {
- name string
- typ batteryType
- charge, disch float32
- importing, export bool
- want string
+ name string
+ typ batteryType
+ charge, disch float32
+ gridImp, gridExp float32
+ want string
}{
- {"battery grid charge", batteryTypeBattery, 3000, 0, true, false, "charge"},
- {"battery pv charge (no import)", batteryTypeBattery, 3000, 0, false, true, "normal"},
- {"battery hold (idle while importing)", batteryTypeBattery, 0, 0, true, false, "hold"},
- {"battery holdcharge (idle while exporting)", batteryTypeBattery, 0, 0, false, true, "holdcharge"},
- {"battery discharge (self-consumption while importing)", batteryTypeBattery, 0, 2000, true, false, "normal"},
- {"battery grid discharge (discharge while exporting)", batteryTypeBattery, 0, 2000, false, true, "discharge"},
- {"battery idle balanced", batteryTypeBattery, 0, 0, false, false, "normal"},
- {"loadpoint charge", batteryTypeLoadpoint, 11000, 0, false, false, "charge"},
- {"loadpoint stop", batteryTypeLoadpoint, 0, 0, false, false, "stop"},
- {"vehicle below threshold is stop", batteryTypeVehicle, 40, 0, false, false, "stop"},
+ {"battery grid charge", batteryTypeBattery, 3000, 0, 1000, 0, "charge"},
+ {"battery pv charge (no import)", batteryTypeBattery, 3000, 0, 0, 1000, "normal"},
+ {"battery hold (idle while importing)", batteryTypeBattery, 0, 0, 1000, 0, "hold"},
+ {"battery holdcharge (idle while exporting)", batteryTypeBattery, 0, 0, 0, 1000, "holdcharge"},
+ {"battery discharge (self-consumption while importing)", batteryTypeBattery, 0, 2000, 1000, 0, "normal"},
+ {"battery grid discharge (discharge while exporting)", batteryTypeBattery, 0, 2000, 0, 1000, "discharge"},
+ {"battery idle balanced", batteryTypeBattery, 0, 0, 0, 0, "normal"},
+ {"loadpoint charge", batteryTypeLoadpoint, 11000, 0, 0, 0, "charge"},
+ {"loadpoint stop", batteryTypeLoadpoint, 0, 0, 0, 0, "stop"},
+ {"vehicle below threshold is stop", batteryTypeVehicle, 40, 0, 0, 0, "stop"},
} {
t.Run(tc.name, func(t *testing.T) {
res := optimizer.BatteryResult{
ChargingPower: []float32{tc.charge},
DischargingPower: []float32{tc.disch},
}
- s := currentSlotSuggestion(batteryDetail{Type: tc.typ}, res, 0, tc.importing, tc.export, 1)
+ s := currentSlotSuggestion(batteryDetail{Type: tc.typ}, optimizer.BatteryConfig{}, res, 0, tc.gridImp, tc.gridExp, 1)
assert.Equal(t, tc.want, s.Action)
assert.InDelta(t, tc.charge, s.Charge, 1e-3)
assert.InDelta(t, tc.disch, s.Discharge, 1e-3)
+ assert.InDelta(t, tc.gridImp-tc.gridExp, s.Grid, 1e-3)
})
}
// no result yields an empty suggestion
- assert.Empty(t, currentSlotSuggestion(batteryDetail{Type: batteryTypeBattery}, optimizer.BatteryResult{}, 0, true, false, 1))
+ assert.Empty(t, currentSlotSuggestion(batteryDetail{Type: batteryTypeBattery}, optimizer.BatteryConfig{}, optimizer.BatteryResult{}, 0, 1000, 0, 1))
res := optimizer.BatteryResult{
ChargingPower: []float32{100, 0},
DischargingPower: []float32{0, 0},
}
- s := currentSlotSuggestion(batteryDetail{Type: batteryTypeBattery}, res, 1, true, false, 1)
+ s := currentSlotSuggestion(batteryDetail{Type: batteryTypeBattery}, optimizer.BatteryConfig{}, res, 1, 1000, 0, 1)
assert.Equal(t, api.BatteryHold.String(), s.Action)
+
+ // an idle battery at its soc bound withholds nothing: normal instead of hold/holdcharge
+ battery := batteryDetail{Type: batteryTypeBattery}
+ req := optimizer.BatteryConfig{SMin: 1000, SMax: 9000}
+ res = optimizer.BatteryResult{
+ ChargingPower: []float32{0},
+ DischargingPower: []float32{0},
+ StateOfCharge: []float32{1000},
+ }
+ assert.Equal(t, api.BatteryNormal.String(), currentSlotSuggestion(battery, req, res, 0, 1000, 0, 1).Action, "empty battery idle while importing")
+ res.StateOfCharge[0] = 9000
+ assert.Equal(t, api.BatteryNormal.String(), currentSlotSuggestion(battery, req, res, 0, 0, 1000, 1).Action, "full battery idle while exporting")
+ res.StateOfCharge[0] = 5000
+ assert.Equal(t, api.BatteryHold.String(), currentSlotSuggestion(battery, req, res, 0, 1000, 0, 1).Action, "idle within bounds is withheld")
+ assert.Equal(t, api.BatteryHoldCharge.String(), currentSlotSuggestion(battery, req, res, 0, 0, 1000, 1).Action, "idle within bounds is withheld")
}
// TestSuggestionActionable ensures the actionable flag follows the current state
diff --git a/core/types/types.go b/core/types/types.go
index f3b254861c..c7959556dd 100644
--- a/core/types/types.go
+++ b/core/types/types.go
@@ -30,6 +30,7 @@ type Suggestion struct {
Action string `json:"action"`
Charge float64 `json:"charge"` // recommended charge power, W
Discharge float64 `json:"discharge"` // recommended discharge power, W
+ Grid float64 `json:"grid"` // planned site grid flow, positive means import, W
// Actionable indicates the suggestion differs from the current operating mode.
Actionable bool `json:"actionable"`
}
diff --git a/i18n/de.json b/i18n/de.json
index 21f690f43f..ab53b77cbd 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -62,6 +62,7 @@
"noCountry": "Kein Land konfiguriert. Lege es unter {link} fest.",
"title": "Netzeinspeisung aus der Batterie"
},
+ "optimizerControlledHint": "Für {loadpoints} deaktiviert. Der {optimizer} entscheidet stattdessen.",
"priority": "Hausbatterie hat Vorrang, solange sie unter {soc} liegt. Dann erhalten die Ladepunkte den Überschuss.",
"priorityNone": "Sonnenüberschuss geht direkt an die Ladepunkte. Setze einen Wert über {soc}, um die Hausbatterie zu bevorzugen.",
"priorityTitle": "Wohin geht der Überschuss zuerst?"
@@ -840,7 +841,27 @@
"usernameHelp": "Für HTTP-Basic-Auth am Upstream-Server."
},
"optimizer": {
- "description": "Analysiert Solarprognose, Strompreise und deinen typischen Verbrauch, um Batterie- und Ladestrategie zu optimieren. Daten werden zur Berechnung an den evcc Optimierungsdienst übertragen. Berechnet und visualisiert aktuell nur. Steuert noch keine Geräte.",
+ "automatic": "Automatische Steuerung",
+ "automaticLevel": {
+ "battery": {
+ "description": "Steuert nur die Hausbatterie.",
+ "label": "Hausbatterie",
+ "status": "{automatic} steuert die Hausbatterie."
+ },
+ "full": {
+ "description": "Steuert die Hausbatterie, startet und stoppt das Laden der Fahrzeuge im Smart-Modus, ersetzt Preis- und CO₂-Grenzen und übernimmt Ladepläne. Unverändert: Aus- und Schnellmodus, Heizgeräte, schaltbare Steckdosen, Fahrzeuge mit unbekannter Batteriekapazität.",
+ "label": "Hausbatterie & Ladepunkte",
+ "status": "{automatic} steuert Hausbatterie und Ladepunkte."
+ },
+ "off": {
+ "description": "Der Optimizer schlägt nur vor. Nichts wird gesteuert.",
+ "status": "{automatic} ist aus. Der Optimizer schlägt nur vor."
+ }
+ },
+ "controlled": "Durch automatische Steuerung deaktiviert. Der {optimizer} entscheidet stattdessen.",
+ "controls": "Steuert",
+ "linkWord": "Optimizer",
+ "description": "Nutzt Solarprognose, Strompreise und deinen typischen Verbrauch, um Batterie und Laden zu planen. Daten werden an den evcc Optimierungsdienst übertragen.",
"enable": "Optimizer aktivieren",
"info": "Bei neuen Installationen kann es bis zu 24 Stunden dauern, bis evcc genügend Daten gesammelt hat.",
"title": "Optimizer"
@@ -1712,6 +1733,7 @@
"targetChargePlan": {
"chargeDuration": "Ladedauer",
"co2Label": "CO₂-Emission ⌀",
+ "optimizerHint": "Hinweis: Dieser Plan wurde vom {optimizer} erstellt.",
"priceLabel": "Energiepreis",
"timeRange": "{day} {range} Uhr",
"unknownPrice": "noch unbekannt"
@@ -1769,6 +1791,7 @@
"feedinPriorityPausing": "Solarladen pausiert, um die Einspeisung zu maximieren.",
"finished": "Abgeschlossen.",
"minCharge": "Mindestladung bis {soc}.",
+ "optimizerControlledTooltip": "Automatische Steuerung: Der Optimizer startet und stoppt das Laden.",
"pvDisable": "Zu wenig Überschuss. Ladung wird gleich pausiert.",
"pvEnable": "Überschuss verfügbar. Ladung wird gleich fortgesetzt.",
"scale1p": "Reduziere gleich auf 1-phasiges Laden.",
diff --git a/i18n/en.json b/i18n/en.json
index ef6bbd7c34..f37506a556 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -62,6 +62,7 @@
"noCountry": "No country configured. Set it under {link}.",
"title": "Grid export from battery"
},
+ "optimizerControlledHint": "Disabled for {loadpoints}. The {optimizer} decides instead.",
"priority": "Home battery has priority while it is below {soc}. Then the charging points receive the surplus.",
"priorityNone": "Solar surplus goes straight to the charging points. Set a value above {soc} to prioritize the home battery.",
"priorityTitle": "Where does the surplus go first?"
@@ -840,7 +841,27 @@
"usernameHelp": "For HTTP Basic Auth to the upstream server."
},
"optimizer": {
- "description": "Analyzes solar forecast, electricity prices, and your consumption patterns to optimize battery and charging strategy. Data is sent to the evcc optimization service for processing. Currently only calculates and visualizes. Does not control devices yet.",
+ "automatic": "Automatic control",
+ "automaticLevel": {
+ "battery": {
+ "description": "Only controls the home battery.",
+ "label": "Home battery",
+ "status": "{automatic} controls the home battery."
+ },
+ "full": {
+ "description": "Controls the home battery, starts and stops vehicle charging in smart mode, replaces price and CO₂ limits and takes over plans. Unchanged: off and fast mode, heating devices, switch sockets, vehicles with unknown capacity.",
+ "label": "Home battery & charging points",
+ "status": "{automatic} controls home battery and charging points."
+ },
+ "off": {
+ "description": "The optimizer only suggests. Nothing is controlled.",
+ "status": "{automatic} is off. The optimizer only suggests."
+ }
+ },
+ "controlled": "Disabled by automatic control. The {optimizer} decides instead.",
+ "controls": "Controls",
+ "linkWord": "optimizer",
+ "description": "Uses solar forecast, electricity prices and your consumption patterns to plan battery and charging. Data is sent to the evcc optimization service.",
"enable": "Enable Optimizer",
"info": "For new installations, it may take up to 24 hours until evcc has collected enough data.",
"title": "Optimizer"
@@ -1712,6 +1733,7 @@
"targetChargePlan": {
"chargeDuration": "Charging time",
"co2Label": "CO₂ emission ⌀",
+ "optimizerHint": "Note: This plan was created by the {optimizer}.",
"priceLabel": "Energy price",
"timeRange": "{day} {range} h",
"unknownPrice": "still unknown"
@@ -1769,6 +1791,7 @@
"feedinPriorityPausing": "Solar charging paused to maximize feed-in.",
"finished": "Finished.",
"minCharge": "Minimum charging to {soc}.",
+ "optimizerControlledTooltip": "Automatic control. The optimizer starts and stops charging.",
"pvDisable": "Not enough surplus. Pausing soon.",
"pvEnable": "Surplus available. Starting soon.",
"scale1p": "Reducing to 1-phase charging soon.",
diff --git a/scripts/state-schema/schemas.ts b/scripts/state-schema/schemas.ts
index 3c2f07deea..2d289bb173 100644
--- a/scripts/state-schema/schemas.ts
+++ b/scripts/state-schema/schemas.ts
@@ -10,6 +10,7 @@ export interface StateSchemas {
// enums use SCREAMING_SNAKE in the frontend, schema names follow openapi PascalCase convention
const RENAME: Record
= {
CHARGE_MODE: "ChargeMode",
+ OPTIMIZER_AUTOMATIC: "OptimizerAutomatic",
ALWAYS_CHARGE: "AlwaysCharge",
BATTERY_MODE: "BatteryMode",
CURRENCY: "Currency",
diff --git a/server/http.go b/server/http.go
index a9cf62793f..13b0e9651d 100644
--- a/server/http.go
+++ b/server/http.go
@@ -174,11 +174,11 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
))
// site api
- smartCostLimit := func(lp loadpoint.API, limit *float64) {
- lp.SetSmartCostLimit(limit)
+ smartCostLimit := func(lp loadpoint.API, limit *float64) error {
+ return lp.SetSmartCostLimit(limit)
}
- smartFeedInPriorityLimit := func(lp loadpoint.API, limit *float64) {
- lp.SetSmartFeedInPriorityLimit(limit)
+ smartFeedInPriorityLimit := func(lp loadpoint.API, limit *float64) error {
+ return lp.SetSmartFeedInPriorityLimit(limit)
}
routes := map[string]route{
@@ -265,11 +265,11 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
"enableDelay": {"POST", "/enable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetEnableDelay), lp.GetEnableDelay)},
"disableThreshold": {"POST", "/disable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetDisableThreshold), lp.GetDisableThreshold)},
"disableDelay": {"POST", "/disable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetDisableDelay), lp.GetDisableDelay)},
- "smartCost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
- "smartCostDelete": {"DELETE", "/smartcostlimit", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
+ "smartCost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(lp.SetSmartCostLimit, lp.GetSmartCostLimit)},
+ "smartCostDelete": {"DELETE", "/smartcostlimit", floatPtrHandler(lp.SetSmartCostLimit, lp.GetSmartCostLimit)},
"solarShare": {"POST", "/solarshare/{value:-?[0-9.]+}", floatHandler(pass(lp.SetSolarShare), lp.GetSolarShare)},
- "smartFeedInPriority": {"POST", "/smartfeedinprioritylimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartFeedInPriorityLimit), lp.GetSmartFeedInPriorityLimit)},
- "smartFeedInPriorityDelete": {"DELETE", "/smartfeedinprioritylimit", floatPtrHandler(pass(lp.SetSmartFeedInPriorityLimit), lp.GetSmartFeedInPriorityLimit)},
+ "smartFeedInPriority": {"POST", "/smartfeedinprioritylimit/{value:-?[0-9.]+}", floatPtrHandler(lp.SetSmartFeedInPriorityLimit, lp.GetSmartFeedInPriorityLimit)},
+ "smartFeedInPriorityDelete": {"DELETE", "/smartfeedinprioritylimit", floatPtrHandler(lp.SetSmartFeedInPriorityLimit, lp.GetSmartFeedInPriorityLimit)},
"priority": {"POST", "/priority/{value:[0-9]+}", intHandler(pass(lp.SetPriority), lp.GetPriority)},
"batteryBoost": {"POST", "/batteryboost/{value:[01truefalse]+}", boolHandler(lp.SetBatteryBoost, func() bool { return lp.GetBatteryBoost() > 0 })},
"batteryBoostLimit": {"POST", "/batteryboostlimit/{value:[0-9]+}", intHandler(pass(lp.SetBatteryBoostLimit), lp.GetBatteryBoostLimit)},
@@ -359,6 +359,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
"deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler(pub)},
"experimental": {"POST", "/experimental/{value:[01truefalse]+}", boolHandler(setExperimental(pub), getExperimental)},
"optimizer": {"POST", "/optimizer/{value:[01truefalse]+}", boolHandler(setOptimizer(pub), getOptimizer)},
+ "optimizerAutomatic": {"POST", "/optimizerautomatic/{value:[a-z]+}", stringHandler(setOptimizerAutomatic(pub, site), core.OptimizerAutomatic)},
"remote": {"POST", "/remote/{value:[01truefalse]+}", boolHandler(remoteAccess.Enable, remoteAccess.Enabled)},
"remoteclients": {"GET", "/remote/clients", remoteClientsHandler(remoteAccess)},
"createremoteclient": {"POST", "/remote/clients", createRemoteClientHandler(remoteAccess)},
diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go
index 14344c8c06..34f84d4214 100644
--- a/server/http_config_loadpoint_handler.go
+++ b/server/http_config_loadpoint_handler.go
@@ -232,7 +232,7 @@ func newLoadpointHandler() http.HandlerFunc {
dev := config.NewConfigurableDevice[loadpoint.API](&conf, instance)
if err := dynamic.Apply(instance); err != nil {
conf.Delete()
- jsonError(w, http.StatusBadRequest, err)
+ jsonError(w, errorStatus(err), err)
return
}
@@ -322,7 +322,7 @@ func updateLoadpointHandler() http.HandlerFunc {
// dynamic; instance is nil for a disabled loadpoint, takes effect on next restart
if instance != nil {
if err := dynamic.Apply(instance); err != nil {
- jsonError(w, http.StatusBadRequest, err)
+ jsonError(w, errorStatus(err), err)
return
}
}
diff --git a/server/http_config_site_other_handler.go b/server/http_config_site_other_handler.go
index dfb69b902e..d1390cce98 100644
--- a/server/http_config_site_other_handler.go
+++ b/server/http_config_site_other_handler.go
@@ -2,9 +2,12 @@ package server
import (
"encoding/json"
+ "fmt"
"net/http"
+ "slices"
"github.com/evcc-io/evcc/api/globalconfig"
+ "github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/db/settings"
"github.com/evcc-io/evcc/util/sponsor"
@@ -14,6 +17,11 @@ func setOptimizer(pub publisher) func(bool) error {
return func(b bool) error {
settings.SetBool(keys.Optimizer, b)
pub(keys.Optimizer, b)
+ if !b {
+ // automatic mode cannot outlive the optimizer
+ settings.SetString(keys.OptimizerAutomatic, core.OptimizerAutomaticOff)
+ pub(keys.OptimizerAutomatic, core.OptimizerAutomaticOff)
+ }
return nil
}
}
@@ -23,6 +31,22 @@ func getOptimizer() bool {
return b
}
+func setOptimizerAutomatic(pub publisher, site *core.Site) func(string) error {
+ return func(level string) error {
+ if !slices.Contains(core.OptimizerAutomaticLevels, level) {
+ return fmt.Errorf("invalid optimizer automatic level: %s", level)
+ }
+
+ settings.SetString(keys.OptimizerAutomatic, level)
+ pub(keys.OptimizerAutomatic, level)
+
+ // suggestions become control decisions, don't wait for the next slot
+ site.Optimize()
+
+ return nil
+ }
+}
+
func setExperimental(pub publisher) func(bool) error {
return func(b bool) error {
settings.SetBool(keys.Experimental, b)
diff --git a/server/http_loadpoint_handler.go b/server/http_loadpoint_handler.go
index 4fe82defbb..5ef5dcfa00 100644
--- a/server/http_loadpoint_handler.go
+++ b/server/http_loadpoint_handler.go
@@ -9,6 +9,7 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
+ "github.com/evcc-io/evcc/core/planner"
"github.com/evcc-io/evcc/core/site"
"github.com/gorilla/mux"
)
@@ -19,6 +20,8 @@ type PlanResponse struct {
Duration int64 `json:"duration"`
Plan api.Rates `json:"plan"`
Power float64 `json:"power"`
+ // Optimizer indicates that the optimizer created the plan
+ Optimizer bool `json:"optimizer"`
}
type PlanPreviewResponse struct {
@@ -37,15 +40,19 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
goal, _ := lp.GetPlanGoal()
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
- strategy := lp.EffectivePlanStrategy()
- plan := lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous)
+
+ plan, power, optimizer := lp.EffectivePlan(planTime, requiredDuration, lp.EffectivePlanStrategy())
+ if optimizer {
+ maxPower, requiredDuration = power, planner.Duration(plan)
+ }
res := PlanResponse{
- PlanId: id,
- PlanTime: planTime,
- Duration: int64(requiredDuration.Seconds()),
- Plan: plan,
- Power: maxPower,
+ PlanId: id,
+ PlanTime: planTime,
+ Duration: int64(requiredDuration.Seconds()),
+ Plan: plan,
+ Power: maxPower,
+ Optimizer: optimizer,
}
jsonWrite(w, res)
diff --git a/server/http_site_handler.go b/server/http_site_handler.go
index 5a226f75f1..0fde443741 100644
--- a/server/http_site_handler.go
+++ b/server/http_site_handler.go
@@ -16,6 +16,7 @@ import (
"time"
"github.com/evcc-io/evcc/api"
+ "github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/site"
@@ -164,7 +165,7 @@ func handler[T any](conv func(string) (T, error), set func(T) error, get func()
}
if err != nil {
- jsonError(w, http.StatusBadRequest, err)
+ jsonError(w, errorStatus(err), err)
return
}
@@ -172,6 +173,14 @@ func handler[T any](conv func(string) (T, error), set func(T) error, get func()
}
}
+// errorStatus maps a setting rejected by the current state to conflict
+func errorStatus(err error) int {
+ if errors.Is(err, core.ErrOptimizerAutomatic) {
+ return http.StatusConflict
+ }
+ return http.StatusBadRequest
+}
+
// ptrHandler updates pointer api
func ptrHandler[T any](conv func(string) (T, error), set func(*T) error, get func() *T) http.HandlerFunc {
return handler(func(s string) (*T, error) {
@@ -249,8 +258,9 @@ func callHandler(fun func()) http.HandlerFunc {
}
}
-// updateSmartCostLimit sets the smart cost limit globally
-func updateSmartCostLimit(site site.API, setLimit func(loadpoint.API, *float64)) http.HandlerFunc {
+// updateSmartCostLimit sets the smart cost limit globally. Loadpoints under
+// optimizer control are skipped, the request fails only if none accepted it.
+func updateSmartCostLimit(site site.API, setLimit func(loadpoint.API, *float64) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
var val *float64
@@ -265,14 +275,35 @@ func updateSmartCostLimit(site site.API, setLimit func(loadpoint.API, *float64))
val = &f
}
- for _, lp := range site.ActiveLoadpoints() {
- setLimit(lp, val)
+ if err := setLoadpointsLimit(site, setLimit, val); err != nil {
+ jsonError(w, errorStatus(err), err)
+ return
}
jsonWrite(w, val)
}
}
+// setLoadpointsLimit applies a limit to all loadpoints that accept it
+func setLoadpointsLimit(site site.API, setLimit func(loadpoint.API, *float64) error, val *float64) error {
+ var applied bool
+ var lastErr error
+
+ for _, lp := range site.ActiveLoadpoints() {
+ if err := setLimit(lp, val); err != nil {
+ lastErr = err
+ } else {
+ applied = true
+ }
+ }
+
+ if applied {
+ return nil
+ }
+
+ return lastErr
+}
+
// updateBatteryMode sets the external battery mode
func updateBatteryMode(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
diff --git a/server/http_site_handler_test.go b/server/http_site_handler_test.go
new file mode 100644
index 0000000000..254505241d
--- /dev/null
+++ b/server/http_site_handler_test.go
@@ -0,0 +1,18 @@
+package server
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/evcc-io/evcc/core"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestErrorStatus(t *testing.T) {
+ // settings the optimizer decides on are rejected as conflict, not bad request
+ assert.Equal(t, http.StatusConflict, errorStatus(core.ErrOptimizerAutomatic))
+ assert.Equal(t, http.StatusConflict, errorStatus(fmt.Errorf("smart cost limit: %w", core.ErrOptimizerAutomatic)))
+ assert.Equal(t, http.StatusBadRequest, errorStatus(errors.New("boom")))
+}
diff --git a/server/http_vehicle_handler.go b/server/http_vehicle_handler.go
index 1d63cd43ae..2f8bd1a901 100644
--- a/server/http_vehicle_handler.go
+++ b/server/http_vehicle_handler.go
@@ -150,6 +150,8 @@ func planSocHandler(site site.API) http.HandlerFunc {
return
}
+ site.Optimize()
+
ts, soc = v.GetPlanSoc()
res := struct {
@@ -216,6 +218,8 @@ func addRepeatingPlansHandler(site site.API) http.HandlerFunc {
return
}
+ site.Optimize()
+
jsonWrite(w, res)
}
}
@@ -236,6 +240,8 @@ func planSocRemoveHandler(site site.API) http.HandlerFunc {
return
}
+ site.Optimize()
+
jsonWrite(w, struct{}{})
}
}
diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json
index e8b71d07d8..61be8eabc8 100644
--- a/server/mcp/openapi.json
+++ b/server/mcp/openapi.json
@@ -1417,6 +1417,45 @@
}
}
},
+ "/config/optimizerautomatic/{value}": {
+ "post": {
+ "operationId": "setOptimizerAutomatic",
+ "summary": "Set optimizer automatic level",
+ "description": "Let the optimizer directly control the home battery (`battery`) or home battery and loadpoints (`full`) instead of only advising (`off`). Requires a sponsor token.",
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "tags": [
+ "experimental"
+ ],
+ "parameters": [
+ {
+ "name": "value",
+ "description": "What the optimizer controls.",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "off",
+ "battery",
+ "full"
+ ]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/StringResult"
+ }
+ }
+ }
+ },
"/smartfeedinprioritylimit": {
"delete": {
"operationId": "removeGlobalSmartFeedInPriorityLimit",
@@ -3521,6 +3560,10 @@
"description": "Recommended discharge power in W.",
"type": "number"
},
+ "grid": {
+ "description": "Planned site grid flow of the slot in W, positive means import.",
+ "type": "number"
+ },
"actionable": {
"description": "Suggestion differs from the current operating mode.",
"type": "boolean"
@@ -3794,6 +3837,10 @@
"description": "Current offered to the vehicle in A.",
"type": "number"
},
+ "optimizerControlled": {
+ "description": "Optimizer decides start/stop for this loadpoint, replacing the price limits.",
+ "type": "boolean"
+ },
"phaseAction": {
"$ref": "#/components/schemas/PhaseAction",
"description": "Pending phase switching action in solar mode."
@@ -4043,6 +4090,7 @@
"minSocNotReached",
"mode",
"offeredCurrent",
+ "optimizerControlled",
"phaseAction",
"phaseRemaining",
"phasesActive",
@@ -4289,6 +4337,10 @@
"description": "Recommended discharge power in W.",
"type": "number"
},
+ "grid": {
+ "description": "Planned site grid flow of the slot in W, positive means import.",
+ "type": "number"
+ },
"actionable": {
"description": "Suggestion differs from the current operating mode.",
"type": "boolean"
@@ -5505,6 +5557,14 @@
}
}
},
+ "OptimizerAutomatic": {
+ "type": "string",
+ "enum": [
+ "off",
+ "battery",
+ "full"
+ ]
+ },
"State": {
"description": "Complete system state as returned by /api/state and pushed via websocket and MQTT. This structure mirrors the internal UI state and carries no compatibility promise. Fields may change or disappear between releases.",
"type": "object",
@@ -5872,6 +5932,10 @@
"description": "Battery optimizer is enabled.",
"type": "boolean"
},
+ "optimizerAutomatic": {
+ "$ref": "#/components/schemas/OptimizerAutomatic",
+ "description": "What the optimizer controls instead of only advising: nothing, the home battery, or battery and loadpoints."
+ },
"optimizerChargingStrategy": {
"description": "Selected battery optimizer charging strategy.",
"type": "string"
@@ -6211,6 +6275,16 @@
}
}
},
+ "StringResult": {
+ "description": "Success - String result",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
"NullResult": {
"description": "Success - Null result",
"content": {
diff --git a/server/mqtt.go b/server/mqtt.go
index 703b8fdfa3..556642fe32 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -233,16 +233,12 @@ func (m *MQTT) listenSiteSetters(topic string, site site.API) error {
{"residualPower", floatSetter(site.SetResidualPower)},
{"gridExportLimit", floatSetter(site.SetGridExportLimit)},
{"solarAdjusted", boolSetter(pass(site.SetSolarAdjusted))},
- {"smartCostLimit", floatPtrSetter(pass(func(limit *float64) {
- for _, lp := range site.ActiveLoadpoints() {
- lp.SetSmartCostLimit(limit)
- }
- }))},
- {"smartFeedInPriorityLimit", floatPtrSetter(pass(func(limit *float64) {
- for _, lp := range site.ActiveLoadpoints() {
- lp.SetSmartFeedInPriorityLimit(limit)
- }
- }))},
+ {"smartCostLimit", floatPtrSetter(func(limit *float64) error {
+ return setLoadpointsLimit(site, loadpoint.API.SetSmartCostLimit, limit)
+ })},
+ {"smartFeedInPriorityLimit", floatPtrSetter(func(limit *float64) error {
+ return setLoadpointsLimit(site, loadpoint.API.SetSmartFeedInPriorityLimit, limit)
+ })},
{"batteryGridChargeLimit", floatPtrSetter(site.SetBatteryGridChargeLimit)},
{"batteryGridDischargeLimit", floatPtrSetter(site.SetBatteryGridDischargeLimit)},
{"batteryMode", ptrSetter(api.BatteryModeString, func(m *api.BatteryMode) error {
@@ -275,9 +271,9 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.
{"disableThreshold", floatSetter(pass(lp.SetDisableThreshold))},
{"enableDelay", durationSetter(pass(lp.SetEnableDelay))},
{"disableDelay", durationSetter(pass(lp.SetDisableDelay))},
- {"smartCostLimit", floatPtrSetter(pass(lp.SetSmartCostLimit))},
+ {"smartCostLimit", floatPtrSetter(lp.SetSmartCostLimit)},
{"solarShare", floatSetter(pass(lp.SetSolarShare))},
- {"smartFeedInPriorityLimit", floatPtrSetter(pass(lp.SetSmartFeedInPriorityLimit))},
+ {"smartFeedInPriorityLimit", floatPtrSetter(lp.SetSmartFeedInPriorityLimit)},
{"batteryBoost", boolSetter(lp.SetBatteryBoost)},
{"batteryBoostLimit", intSetter(pass(lp.SetBatteryBoostLimit))},
{"planStrategy", planStrategySetter(lp.SetPlanStrategy)},
diff --git a/server/openapi.state.yaml b/server/openapi.state.yaml
index 36ba59ad07..3daca0b28a 100644
--- a/server/openapi.state.yaml
+++ b/server/openapi.state.yaml
@@ -275,6 +275,9 @@ components:
optimizer:
description: Battery optimizer is enabled.
type: boolean
+ optimizerAutomatic:
+ $ref: "#/components/schemas/OptimizerAutomatic"
+ description: "What the optimizer controls instead of only advising: nothing, the home battery, or battery and loadpoints."
optimizerChargingStrategy:
description: Selected battery optimizer charging strategy.
type: string
@@ -449,6 +452,9 @@ components:
discharge:
description: Recommended discharge power in W.
type: number
+ grid:
+ description: Planned site grid flow of the slot in W, positive means import.
+ type: number
actionable:
description: Suggestion differs from the current operating mode.
type: boolean
@@ -942,6 +948,9 @@ components:
offeredCurrent:
description: Current offered to the vehicle in A.
type: number
+ optimizerControlled:
+ description: Optimizer decides start/stop for this loadpoint, replacing the price limits.
+ type: boolean
phaseAction:
$ref: "#/components/schemas/PhaseAction"
description: Pending phase switching action in solar mode.
@@ -1143,6 +1152,7 @@ components:
- minSocNotReached
- mode
- offeredCurrent
+ - optimizerControlled
- phaseAction
- phaseRemaining
- phasesActive
@@ -1197,6 +1207,9 @@ components:
discharge:
description: Recommended discharge power in W.
type: number
+ grid:
+ description: Planned site grid flow of the slot in W, positive means import.
+ type: number
actionable:
description: Suggestion differs from the current operating mode.
type: boolean
@@ -1542,6 +1555,12 @@ components:
$ref: "#/components/schemas/OcppStationStatus"
required:
- stations
+ OptimizerAutomatic:
+ type: string
+ enum:
+ - off
+ - battery
+ - full
PhaseAction:
description: Pending phase switching action in solar mode.
type: string
diff --git a/server/openapi.yaml b/server/openapi.yaml
index b2db36c7c2..d455479b00 100644
--- a/server/openapi.yaml
+++ b/server/openapi.yaml
@@ -850,6 +850,30 @@ paths:
responses:
"200":
$ref: "#/components/responses/BooleanResult"
+ /config/optimizerautomatic/{value}:
+ post:
+ operationId: setOptimizerAutomatic
+ summary: Set optimizer automatic level
+ description: "Let the optimizer directly control the home battery (`battery`) or home battery and loadpoints (`full`) instead of only advising (`off`). Requires a sponsor token."
+ security:
+ - cookieAuth: []
+ - bearerAuth: []
+ tags:
+ - experimental
+ parameters:
+ - name: value
+ description: What the optimizer controls.
+ in: path
+ required: true
+ schema:
+ type: string
+ enum:
+ - "off"
+ - "battery"
+ - "full"
+ responses:
+ "200":
+ $ref: "#/components/responses/StringResult"
/smartfeedinprioritylimit:
delete:
operationId: removeGlobalSmartFeedInPriorityLimit
@@ -2339,6 +2363,12 @@ components:
application/json:
schema:
type: boolean
+ StringResult:
+ description: Success - String result
+ content:
+ application/json:
+ schema:
+ type: string
NullResult:
description: Success - Null result
content:
diff --git a/templates/definition/meter/fronius-gen24.yaml b/templates/definition/meter/fronius-gen24.yaml
index 281783301b..c680301f39 100644
--- a/templates/definition/meter/fronius-gen24.yaml
+++ b/templates/definition/meter/fronius-gen24.yaml
@@ -268,6 +268,13 @@ render: |
{{- include "modbus-connection" . | indent 10 }}
id: 1
value: 124:0:OutWRte
+ - source: const
+ value: 100 # %
+ set:
+ source: sunspec
+ uri: {{ joinHostPort .host .port }}
+ id: 1
+ value: 124:0:InWRte
- case: 2 # hold
set:
source: sequence
@@ -329,5 +336,37 @@ render: |
{{- include "modbus-connection" . | indent 10 }}
id: 1
value: 124:0:InWRte
+ - case: 5 # discharge
+ set:
+ source: sequence
+ set:
+ - source: const
+ value: 0
+ set:
+ source: sunspec
+ uri: {{ joinHostPort .host .port }}
+ id: 1
+ value: 124:0:StorCtl_Mod
+ - source: const
+ value: -100 # %
+ set:
+ source: sunspec
+ uri: {{ joinHostPort .host .port }}
+ id: 1
+ value: 124:0:InWRte
+ - source: const
+ value: 100 # %
+ set:
+ source: sunspec
+ uri: {{ joinHostPort .host .port }}
+ id: 1
+ value: 124:0:OutWRte
+ - source: const
+ value: 3
+ set:
+ source: sunspec
+ uri: {{ joinHostPort .host .port }}
+ id: 1
+ value: 124:0:StorCtl_Mod
{{- include "battery-params" . }}
{{- end }}
diff --git a/tests/battery-settings.spec.ts b/tests/battery-settings.spec.ts
index 733c8ccdcf..ace6c1286e 100644
--- a/tests/battery-settings.spec.ts
+++ b/tests/battery-settings.spec.ts
@@ -87,6 +87,56 @@ test.describe("battery settings", async () => {
).toBeVisible();
});
+ test("optimizer automatic mode", async ({ page }) => {
+ await page.goto("/#/battery");
+
+ const enableLimit = page.getByRole("switch", { name: "Enable limit" });
+ const priceLimit = page.getByRole("combobox", { name: "Price limit" });
+ const dischargeControl = page.getByRole("switch", { name: /Prevent home battery discharge/ });
+ const optimizerHints = page.getByText(
+ "Disabled by automatic control. The optimizer decides instead.",
+ {
+ exact: true,
+ }
+ );
+
+ await expect(enableLimit).toBeEnabled();
+ await expect(priceLimit).toBeEnabled();
+ await expect(dischargeControl).toBeEnabled();
+ await expect(optimizerHints).toHaveCount(0);
+
+ await expect(await page.request.post("/api/config/optimizerautomatic/full")).toBeOK();
+
+ await expect(enableLimit).toBeDisabled();
+ await expect(priceLimit).toBeDisabled();
+ await expect(dischargeControl).toBeDisabled();
+ await expect(optimizerHints).toHaveCount(2);
+ await expect(optimizerHints.first()).toBeVisible();
+ await expect(optimizerHints.last()).toBeVisible();
+ await expect(page.getByTestId("battery-priority").getByRole("combobox")).toBeEnabled();
+ await expect(page.getByTestId("battery-buffer").getByRole("combobox").first()).toBeEnabled();
+
+ // battery only level takes over the battery settings as well
+ await expect(await page.request.post("/api/config/optimizerautomatic/battery")).toBeOK();
+ await expect(enableLimit).toBeDisabled();
+ await expect(dischargeControl).toBeDisabled();
+ await expect(optimizerHints).toHaveCount(2);
+
+ await expect(await page.request.post("/api/config/optimizerautomatic/off")).toBeOK();
+
+ await expect(enableLimit).toBeEnabled();
+ await expect(priceLimit).toBeEnabled();
+ await expect(dischargeControl).toBeEnabled();
+ await expect(optimizerHints).toHaveCount(0);
+
+ // disabling the optimizer releases automatic mode
+ await expect(await page.request.post("/api/config/optimizerautomatic/full")).toBeOK();
+ await expect(enableLimit).toBeDisabled();
+ await expect(await page.request.post("/api/config/optimizer/false")).toBeOK();
+ await expect(enableLimit).toBeEnabled();
+ await expect(optimizerHints).toHaveCount(0);
+ });
+
test("hold mode display", async ({ page }) => {
await page.goto("/");
await page.getByTestId("energyflow").click();
diff --git a/tests/optimize.spec.ts b/tests/optimize.spec.ts
index 337b24229b..a89c6441a5 100644
--- a/tests/optimize.spec.ts
+++ b/tests/optimize.spec.ts
@@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
-import { openMoreMenu } from "./utils";
+import { expectModalVisible, openMoreMenu } from "./utils";
const CONFIG = "basics.evcc.yaml";
const SQL = "optimize.sql";
@@ -30,4 +30,17 @@ test.describe("optimize page", async () => {
await page.getByRole("link", { name: "Check logs" }).click();
await expect(page.getByTestId("log-search")).toHaveValue("optimizer");
});
+
+ test("disabled state", async ({ page }) => {
+ await page.goto("/#/optimize");
+ await expect(page.getByTestId("optimize-empty")).toBeVisible();
+
+ await expect(await page.request.post("/api/config/optimizer/false")).toBeOK();
+ await expect(page.getByText("The optimizer is disabled.")).toBeVisible();
+
+ // settings open in place, the page stays reachable without its menu entry
+ await page.getByRole("button", { name: "Open settings" }).click();
+ await expectModalVisible(page.getByTestId("optimizer-modal"));
+ await expect(page).toHaveURL("/#/optimize");
+ });
});
From f4174369b523201561b332a8d00e4151c7722788 Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 12:20:38 +0200
Subject: [PATCH 28/61] Optimizer: ignore grid solver noise in battery
suggestion (#34678)
---
core/site_optimizer.go | 4 ++--
core/site_optimizer_test.go | 4 ++++
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/core/site_optimizer.go b/core/site_optimizer.go
index b5bbcf4e03..4b8d0336dd 100644
--- a/core/site_optimizer.go
+++ b/core/site_optimizer.go
@@ -181,8 +181,8 @@ func currentSlotSuggestion(detail batteryDetail, req optimizer.BatteryConfig, re
charge := float64(res.ChargingPower[slot]) / slotHours
discharge := float64(res.DischargingPower[slot]) / slotHours
- gridImporting := gridImport > 0
- gridExporting := gridExport > 0
+ gridImporting := float64(gridImport)/slotHours > suggestionThreshold
+ gridExporting := float64(gridExport)/slotHours > suggestionThreshold
s := types.Suggestion{
Charge: charge,
diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go
index 276ee898de..dfeef80033 100644
--- a/core/site_optimizer_test.go
+++ b/core/site_optimizer_test.go
@@ -713,6 +713,10 @@ func TestCurrentSlotSuggestion(t *testing.T) {
res.StateOfCharge[0] = 5000
assert.Equal(t, api.BatteryHold.String(), currentSlotSuggestion(battery, req, res, 0, 1000, 0, 1).Action, "idle within bounds is withheld")
assert.Equal(t, api.BatteryHoldCharge.String(), currentSlotSuggestion(battery, req, res, 0, 0, 1000, 1).Action, "idle within bounds is withheld")
+
+ // solver noise on the grid is no import/export (#34657)
+ assert.Equal(t, api.BatteryNormal.String(), currentSlotSuggestion(battery, req, res, 0, 4e-05, 0, 570.0/3600).Action, "idle with import noise")
+ assert.Equal(t, api.BatteryNormal.String(), currentSlotSuggestion(battery, req, res, 0, 0, 4e-05, 570.0/3600).Action, "idle with export noise")
}
// TestSuggestionActionable ensures the actionable flag follows the current state
From c5ed5f848a285b839870a6bc94b93d28a94dbb24 Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 12:32:44 +0200
Subject: [PATCH 29/61] Templates: render battery params for Sessy, ioBroker
and Victron VRM (#34680)
---
templates/definition/meter/iobroker.yaml | 9 ++++-----
templates/definition/meter/sessy-smart-battery.yaml | 2 +-
templates/definition/meter/victron-vrm.yaml | 1 +
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/templates/definition/meter/iobroker.yaml b/templates/definition/meter/iobroker.yaml
index ef07bbb697..7d759c0d3a 100644
--- a/templates/definition/meter/iobroker.yaml
+++ b/templates/definition/meter/iobroker.yaml
@@ -69,14 +69,13 @@ params:
type: string
advanced: true
- preset: battery-params
- - name: capacity
- - name: minsoc
- - name: maxsoc
- - name: maxchargepower
- - name: maxdischargepower
- name: maxacpower
render: |
type: custom
+ maxacpower: {{ .maxacpower }} # W
+ {{- if eq .usage "battery" }}
+ {{- include "battery-params" . }}
+ {{- end }}
{{- if .power }}
power:
source: http
diff --git a/templates/definition/meter/sessy-smart-battery.yaml b/templates/definition/meter/sessy-smart-battery.yaml
index c70fc6af18..2369a7041b 100644
--- a/templates/definition/meter/sessy-smart-battery.yaml
+++ b/templates/definition/meter/sessy-smart-battery.yaml
@@ -158,4 +158,4 @@ render: |
headers:
- content-type: application/json
body: '{"setpoint": -2200}'
- capacity: {{ .capacity }}
+ {{- include "battery-params" . }}
diff --git a/templates/definition/meter/victron-vrm.yaml b/templates/definition/meter/victron-vrm.yaml
index 072a3eb8d1..bf1ff0e20f 100644
--- a/templates/definition/meter/victron-vrm.yaml
+++ b/templates/definition/meter/victron-vrm.yaml
@@ -51,3 +51,4 @@ render: |
- content-type: application/json
- x-authorization: Token {{ .token }}
jq: .records.data["51"].valueFloat
+ {{- include "battery-params" . }}
From bd3ac4cd405559781fdff1cb39936847a51d75d5 Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 13:00:16 +0200
Subject: [PATCH 30/61] MySkoda: add wake-up via charging/start (#34686)
---
vehicle/myskoda/provider.go | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/vehicle/myskoda/provider.go b/vehicle/myskoda/provider.go
index 8dae597a3a..9b582ab138 100644
--- a/vehicle/myskoda/provider.go
+++ b/vehicle/myskoda/provider.go
@@ -161,3 +161,11 @@ func (v *Provider) ChargeEnable(enable bool) error {
}
return v.action(action)
}
+
+var _ api.Resurrector = (*Provider)(nil)
+
+// WakeUp implements the api.Resurrector interface
+// the api has no wake-up call, starting charging wakes the vehicle
+func (v *Provider) WakeUp() error {
+ return v.action(ActionStart)
+}
From d2941ea7dba7bd4e10b8930dbbe60292bdd3a51c Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 13:06:01 +0200
Subject: [PATCH 31/61] Battery: only offer grid discharge for batteries
supporting discharge (#34685)
---
.../components/Battery/BatteryConfigCard.vue | 9 ++-
assets/js/types/evcc.ts | 2 +
core/site.go | 5 +-
core/types/types.go | 1 +
server/influxdb_test.go | 6 +-
server/mcp/openapi.json | 7 +++
server/mqtt_test.go | 56 ++++++++++---------
server/openapi.state.yaml | 5 ++
tests/battery-multi.evcc.yaml | 2 +-
9 files changed, 64 insertions(+), 29 deletions(-)
diff --git a/assets/js/components/Battery/BatteryConfigCard.vue b/assets/js/components/Battery/BatteryConfigCard.vue
index 6586ff413f..0a5078a35a 100644
--- a/assets/js/components/Battery/BatteryConfigCard.vue
+++ b/assets/js/components/Battery/BatteryConfigCard.vue
@@ -123,7 +123,7 @@
-
+
+ d.modes?.includes("discharge")
+ );
+ return this.experimental && (supported || this.batteryGridDischarge);
+ },
controlledTitleList(): string {
return new Intl.ListFormat(this.$i18n?.locale).format(this.optimizerControlledTitles);
},
diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts
index 4458bd90dc..c7875b11a5 100644
--- a/assets/js/types/evcc.ts
+++ b/assets/js/types/evcc.ts
@@ -1241,6 +1241,8 @@ export interface BatteryMeter extends Meter {
soc: number;
/** Battery supports external control of its operation mode. */
controllable: boolean;
+ /** Battery modes supported by the device. */
+ modes?: string[];
/** Battery capacity in kWh. Zero when not specified. */
capacity: number;
/** Battery optimizer suggestion. */
diff --git a/core/site.go b/core/site.go
index 26043e43c8..621ae18770 100644
--- a/core/site.go
+++ b/core/site.go
@@ -825,8 +825,11 @@ func (site *Site) updateBatteryMeters() {
maxDischargePower = -1 // any battery without a limit disables the cap
}
- _, controllable := api.Cap[api.BatteryController](meter)
+ ctrl, controllable := api.Cap[api.BatteryController](meter)
mm[i].Controllable = new(controllable)
+ if controllable {
+ mm[i].Modes = lo.Map(ctrl.BatteryModes(), func(m api.BatteryMode, _ int) string { return m.String() })
+ }
}
// retain the last known soc when all reads failed, so that a transient
diff --git a/core/types/types.go b/core/types/types.go
index c7959556dd..0a6c2854a9 100644
--- a/core/types/types.go
+++ b/core/types/types.go
@@ -20,6 +20,7 @@ type Measurement struct {
Capacity *float64 `json:"capacity,omitempty"`
Soc *float64 `json:"soc,omitempty"`
Controllable *bool `json:"controllable,omitempty"`
+ Modes []string `json:"modes,omitempty"`
Suggestion *Suggestion `json:"suggestion,omitempty"`
}
diff --git a/server/influxdb_test.go b/server/influxdb_test.go
index 5e09ebbde1..190b58638b 100644
--- a/server/influxdb_test.go
+++ b/server/influxdb_test.go
@@ -87,8 +87,12 @@ func (w *influxSuite) TestSlice() {
w.Len(w.p, 0)
}
+// only set numeric fields are written, so adding a field does not change the points
func (w *influxSuite) TestMeasurement() {
- w.WriteParam(util.Param{Key: "battery", Val: types.Measurement{Power: 1, Soc: new(10.0)}})
+ w.WriteParam(util.Param{Key: "battery", Val: types.Measurement{
+ Power: 1, Soc: new(10.0),
+ Title: "home", Controllable: new(true), Modes: []string{"normal", "discharge"},
+ }})
w.Equal([]*write.Point{
inf2.NewPoint("batteryPower", nil, map[string]any{"value": 1.0}, w.clock.Now()),
inf2.NewPoint("batterySoc", nil, map[string]any{"value": 10.0}, w.clock.Now()),
diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json
index 61be8eabc8..548d94c638 100644
--- a/server/mcp/openapi.json
+++ b/server/mcp/openapi.json
@@ -4400,6 +4400,13 @@
"description": "Battery supports external control of its operation mode.",
"type": "boolean"
},
+ "modes": {
+ "description": "Battery modes supported by the device.",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
"capacity": {
"description": "Battery capacity in kWh. Zero when not specified.",
"type": "number"
diff --git a/server/mqtt_test.go b/server/mqtt_test.go
index 24bc0487e9..902411dd2f 100644
--- a/server/mqtt_test.go
+++ b/server/mqtt_test.go
@@ -10,7 +10,6 @@ import (
"github.com/evcc-io/evcc/core/types"
"github.com/evcc-io/evcc/util"
- "github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
@@ -126,39 +125,44 @@ func (suite *mqttSuite) TestNilInterface() {
suite.Equal([]string{""}, suite.payloads, "payloads")
}
-func (suite *mqttSuite) TestMeasurement() {
- topics := lo.Map([]string{
- "name", "title", "icon", "power", "energy", "returnEnergy", "powers", "currents", "excessDCPower", "capacity", "soc", "controllable", "suggestion",
- }, func(s string, _ int) string {
- return "test/" + s
- })
+// published returns the last payload per topic
+func (suite *mqttSuite) published() map[string]string {
+ res := make(map[string]string, len(suite.topics))
+ for i, topic := range suite.topics {
+ res[topic] = suite.payloads[i]
+ }
+ return res
+}
+// TestMeasurement checks the fields under test only, so adding a field does not break it
+func (suite *mqttSuite) TestMeasurement() {
suite.publish("test", false, types.Measurement{})
- suite.Equal(topics, suite.topics, "topics")
- suite.Equal([]string{"", "", "", "0", "", "", "", "", "", "", "", "", ""}, suite.payloads, "empty payloads")
+ for topic, payload := range suite.published() {
+ if topic != "test/power" {
+ suite.Empty(payload, "unset field must clear retained topic %s", topic)
+ }
+ }
+ suite.Equal("0", suite.published()["test/power"])
suite.publish("test", false, types.Measurement{Energy: new(1.0)})
- suite.Equal(topics, suite.topics, "topics")
- suite.Equal([]string{"", "", "", "0", "1", "", "", "", "", "", "", "", ""}, suite.payloads, "energy payloads")
+ suite.Equal("1", suite.published()["test/energy"])
suite.publish("test", false, types.Measurement{Controllable: new(false)})
- suite.Equal(topics, suite.topics, "topics")
- suite.Equal([]string{"", "", "", "0", "", "", "", "", "", "", "", "false", ""}, suite.payloads, "controllable payloads")
+ suite.Equal("false", suite.published()["test/controllable"])
+ suite.Empty(suite.published()["test/energy"], "energy cleared")
suite.publish("test", false, types.Measurement{Currents: []float64{1, 2, 3}})
- suite.Equal(append(topics, "test/currents/1", "test/currents/2", "test/currents/3"), suite.topics, "currents topics")
- suite.Equal([]string{"", "", "", "0", "", "", "", "3", "", "", "", "", "", "1", "2", "3"}, suite.payloads, "currents payloads")
-}
+ suite.Subset(suite.published(), map[string]string{
+ "test/currents": "3", "test/currents/1": "1", "test/currents/2": "2", "test/currents/3": "3",
+ })
-func (suite *mqttSuite) TestBatteryState() {
- topics := lo.Map([]string{
- "power", "energy", "returnEnergy", "capacity", "soc",
- "devices", "devices/1/name", "devices/1/title", "devices/1/icon", "devices/1/power", "devices/1/energy", "devices/1/returnEnergy", "devices/1/powers", "devices/1/currents", "devices/1/excessDCPower", "devices/1/capacity", "devices/1/soc", "devices/1/controllable", "devices/1/suggestion",
- "forecast",
- }, func(s string, _ int) string {
- return "test/" + s
+ suite.publish("test", false, types.Measurement{Modes: []string{"normal", "discharge"}})
+ suite.Subset(suite.published(), map[string]string{
+ "test/modes": "2", "test/modes/1": "normal", "test/modes/2": "discharge",
})
+}
+func (suite *mqttSuite) TestBatteryState() {
suite.publish("test", false, types.BatteryState{
Power: 2,
Soc: 20.0,
@@ -168,8 +172,10 @@ func (suite *mqttSuite) TestBatteryState() {
}},
})
- suite.Equal(topics, suite.topics, "topics")
- suite.Equal([]string{"2", "", "", "", "20", "1", "", "", "", "1", "", "", "", "", "", "", "10", "", "", ""}, suite.payloads, "payloads")
+ suite.Subset(suite.published(), map[string]string{
+ "test/power": "2", "test/soc": "20", "test/forecast": "",
+ "test/devices": "1", "test/devices/1/power": "1", "test/devices/1/soc": "10",
+ })
}
func (suite *mqttSuite) TestMapKeys() {
diff --git a/server/openapi.state.yaml b/server/openapi.state.yaml
index 3daca0b28a..3ba957570f 100644
--- a/server/openapi.state.yaml
+++ b/server/openapi.state.yaml
@@ -412,6 +412,11 @@ components:
controllable:
description: Battery supports external control of its operation mode.
type: boolean
+ modes:
+ description: Battery modes supported by the device.
+ type: array
+ items:
+ type: string
capacity:
description: Battery capacity in kWh. Zero when not specified.
type: number
diff --git a/tests/battery-multi.evcc.yaml b/tests/battery-multi.evcc.yaml
index 9bd0dfb94a..e09fd9ff0b 100644
--- a/tests/battery-multi.evcc.yaml
+++ b/tests/battery-multi.evcc.yaml
@@ -29,7 +29,7 @@ meters:
source: const
value: 76
capacity: 13.5
- batterymodes: ["normal", "hold", "charge", "holdcharge"]
+ batterymodes: ["normal", "hold", "charge", "holdcharge", "discharge"]
batterymode:
source: js
vm: shared
From 0306287bf2e92868df9d4b09d6580c28f49f79a6 Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 13:22:46 +0200
Subject: [PATCH 32/61] SENEC Connect: add .Home V4 product (#34688)
---
templates/definition/meter/senec-connect.yaml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/templates/definition/meter/senec-connect.yaml b/templates/definition/meter/senec-connect.yaml
index ffad1bf651..0cc04d06e7 100644
--- a/templates/definition/meter/senec-connect.yaml
+++ b/templates/definition/meter/senec-connect.yaml
@@ -3,6 +3,9 @@ products:
- brand: SENEC
description:
generic: .Connect (Cloud)
+ - brand: SENEC
+ description:
+ generic: .Home V4
- brand: SENEC
description:
generic: .Home P4
From f28efb90ab53d12e229a8c58fc31553f8a9a6cf6 Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 14:24:47 +0200
Subject: [PATCH 33/61] Optimizer: ignore prioritySoc on loadpoints it controls
(#34689)
---
core/optimizer_automatic_test.go | 38 +++++++++++++++++++++++++++++++
core/site.go | 39 +++++++++++++++++++++-----------
docs/agents/core-domain.md | 2 ++
3 files changed, 66 insertions(+), 13 deletions(-)
diff --git a/core/optimizer_automatic_test.go b/core/optimizer_automatic_test.go
index 6a4b7333ea..927bf9ba00 100644
--- a/core/optimizer_automatic_test.go
+++ b/core/optimizer_automatic_test.go
@@ -225,6 +225,44 @@ func TestOptimizerStopFollowsSurplus(t *testing.T) {
assert.True(t, lp.pvTimer.Equal(elapsed))
}
+// TestOptimizerPrioritySoc covers the battery priority of a loadpoint the optimizer
+// controls: prioritySoc does not apply, a stop gives the battery priority and a
+// surplus charge the vehicle (#34606)
+func TestOptimizerPrioritySoc(t *testing.T) {
+ // battery charging 1000W; below prioritySoc the site hides it and forces 100W residual power
+ charging := siteState{battery: types.BatteryState{Power: -1000}}
+ above := sitePowerResult{state: charging, power: -1000}
+ below := sitePowerResult{state: charging, power: 100, priorityAdjustment: -1100}
+
+ for _, tc := range []struct {
+ name string
+ automatic bool
+ action string
+ res sitePowerResult
+ expect float64
+ }{
+ // the report: above prioritySoc the vehicle took the battery's charge power against the stop
+ {"stop above prioritySoc", true, actionStop, above, 0},
+ {"stop below prioritySoc", true, actionStop, below, 0},
+ {"charge above prioritySoc", true, actionCharge, above, -1000},
+ {"charge below prioritySoc", true, actionCharge, below, -1000},
+ // a discharging battery is not hidden by a stop
+ {"stop while discharging", true, actionStop, sitePowerResult{state: siteState{battery: types.BatteryState{Power: 500}}, power: 500}, 500},
+ // prioritySoc applies to loadpoints the optimizer does not control
+ {"uncontrolled below prioritySoc", false, actionStop, below, 100},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ lp := NewLoadpoint(util.NewLogger("foo"), nil)
+ lp.site = &mockSite{automatic: tc.automatic}
+ lp.vehicle = modelledVehicle(gomock.NewController(t))
+ lp.setSuggestion(&types.Suggestion{Action: tc.action})
+
+ site := &Site{log: util.NewLogger("foo")}
+ assert.Equal(t, tc.expect, site.loadpointSitePower(lp, tc.res))
+ })
+ }
+}
+
// TestOptimizerFlexibility covers a loadpoint the optimizer pins to a setpoint:
// it does not yield to a higher priority loadpoint, so its power is not flexible
func TestOptimizerFlexibility(t *testing.T) {
diff --git a/core/site.go b/core/site.go
index 621ae18770..8a77a62065 100644
--- a/core/site.go
+++ b/core/site.go
@@ -48,6 +48,7 @@ const standbyPower = 10 // consider less than 10W as charger in standby
type updater interface {
loadpoint.API
Update(sitePower, batteryPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64, dim *bool)
+ activeSuggestion() *types.Suggestion
}
var _ site.API = (*Site)(nil)
@@ -1152,7 +1153,7 @@ type sitePowerResult struct {
batteryStart bool
// adjustment applied for battery priority below prioritySoc; adding it back
- // restores the unadjusted site power for a battery-boosting loadpoint
+ // restores the unadjusted site power for loadpoints prioritySoc does not apply to
priorityAdjustment float64
}
@@ -1251,6 +1252,29 @@ func (site *Site) updateLoadpoints(rates api.Rates) float64 {
return sum
}
+// loadpointSitePower returns the site power the loadpoint controls against:
+// the surplus reserved for higher-priority loadpoints and its own battery priority
+func (site *Site) loadpointSitePower(lp updater, res sitePowerResult) float64 {
+ sitePower := res.power
+
+ // reserve surplus claimed by higher-priority loadpoints that are starting up (#31194)
+ sitePower += site.reservedPVPower(lp)
+
+ // prioritySoc does not apply to a boosting loadpoint, which deliberately drains the
+ // battery (#30541), nor to one the optimizer controls, since the optimizer plans the
+ // home battery: a stop gives the battery priority, a charge takes the surplus (#34606)
+ suggestion := lp.activeSuggestion()
+ if suggestion != nil || lp.GetBatteryBoost() != boostDisabled {
+ sitePower += res.priorityAdjustment
+ }
+
+ if suggestion != nil && suggestion.Action != actionCharge && res.state.battery.Power < 0 {
+ sitePower -= res.state.battery.Power + res.state.excessDCPower
+ }
+
+ return sitePower
+}
+
// reservedPVPower returns the anticipated surplus claimed by higher-priority PV loadpoints
// that are starting up, so lower-priority loadpoints defer enabling against it (#31194).
func (site *Site) reservedPVPower(lp updater) float64 {
@@ -1374,19 +1398,8 @@ func (site *Site) updatePower(lp updater, state siteState, totalChargePower floa
// TODO
if lp != nil {
- sitePower := res.power
-
- // reserve surplus claimed by higher-priority loadpoints that are starting up (#31194)
- sitePower += site.reservedPVPower(lp)
-
- // battery boost deliberately drains the battery, hence battery priority
- // below prioritySoc does not apply to the boosting loadpoint (#30541)
- if lp.GetBatteryBoost() != boostDisabled {
- sitePower += res.priorityAdjustment
- }
-
lp.Update(
- sitePower, state.battery.Power, consumption, feedin, res.batteryBuffered, res.batteryStart,
+ site.loadpointSitePower(lp, res), state.battery.Power, consumption, feedin, res.batteryBuffered, res.batteryStart,
greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints),
hems.Dimmed(site.hems),
)
diff --git a/docs/agents/core-domain.md b/docs/agents/core-domain.md
index 56523def0b..cf3322d80d 100644
--- a/docs/agents/core-domain.md
+++ b/docs/agents/core-domain.md
@@ -103,6 +103,8 @@ optimal current, sends single command. Resilient to restarts and missed updates.
| `bufferSoc` | Above this: EV can draw from battery reserves |
| `bufferStartSoc` | Above this: EV charging can begin even if importing |
+None of these apply to a loadpoint the optimizer controls: the optimizer plans the home battery, so a stop gives the battery priority and a charge takes the surplus (`loadpointSitePower`, `#34606`).
+
## Effective Price Calculation
```
From 63544a3240ed0b08ceca23b70fcfe2a4e5a90015 Mon Sep 17 00:00:00 2001
From: Michael Geers
Date: Sun, 11 Oct 2026 14:34:04 +0200
Subject: [PATCH 34/61] Config: fix "false" placeholder on fields without
default value (#34655)
---
assets/js/components/Config/PropertyEntry.vue | 2 +-
assets/js/components/Config/PropertyField.vue | 2 +-
tests/config-loadpoint.spec.ts | 1 +
3 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/assets/js/components/Config/PropertyEntry.vue b/assets/js/components/Config/PropertyEntry.vue
index 3bcccaa63f..28b7393369 100644
--- a/assets/js/components/Config/PropertyEntry.vue
+++ b/assets/js/components/Config/PropertyEntry.vue
@@ -45,7 +45,7 @@ export default {
Description: String,
Help: String,
Example: String,
- Default: [String, Number, Boolean],
+ Default: { type: [String, Number, Boolean], default: undefined },
Type: String,
Unit: String,
Mask: Boolean,
diff --git a/assets/js/components/Config/PropertyField.vue b/assets/js/components/Config/PropertyField.vue
index 3a035cbb72..f5f1f68d75 100644
--- a/assets/js/components/Config/PropertyField.vue
+++ b/assets/js/components/Config/PropertyField.vue
@@ -186,7 +186,7 @@ export default {
property: String,
masked: Boolean,
placeholder: String,
- defaultValue: [String, Number, Boolean],
+ defaultValue: { type: [String, Number, Boolean], default: undefined },
type: String,
unit: String,
// transitional: emit ns numbers, remove once all callers accept duration strings (loadpoint follow-up)
diff --git a/tests/config-loadpoint.spec.ts b/tests/config-loadpoint.spec.ts
index a2d346eb3d..9f444aa81f 100644
--- a/tests/config-loadpoint.spec.ts
+++ b/tests/config-loadpoint.spec.ts
@@ -53,6 +53,7 @@ test.describe("charging loadpoint", async () => {
await lpModal.getByRole("link", { name: "Advanced configuration" }).click();
await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport");
+ await expect(lpModal.getByLabel("Title")).not.toHaveAttribute("placeholder");
// verify defaults
await expect(lpModal.getByLabel("Default mode")).toHaveValue("");
// mode
From dfc0468af4f845e2575d7186f8299e107826b66d Mon Sep 17 00:00:00 2001
From: Michael Geers
Date: Sun, 11 Oct 2026 14:34:55 +0200
Subject: [PATCH 35/61] Home Assistant: improved entity suggestions (#34654)
---
.../js/components/Helper/Combobox.stories.ts | 17 ++++++
assets/js/components/Helper/Combobox.vue | 46 ++++++----------
server/service/registry.go | 9 ++++
.../charger/homeassistant-switch.yaml | 6 +--
.../definition/charger/homeassistant.yaml | 20 +++----
templates/definition/meter/homeassistant.yaml | 24 ++++-----
.../definition/vehicle/homeassistant.yaml | 10 ++--
util/homeassistant/service.go | 54 +++++++++++++------
util/homeassistant/service_test.go | 41 ++++++++++++++
util/service/demo.go | 2 +-
util/service/helper.go | 9 ----
util/service/network.go | 4 +-
12 files changed, 154 insertions(+), 88 deletions(-)
create mode 100644 util/homeassistant/service_test.go
diff --git a/assets/js/components/Helper/Combobox.stories.ts b/assets/js/components/Helper/Combobox.stories.ts
index 4a6302b778..6860f44f5e 100644
--- a/assets/js/components/Helper/Combobox.stories.ts
+++ b/assets/js/components/Helper/Combobox.stories.ts
@@ -37,6 +37,23 @@ Matching.args = {
options: options.map((o) => ({ ...o, match: o.hint?.startsWith("Fronius") })),
};
+const entities = [
+ { value: "sensor.grid_power", label: "Grid Power", hint: "-25 W", match: true },
+ { value: "sensor.home_power", label: "Home Power", hint: "635 W", match: true, used: true },
+ {
+ value: "sensor.backup_next_scheduled_automatic_backup",
+ label: "Backup Next scheduled automatic backup",
+ hint: "2026-10-11T03:42:17+00:00",
+ },
+ { value: "sensor.backup_manager_state", label: "Backup Manager state", hint: "idle" },
+ { value: "sensor.battery_soc", label: "Battery", hint: "87 %" },
+ { value: "sensor.outdoor_temperature", label: "Outdoor", hint: "12.4 °C" },
+ { value: "sensor.unavailable_device", label: "Unavailable device" },
+];
+
+export const Entities = Template.bind({});
+Entities.args = { id: "power", options: entities };
+
export const Selected = Template.bind({});
Selected.args = { ...Default.args, modelValue: "192.168.31.21" };
diff --git a/assets/js/components/Helper/Combobox.vue b/assets/js/components/Helper/Combobox.vue
index 4729df5645..78244f2991 100644
--- a/assets/js/components/Helper/Combobox.vue
+++ b/assets/js/components/Helper/Combobox.vue
@@ -66,18 +66,20 @@
:aria-selected="option.value === activeValue"
@click="select(option)"
>
- {{ option.value }}
+
+
{{ option.value }}
+
+ {{ $t("config.combobox.used") }}
+
+
- {{ option.label }}
-
-
- {{ $t("config.combobox.used") }}
-
-
- {{ option.hint }}
+
{{ option.label }}
+
+ {{ option.hint }}
+
@@ -266,26 +268,13 @@ export default defineComponent({
.options {
max-height: 20rem;
}
-/* address and name left, badge and brand right, brand is cut first */
-.option {
- display: grid;
- grid-template-columns: minmax(0, 1fr) fit-content(50%);
- column-gap: 1rem;
- align-items: center;
-}
.option-value {
- grid-area: 1 / 1;
-}
-.option-label {
- grid-area: 2 / 1;
-}
-.option-badge {
- grid-area: 1 / 2;
- justify-self: end;
+ font-size: var(--bs-body-font-size);
}
+/* label is cut before the hint, hint never takes more than half the row */
.option-hint {
- grid-area: 2 / 2;
- text-align: end;
+ flex-shrink: 0;
+ max-width: 50%;
}
.searching {
border-top: var(--bs-dropdown-border-width) solid var(--bs-dropdown-divider-bg);
@@ -298,7 +287,6 @@ export default defineComponent({
cursor: pointer;
}
.option--active {
- color: var(--bs-dropdown-link-hover-color);
- background-color: var(--bs-dropdown-link-hover-bg);
+ background-color: var(--evcc-gray-25);
}
diff --git a/server/service/registry.go b/server/service/registry.go
index 6997da72cf..c1972cb5c8 100644
--- a/server/service/registry.go
+++ b/server/service/registry.go
@@ -5,6 +5,15 @@ import (
"sync"
)
+// Option is a service suggestion with context
+type Option struct {
+ Value string `json:"value"`
+ Label string `json:"label,omitempty"`
+ Hint string `json:"hint,omitempty"`
+ Match bool `json:"match,omitempty"`
+ Used bool `json:"used,omitempty"`
+}
+
var (
mu sync.Mutex
registry = make(map[string]http.Handler)
diff --git a/templates/definition/charger/homeassistant-switch.yaml b/templates/definition/charger/homeassistant-switch.yaml
index e28b94802d..3ba747186e 100644
--- a/templates/definition/charger/homeassistant-switch.yaml
+++ b/templates/definition/charger/homeassistant-switch.yaml
@@ -39,7 +39,7 @@ params:
description:
de: Entity ID für Leistungsmessung
en: Entity ID for power measurement
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=W,kW
example: sensor.smartsocket_power
- name: temp
description:
@@ -48,7 +48,7 @@ params:
help:
de: Nur für Heizgeräte
en: Heating devices only
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=°C,°F,K
example: sensor.heatpump_temperature
advanced: true
- name: limittemp
@@ -58,7 +58,7 @@ params:
help:
de: Im Gerät konfigurierte Begrenzung, nur für Heizgeräte
en: Limit configured in the device, heating devices only
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=°C,°F,K
example: sensor.heatpump_target_temperature
advanced: true
- preset: switchsocket
diff --git a/templates/definition/charger/homeassistant.yaml b/templates/definition/charger/homeassistant.yaml
index fb5f8ec765..cd411f100f 100644
--- a/templates/definition/charger/homeassistant.yaml
+++ b/templates/definition/charger/homeassistant.yaml
@@ -85,7 +85,7 @@ params:
description:
de: Maximale Stromstärke-Entität [A]
en: Maximum current entity [A]
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number&unit=A
example: "number.charger_max_current"
required: true
help:
@@ -104,7 +104,7 @@ params:
description:
de: Leistungsentität
en: Power entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=W,kW
example: "sensor.charger_power"
advanced: true
help:
@@ -114,7 +114,7 @@ params:
description:
de: Energieentität
en: Energy entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=Wh,kWh,MWh
example: "sensor.charger_energy"
advanced: true
help:
@@ -124,7 +124,7 @@ params:
description:
de: Temperaturentität
en: Temperature entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,input_number
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,input_number&unit=°C,°F,K
example: "sensor.boiler_temperature"
advanced: true
help:
@@ -134,7 +134,7 @@ params:
description:
de: L1 Stromentität
en: L1 current entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=A
example: "sensor.charger_current_l1"
advanced: true
help:
@@ -144,7 +144,7 @@ params:
description:
de: L2 Stromentität
en: L2 current entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=A
example: "sensor.charger_current_l2"
advanced: true
help:
@@ -154,7 +154,7 @@ params:
description:
de: L3 Stromentität
en: L3 current entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=A
example: "sensor.charger_current_l3"
advanced: true
help:
@@ -164,7 +164,7 @@ params:
description:
de: L1 Spannungsentität
en: L1 voltage entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=V
example: "sensor.charger_voltage_l1"
advanced: true
help:
@@ -174,7 +174,7 @@ params:
description:
de: L2 Spannungsentität
en: L2 voltage entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=V
example: "sensor.charger_voltage_l2"
advanced: true
help:
@@ -184,7 +184,7 @@ params:
description:
de: L3 Spannungsentität
en: L3 voltage entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=V
example: "sensor.charger_voltage_l3"
advanced: true
help:
diff --git a/templates/definition/meter/homeassistant.yaml b/templates/definition/meter/homeassistant.yaml
index b3a474680b..7b96098ea4 100644
--- a/templates/definition/meter/homeassistant.yaml
+++ b/templates/definition/meter/homeassistant.yaml
@@ -31,7 +31,7 @@ params:
de: Leistungsentität
en: Power Entity
required: true
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=W,kW
example: "sensor.house_power"
help:
en: Entity ID for instantaneous power measurement in watts. The entity must provide numeric values only (e.g., "1234", not "1234 W").
@@ -40,7 +40,7 @@ params:
description:
de: Energieentität
en: Energy Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=Wh,kWh,MWh
example: "sensor.house_energy"
advanced: true
help:
@@ -50,7 +50,7 @@ params:
description:
de: Energieentität (Gegenrichtung)
en: Return Energy Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=Wh,kWh,MWh
example: "sensor.house_return_energy"
advanced: true
help:
@@ -60,7 +60,7 @@ params:
description:
de: L1 Stromentität
en: L1 Current Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=A
example: "sensor.house_current_l1"
advanced: true
help:
@@ -70,7 +70,7 @@ params:
description:
de: L2 Stromentität
en: L2 Current Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=A
example: "sensor.house_current_l2"
advanced: true
help:
@@ -80,7 +80,7 @@ params:
description:
de: L3 Stromentität
en: L3 Current Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=A
example: "sensor.house_current_l3"
advanced: true
help:
@@ -90,7 +90,7 @@ params:
description:
de: L1 Spannungsentität
en: L1 Voltage Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=V
example: "sensor.house_voltage_l1"
advanced: true
help:
@@ -100,7 +100,7 @@ params:
description:
de: L2 Spannungsentität
en: L2 Voltage Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=V
example: "sensor.house_voltage_l2"
advanced: true
help:
@@ -110,7 +110,7 @@ params:
description:
de: L3 Spannungsentität
en: L3 Voltage Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=V
example: "sensor.house_voltage_l3"
advanced: true
help:
@@ -121,7 +121,7 @@ params:
description:
de: Batterieladestand
en: Battery State of Charge
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=%25
example: "sensor.battery_soc"
advanced: true
help:
@@ -165,7 +165,7 @@ params:
description:
de: Minimaler Ladestand (Entität)
en: Minimum SoC Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,number,input_number
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,number,input_number&unit=%25
example: "number.battery_min_soc"
advanced: true
help:
@@ -176,7 +176,7 @@ params:
description:
de: Maximaler Ladestand (Entität)
en: Maximum SoC Entity
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,number,input_number
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,number,input_number&unit=%25
example: "number.battery_max_soc"
advanced: true
help:
diff --git a/templates/definition/vehicle/homeassistant.yaml b/templates/definition/vehicle/homeassistant.yaml
index 779447468c..b55d750dd0 100644
--- a/templates/definition/vehicle/homeassistant.yaml
+++ b/templates/definition/vehicle/homeassistant.yaml
@@ -30,7 +30,7 @@ params:
description:
de: Ladezustand [%]
en: State of charge [%]
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=%25
example: "sensor.vehicle_soc"
required: true
help:
@@ -40,7 +40,7 @@ params:
description:
de: Restreichweite [km]
en: Remaining range [km]
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=km
example: "sensor.vehicle_range"
help:
en: Entity ID for the vehicle's remaining range in kilometers
@@ -85,7 +85,7 @@ params:
description:
de: Ziel-Ladezustand [%]
en: Target state of charge [%]
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number&unit=%25
example: "number.vehicle_target_state_of_charge"
help:
en: Entity ID for the vehicle's target state of charge in percent (`number` or `input_number` entity)
@@ -94,7 +94,7 @@ params:
description:
de: Kilometerstand [km]
en: Odometer [km]
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor&unit=km
example: "sensor.vehicle_odometer"
help:
en: Entity ID for the vehicle's odometer reading in kilometers
@@ -141,7 +141,7 @@ params:
description:
de: Ladestromstärke setzen [A]
en: Set charging current [A]
- service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number
+ service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number&unit=A
example: "number.vehicle_charging_current"
help:
en: Entity ID for setting the maximum charging current in amperes (`number` or `input_number` entity)
diff --git a/util/homeassistant/service.go b/util/homeassistant/service.go
index c93f879f7b..59f7212ebe 100644
--- a/util/homeassistant/service.go
+++ b/util/homeassistant/service.go
@@ -1,6 +1,7 @@
package homeassistant
import (
+ "cmp"
"encoding/json"
"errors"
"maps"
@@ -28,7 +29,34 @@ func getInstances(w http.ResponseWriter, req *http.Request) {
mu.Lock()
defer mu.Unlock()
- jsonWrite(w, slices.Sorted(maps.Values(instances)))
+ res := make([]service.Option, 0, len(instances))
+ for name, uri := range instances {
+ // match keeps the single-instance auto-fill
+ res = append(res, service.Option{Value: uri, Label: name, Match: true})
+ }
+
+ slices.SortFunc(res, func(a, b service.Option) int { return cmp.Compare(a.Value, b.Value) })
+ jsonWrite(w, res)
+}
+
+func entityOptions(states []StateResponse, domains, units []string) []service.Option {
+ var res []service.Option
+ for _, e := range states {
+ if !matchesDomains(e.EntityId, domains) {
+ continue
+ }
+ o := service.Option{
+ Value: e.EntityId,
+ Label: e.Attributes.FriendlyName,
+ Match: slices.Contains(units, e.Attributes.UnitOfMeasurement),
+ }
+ if e.State != "unavailable" && e.State != "unknown" {
+ o.Hint = strings.TrimSpace(e.State + " " + e.Attributes.UnitOfMeasurement)
+ }
+ res = append(res, o)
+ }
+ slices.SortFunc(res, func(a, b service.Option) int { return cmp.Compare(a.Value, b.Value) })
+ return res
}
func connectionFromRequest(req *http.Request) (*Connection, error) {
@@ -46,10 +74,10 @@ func connectionFromRequest(req *http.Request) (*Connection, error) {
return NewConnection(log, uri, "", insecure)
}
-// domainsFromRequest parses the comma-separated "domain" query parameter.
-func domainsFromRequest(req *http.Request) []string {
- if domain := req.URL.Query().Get("domain"); domain != "" {
- return strings.Split(domain, ",")
+// queryList parses a comma-separated query parameter.
+func queryList(req *http.Request, key string) []string {
+ if v := req.URL.Query().Get(key); v != "" {
+ return strings.Split(v, ",")
}
return nil
}
@@ -81,17 +109,9 @@ func getEntities(w http.ResponseWriter, req *http.Request) {
return
}
- domains := domainsFromRequest(req)
-
- var result []string
- for _, e := range states {
- if matchesDomains(e.EntityId, domains) {
- result = append(result, e.EntityId)
- }
- }
-
- w.Header().Set("Cache-control", "max-age=300")
- jsonWrite(w, result)
+ // short cache: hints carry live values, the form refetches on every change
+ w.Header().Set("Cache-control", "max-age=30")
+ jsonWrite(w, entityOptions(states, queryList(req, "domain"), queryList(req, "unit")))
}
func getServices(w http.ResponseWriter, req *http.Request) {
@@ -101,7 +121,7 @@ func getServices(w http.ResponseWriter, req *http.Request) {
return
}
- domains := domainsFromRequest(req)
+ domains := queryList(req, "domain")
seen := make(map[string]struct{})
diff --git a/util/homeassistant/service_test.go b/util/homeassistant/service_test.go
new file mode 100644
index 0000000000..98ae7a6c2b
--- /dev/null
+++ b/util/homeassistant/service_test.go
@@ -0,0 +1,41 @@
+package homeassistant
+
+import (
+ "net/http/httptest"
+ "testing"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+)
+
+func state(id, state, unit, name string) StateResponse {
+ var s StateResponse
+ s.EntityId = id
+ s.State = state
+ s.Attributes.UnitOfMeasurement = unit
+ s.Attributes.FriendlyName = name
+ return s
+}
+
+func TestEntityOptions(t *testing.T) {
+ states := []StateResponse{
+ state("sensor.grid_power", "1234", "W", "Grid Power"),
+ state("switch.garden", "on", "", "Garden"),
+ state("sensor.backup", "unavailable", "", "Backup"),
+ }
+
+ assert.Equal(t, []service.Option{
+ {Value: "sensor.backup", Label: "Backup"},
+ {Value: "sensor.grid_power", Label: "Grid Power", Hint: "1234 W", Match: true},
+ }, entityOptions(states, []string{"sensor"}, []string{"W", "kW"}))
+
+ assert.Len(t, entityOptions(states, nil, nil), 3)
+ assert.Empty(t, entityOptions(states, []string{"light"}, nil))
+}
+
+func TestQueryList(t *testing.T) {
+ req := httptest.NewRequest("GET", "/entities?domain=sensor,number&unit=%25", nil)
+ assert.Equal(t, []string{"sensor", "number"}, queryList(req, "domain"))
+ assert.Equal(t, []string{"%"}, queryList(req, "unit"))
+ assert.Nil(t, queryList(req, "missing"))
+}
diff --git a/util/service/demo.go b/util/service/demo.go
index c5a2e03232..2c2981b11d 100644
--- a/util/service/demo.go
+++ b/util/service/demo.go
@@ -42,7 +42,7 @@ func getCity(w http.ResponseWriter, req *http.Request) {
}
func getOptions(w http.ResponseWriter, req *http.Request) {
- json.NewEncoder(w).Encode([]Option{
+ json.NewEncoder(w).Encode([]service.Option{
{Value: "192.0.2.10", Label: "alpha", Hint: "Vendor A", Match: true, Used: true},
{Value: "192.0.2.20", Label: "beta", Hint: "Vendor B"},
{Value: "192.0.2.30", Label: "gamma", Hint: "Vendor C", Match: true},
diff --git a/util/service/helper.go b/util/service/helper.go
index 92db757469..e3cc9f6356 100644
--- a/util/service/helper.go
+++ b/util/service/helper.go
@@ -10,15 +10,6 @@ import (
"github.com/spf13/cast"
)
-// Option is a service suggestion with context
-type Option struct {
- Value string `json:"value"`
- Label string `json:"label,omitempty"`
- Hint string `json:"hint,omitempty"`
- Match bool `json:"match,omitempty"`
- Used bool `json:"used,omitempty"`
-}
-
// toString converts to canonical string representation
func toString(value any, castType string) string {
res := value
diff --git a/util/service/network.go b/util/service/network.go
index d460314f41..d9d83023ca 100644
--- a/util/service/network.go
+++ b/util/service/network.go
@@ -149,13 +149,13 @@ func getHosts(w http.ResponseWriter, req *http.Request) {
used := usedHosts(allDeviceConfigs())
- res := make([]Option, 0)
+ res := make([]service.Option, 0)
for _, h := range hosts(w, req, mdnsTypes(all)) {
match := func(d templates.Discovery) bool {
return d.Match(h.Names(), h.MAC, h.Services)
}
- o := Option{
+ o := service.Option{
Value: h.IP,
Label: h.Hostname,
Hint: discovery.Vendor(h.MAC),
From 470fd6a1db0a95b716c41c399b917c9100a494da Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 14:57:41 +0200
Subject: [PATCH 36/61] Tibber: rich vehicle suggestions (#34693)
---
vehicle/tibber/service.go | 35 +++++++++++++++++++++++++---------
vehicle/tibber/service_test.go | 24 +++++++++++++++++++++++
2 files changed, 50 insertions(+), 9 deletions(-)
create mode 100644 vehicle/tibber/service_test.go
diff --git a/vehicle/tibber/service.go b/vehicle/tibber/service.go
index b5c5fbc2fd..230a3aab29 100644
--- a/vehicle/tibber/service.go
+++ b/vehicle/tibber/service.go
@@ -1,10 +1,12 @@
package tibber
import (
+ "cmp"
"context"
"encoding/json"
"net/http"
"slices"
+ "strings"
"github.com/evcc-io/evcc/server/service"
"github.com/evcc-io/evcc/util"
@@ -17,17 +19,35 @@ func init() {
service.Register("tibber", mux)
}
-// getVehicles lists the external ids of the vehicles in the account, driving
-// vehicle selection in the template. It reuses the OAuth instance created for
-// the same client, so results appear once the user has authorized via the UI.
+// vehicleOptions maps the device list to suggestions keyed by VIN
+func vehicleOptions(vehicles []Device) []service.Option {
+ res := make([]service.Option, 0, len(vehicles))
+ for _, v := range vehicles {
+ model := strings.TrimSpace(v.Info.Brand + " " + v.Info.Model)
+ // every vehicle is a valid choice, a single one auto-fills the vin
+ o := service.Option{Value: v.VIN(), Label: strings.TrimSpace(v.Info.Name), Match: true}
+ if o.Label == "" {
+ o.Label = model
+ } else {
+ o.Hint = model
+ }
+ res = append(res, o)
+ }
+ slices.SortFunc(res, func(a, b service.Option) int { return cmp.Compare(a.Value, b.Value) })
+ return res
+}
+
+// getVehicles lists the vehicles in the account, driving vehicle selection in
+// the template. It reuses the OAuth instance created for the same client, so
+// results appear once the user has authorized via the UI.
func getVehicles(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
q := req.URL.Query()
clientID, clientSecret, redirectURI := q.Get("clientid"), q.Get("clientsecret"), q.Get("redirecturi")
- ids := []string{}
- defer func() { _ = json.NewEncoder(w).Encode(ids) }()
+ res := []service.Option{}
+ defer func() { _ = json.NewEncoder(w).Encode(res) }()
if clientID == "" || clientSecret == "" {
return
@@ -46,8 +66,5 @@ func getVehicles(w http.ResponseWriter, req *http.Request) {
return
}
- for _, v := range vehicles {
- ids = append(ids, v.VIN())
- }
- slices.Sort(ids)
+ res = vehicleOptions(vehicles)
}
diff --git a/vehicle/tibber/service_test.go b/vehicle/tibber/service_test.go
new file mode 100644
index 0000000000..942bfaaab0
--- /dev/null
+++ b/vehicle/tibber/service_test.go
@@ -0,0 +1,24 @@
+package tibber
+
+import (
+ "testing"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestVehicleOptions(t *testing.T) {
+ vehicles := []Device{
+ {ExternalID: "tesla:5YJSA1E26MF1234567", Info: DeviceInfo{Name: "Model S", Brand: "Tesla", Model: "Model S"}},
+ {ExternalID: "vw:WVWZZZ1KZAW000001", Info: DeviceInfo{Name: " ", Brand: "Volkswagen", Model: "ID.3"}},
+ {ExternalID: "skoda:TMBJJ7NX0L0000001"},
+ }
+
+ assert.Equal(t, []service.Option{
+ {Value: "5YJSA1E26MF1234567", Label: "Model S", Hint: "Tesla Model S", Match: true},
+ {Value: "TMBJJ7NX0L0000001", Match: true},
+ {Value: "WVWZZZ1KZAW000001", Label: "Volkswagen ID.3", Match: true},
+ }, vehicleOptions(vehicles))
+
+ assert.Empty(t, vehicleOptions(nil))
+}
From bc539ee237a0b17532d2a77994c0f78b5afa2f52 Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 14:58:19 +0200
Subject: [PATCH 37/61] Octopus: rich device suggestions (#34694)
---
vehicle/octopuskraken/service.go | 35 +++++++++++++++++++-----
vehicle/octopuskraken/service_test.go | 38 +++++++++++++++++++++++++++
2 files changed, 66 insertions(+), 7 deletions(-)
create mode 100644 vehicle/octopuskraken/service_test.go
diff --git a/vehicle/octopuskraken/service.go b/vehicle/octopuskraken/service.go
index 75203cc255..f5b8af1c58 100644
--- a/vehicle/octopuskraken/service.go
+++ b/vehicle/octopuskraken/service.go
@@ -1,9 +1,11 @@
package octopuskraken
import (
+ "cmp"
"encoding/json"
"net/http"
"slices"
+ "strings"
"github.com/evcc-io/evcc/server/service"
"github.com/evcc-io/evcc/util"
@@ -24,7 +26,29 @@ func register(name, baseURI string) {
service.Register(name, mux)
}
-// getDevices lists the ids of the SmartFlex devices in the account, driving
+// deviceTypes maps the Kraken device type enum to a readable hint.
+var deviceTypes = map[string]string{
+ "ELECTRIC_VEHICLES": "Vehicle",
+ "CHARGE_POINTS": "Charge point",
+}
+
+func deviceOptions(devices []Device) []service.Option {
+ res := make([]service.Option, 0, len(devices))
+ for _, d := range devices {
+ parts := []string{cmp.Or(deviceTypes[d.DeviceType], d.DeviceType), d.Provider}
+ res = append(res, service.Option{
+ Value: d.ID,
+ Label: d.Name,
+ Hint: strings.Join(slices.DeleteFunc(parts, func(s string) bool { return s == "" }), " · "),
+ // vehicle template: charge points are listed but don't match
+ Match: d.DeviceType == "ELECTRIC_VEHICLES",
+ })
+ }
+ slices.SortFunc(res, func(a, b service.Option) int { return cmp.Compare(a.Value, b.Value) })
+ return res
+}
+
+// getDevices lists the SmartFlex devices in the account, driving
// device selection in the template.
func getDevices(w http.ResponseWriter, req *http.Request, name, baseURI string) {
w.Header().Set("Content-Type", "application/json")
@@ -32,8 +56,8 @@ func getDevices(w http.ResponseWriter, req *http.Request, name, baseURI string)
q := req.URL.Query()
email, password, account := q.Get("email"), q.Get("password"), q.Get("accountnumber")
- ids := []string{}
- defer func() { _ = json.NewEncoder(w).Encode(ids) }()
+ res := []service.Option{}
+ defer func() { _ = json.NewEncoder(w).Encode(res) }()
if email == "" || password == "" {
return
@@ -58,8 +82,5 @@ func getDevices(w http.ResponseWriter, req *http.Request, name, baseURI string)
return
}
- for _, d := range devices {
- ids = append(ids, d.ID)
- }
- slices.Sort(ids)
+ res = deviceOptions(devices)
}
diff --git a/vehicle/octopuskraken/service_test.go b/vehicle/octopuskraken/service_test.go
new file mode 100644
index 0000000000..30ea676b11
--- /dev/null
+++ b/vehicle/octopuskraken/service_test.go
@@ -0,0 +1,38 @@
+package octopuskraken
+
+import (
+ "testing"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+)
+
+func device(id, name, typ, provider string) Device {
+ return Device{ID: id, Name: name, DeviceType: typ, Provider: provider}
+}
+
+func TestDeviceOptions(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ devices []Device
+ want []service.Option
+ }{
+ {"empty", nil, []service.Option{}},
+ {"sorted by id, vehicles match", []Device{
+ device("dev-2", "Wallbox", "CHARGE_POINTS", "OCPP"),
+ device("dev-1", "My Car", "ELECTRIC_VEHICLES", "TESLA"),
+ }, []service.Option{
+ {Value: "dev-1", Label: "My Car", Hint: "Vehicle · TESLA", Match: true},
+ {Value: "dev-2", Label: "Wallbox", Hint: "Charge point · OCPP"},
+ }},
+ {"unknown type and missing provider", []Device{
+ device("dev-3", "Heat Pump", "HEAT_PUMPS", ""),
+ }, []service.Option{
+ {Value: "dev-3", Label: "Heat Pump", Hint: "HEAT_PUMPS"},
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ assert.Equal(t, tc.want, deviceOptions(tc.devices))
+ })
+ }
+}
From f15588c34f407f79b5cfc9ac7f8685b7c0df916a Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 14:58:45 +0200
Subject: [PATCH 38/61] Circuit: drop stale service todo on meter param
(#34695)
---
templates/definition/circuit/static.yaml | 1 -
1 file changed, 1 deletion(-)
diff --git a/templates/definition/circuit/static.yaml b/templates/definition/circuit/static.yaml
index 68904e59ae..0e4c93d97a 100644
--- a/templates/definition/circuit/static.yaml
+++ b/templates/definition/circuit/static.yaml
@@ -16,7 +16,6 @@ params:
de: Maximal erlaubte Gesamtleistung in diesem Stromkreis.
en: Maximum total power allowed in this circuit.
- name: meter
- # service: TODO
- name: parent
description:
de: Übergeordneter Stromkreis
From 2919f74d8a74617b131eb0ef714125b203509811 Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 14:59:27 +0200
Subject: [PATCH 39/61] EEBus: rich device suggestions for SKI (#34696)
---
server/eebus/eebus.go | 7 +++++++
server/eebus/service.go | 26 ++++++++++++++++++++++----
server/eebus/service_test.go | 26 ++++++++++++++++++++++++++
3 files changed, 55 insertions(+), 4 deletions(-)
create mode 100644 server/eebus/service_test.go
diff --git a/server/eebus/eebus.go b/server/eebus/eebus.go
index 72ab8b9741..ebf391aec0 100644
--- a/server/eebus/eebus.go
+++ b/server/eebus/eebus.go
@@ -470,6 +470,13 @@ func (c *EEBus) clientsFor(ski string) []Device {
return c.clients[ski]
}
+// HasDevices reports whether any configured device uses the given ski
+func (c *EEBus) HasDevices(ski string) bool {
+ c.mux.Lock()
+ defer c.mux.Unlock()
+ return len(c.clientsFor(ski)) > 0
+}
+
// RegisterDevice subscribes a device to the remote service with given ski.
// An empty ski subscribes to the device paired via the SHIP Pairing Service.
func (c *EEBus) RegisterDevice(ski, ip string, device Device) error {
diff --git a/server/eebus/service.go b/server/eebus/service.go
index 06594ed2ae..1d86e8e1b1 100644
--- a/server/eebus/service.go
+++ b/server/eebus/service.go
@@ -1,9 +1,13 @@
package eebus
import (
+ "cmp"
"encoding/json"
"net/http"
+ "slices"
+ "strings"
+ shipapi "github.com/enbility/ship-go/api"
"github.com/evcc-io/evcc/server/service"
)
@@ -16,12 +20,26 @@ func init() {
service.Register("eebus", mux)
}
+// deviceOptions maps discovered services to suggestions, used marks skis already configured
+func deviceOptions(services []shipapi.RemoteMdnsService, used func(ski string) bool) []service.Option {
+ res := make([]service.Option, 0, len(services))
+ for _, s := range services {
+ res = append(res, service.Option{
+ Value: s.Ski,
+ Label: cmp.Or(strings.TrimSpace(s.Brand+" "+s.Model), s.Name, s.ShipID),
+ Hint: cmp.Or(s.Serial, s.ShipID),
+ Match: true, // match keeps the single-device auto-fill
+ Used: used(s.Ski),
+ })
+ }
+ slices.SortFunc(res, func(a, b service.Option) int { return cmp.Compare(a.Value, b.Value) })
+ return res
+}
+
func getServices(w http.ResponseWriter, req *http.Request) {
- var res []string
+ res := []service.Option{}
if instance != nil {
- for _, s := range instance.RemoteServices() {
- res = append(res, s.Ski)
- }
+ res = deviceOptions(instance.RemoteServices(), instance.HasDevices)
}
json.NewEncoder(w).Encode(res)
}
diff --git a/server/eebus/service_test.go b/server/eebus/service_test.go
new file mode 100644
index 0000000000..812d9f2808
--- /dev/null
+++ b/server/eebus/service_test.go
@@ -0,0 +1,26 @@
+package eebus
+
+import (
+ "testing"
+
+ shipapi "github.com/enbility/ship-go/api"
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDeviceOptions(t *testing.T) {
+ services := []shipapi.RemoteMdnsService{
+ {Ski: "bbb", Name: "Wallbox", ShipID: "Wallbox-2", Brand: "Elli", Model: "Charger Pro", Serial: "12345"},
+ {Ski: "aaa", Name: "Steuerbox", ShipID: "FNN-1"},
+ {Ski: "ccc", ShipID: "Heatpump-3", Brand: "Vaillant"},
+ }
+ used := func(ski string) bool { return ski == "bbb" }
+
+ assert.Equal(t, []service.Option{
+ {Value: "aaa", Label: "Steuerbox", Hint: "FNN-1", Match: true},
+ {Value: "bbb", Label: "Elli Charger Pro", Hint: "12345", Match: true, Used: true},
+ {Value: "ccc", Label: "Vaillant", Hint: "Heatpump-3", Match: true},
+ }, deviceOptions(services, used))
+
+ assert.Equal(t, []service.Option{}, deviceOptions(nil, used))
+}
From 90d83663e075ee67de248e90b7837905b3b1e1c7 Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 15:02:21 +0200
Subject: [PATCH 40/61] Viessmann: rich equipment suggestions (#34697)
---
charger/viessmann/api.go | 15 +++++++++--
charger/viessmann/api_test.go | 9 ++++---
charger/viessmann/service.go | 43 ++++++++++++++++++++++---------
charger/viessmann/service_test.go | 42 +++++++++++++++++++++++++-----
4 files changed, 85 insertions(+), 24 deletions(-)
diff --git a/charger/viessmann/api.go b/charger/viessmann/api.go
index 6255504d22..7330e313cf 100644
--- a/charger/viessmann/api.go
+++ b/charger/viessmann/api.go
@@ -12,12 +12,23 @@ import (
// Gateway is a Viessmann communication module.
type Gateway struct {
Serial string `json:"serial"`
+ // GatewayType is the module model, e.g. VitoconnectOPTO2 or TCU301 (IoT API v2 Gateway schema)
+ GatewayType string `json:"gatewayType"`
+}
+
+// Address is the location of an installation (IoT API v2 Installation schema).
+type Address struct {
+ Street string `json:"street"`
+ HouseNumber string `json:"houseNumber"`
+ City string `json:"city"`
}
// Installation is a Viessmann installation including its gateways.
type Installation struct {
- ID int `json:"id"`
- Gateways []Gateway `json:"gateways"`
+ ID int `json:"id"`
+ Description string `json:"description"`
+ Address Address `json:"address"`
+ Gateways []Gateway `json:"gateways"`
}
// API is the Viessmann IoT API client.
diff --git a/charger/viessmann/api_test.go b/charger/viessmann/api_test.go
index 9e05a5f587..905cbb28e8 100644
--- a/charger/viessmann/api_test.go
+++ b/charger/viessmann/api_test.go
@@ -28,8 +28,8 @@ func TestInstallations(t *testing.T) {
require.Equal(t, "true", r.URL.Query().Get("includeGateways"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[
- {"id":3242119,"gateways":[{"serial":"7472258009383262"},{"serial":"7472258009383263"}]},
- {"id":1000001,"gateways":[{"serial":"9999999999999999"}]}
+ {"id":3242119,"description":"Home","address":{"street":"Main St","houseNumber":"1","zip":"10115","city":"Berlin","country":"de"},"gateways":[{"serial":"7472258009383262","version":"1.4.0.0","gatewayType":"VitoconnectOPTO2"},{"serial":"7472258009383263","gatewayType":"TCU301"}]},
+ {"id":1000001,"description":null,"address":{"street":null,"city":null},"gateways":[{"serial":"9999999999999999"}]}
]}`))
})
@@ -37,8 +37,11 @@ func TestInstallations(t *testing.T) {
require.NoError(t, err)
require.Len(t, res, 2)
assert.Equal(t, 3242119, res[0].ID)
+ assert.Equal(t, "Home", res[0].Description)
+ assert.Equal(t, Address{Street: "Main St", HouseNumber: "1", City: "Berlin"}, res[0].Address)
assert.Len(t, res[0].Gateways, 2)
- assert.Equal(t, "7472258009383262", res[0].Gateways[0].Serial)
+ assert.Equal(t, Gateway{Serial: "7472258009383262", GatewayType: "VitoconnectOPTO2"}, res[0].Gateways[0])
+ assert.Equal(t, Installation{ID: 1000001, Gateways: []Gateway{{Serial: "9999999999999999"}}}, res[1])
}
func TestInstallationsError(t *testing.T) {
diff --git a/charger/viessmann/service.go b/charger/viessmann/service.go
index f3318a6841..f05642c888 100644
--- a/charger/viessmann/service.go
+++ b/charger/viessmann/service.go
@@ -1,12 +1,14 @@
package viessmann
import (
+ "cmp"
"context"
"encoding/json"
"errors"
"net/http"
"slices"
"strconv"
+ "strings"
"github.com/evcc-io/evcc/server/service"
"github.com/evcc-io/evcc/util"
@@ -61,35 +63,52 @@ func equipment(req *http.Request) ([]Installation, error) {
return api.Installations()
}
-// values extracts the installation ids or, with gateways set, the deduplicated
-// gateway serials of the given installations.
-func values(installations []Installation, gateways bool) []string {
- res := []string{}
+// label describes the installation by its description or, if unset, its address.
+func (inst Installation) label() string {
+ if desc := strings.TrimSpace(inst.Description); desc != "" {
+ return desc
+ }
+
+ street := strings.TrimSpace(inst.Address.Street + " " + inst.Address.HouseNumber)
+ return strings.Trim(street+", "+inst.Address.City, ", ")
+}
+
+// equipmentOptions lists the installations or, with gateways set, the deduplicated
+// gateways of the given installations, described by location and gateway type.
+// All options are valid choices, match keeps the single-item auto-fill.
+func equipmentOptions(installations []Installation, gateways bool) []service.Option {
+ res := []service.Option{}
for _, inst := range installations {
if !gateways {
- res = append(res, strconv.Itoa(inst.ID))
+ serials := make([]string, 0, len(inst.Gateways))
+ for _, gw := range inst.Gateways {
+ if serial := strings.TrimSpace(gw.Serial); serial != "" {
+ serials = append(serials, serial)
+ }
+ }
+ res = append(res, service.Option{Value: strconv.Itoa(inst.ID), Label: inst.label(), Hint: strings.Join(serials, ", "), Match: true})
continue
}
for _, gw := range inst.Gateways {
- if !slices.Contains(res, gw.Serial) {
- res = append(res, gw.Serial)
+ if !slices.ContainsFunc(res, func(o service.Option) bool { return o.Value == gw.Serial }) {
+ res = append(res, service.Option{Value: gw.Serial, Label: strings.TrimSpace(gw.GatewayType), Hint: inst.label(), Match: true})
}
}
}
- slices.Sort(res)
+ slices.SortFunc(res, func(a, b service.Option) int { return cmp.Compare(a.Value, b.Value) })
return res
}
-// getEquipment lists the account's installation ids or, with detail=gateways,
-// their gateway serials, driving the respective selection in the template.
+// getEquipment lists the account's installations or, with detail=gateways,
+// their gateways, driving the respective selection in the template.
func getEquipment(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
- res := []string{}
+ res := []service.Option{}
defer func() { _ = json.NewEncoder(w).Encode(res) }()
installations, err := equipment(req)
@@ -101,7 +120,7 @@ func getEquipment(w http.ResponseWriter, req *http.Request) {
return
}
- res = values(installations, req.URL.Query().Get("detail") == "gateways")
+ res = equipmentOptions(installations, req.URL.Query().Get("detail") == "gateways")
}
// measurementFeatures are the data points the charger template's optional
diff --git a/charger/viessmann/service_test.go b/charger/viessmann/service_test.go
index bdbfb7b414..94706a9916 100644
--- a/charger/viessmann/service_test.go
+++ b/charger/viessmann/service_test.go
@@ -5,22 +5,50 @@ import (
"net/http/httptest"
"testing"
+ "github.com/evcc-io/evcc/server/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
-func TestValues(t *testing.T) {
+func TestInstallationLabel(t *testing.T) {
+ for _, tc := range []struct {
+ inst Installation
+ label string
+ }{
+ {Installation{Description: "Home", Address: Address{Street: "Main St", City: "Berlin"}}, "Home"},
+ {Installation{Description: " ", Address: Address{City: "Berlin"}}, "Berlin"},
+ {Installation{Address: Address{Street: "Main St", HouseNumber: "1", City: "Berlin"}}, "Main St 1, Berlin"},
+ {Installation{Address: Address{Street: "Main St"}}, "Main St"},
+ {Installation{Address: Address{City: "Berlin"}}, "Berlin"},
+ {Installation{}, ""},
+ } {
+ assert.Equal(t, tc.label, tc.inst.label())
+ }
+}
+
+func TestEquipmentOptions(t *testing.T) {
installations := []Installation{
- {ID: 3242119, Gateways: []Gateway{{Serial: "7472258009383263"}, {Serial: "7472258009383262"}}},
- {ID: 1000001, Gateways: []Gateway{{Serial: "7472258009383262"}}},
+ {ID: 3242119, Description: "Home", Gateways: []Gateway{{Serial: "7472258009383263", GatewayType: "TCU301 "}, {Serial: "7472258009383262", GatewayType: "VitoconnectOPTO2"}}},
+ {ID: 1000001, Address: Address{Street: "Main St", HouseNumber: "1", City: "Berlin"}, Gateways: []Gateway{{Serial: "7472258009383262", GatewayType: "VitoconnectOPTO2"}}},
+ {ID: 2000002},
}
- assert.Equal(t, []string{"1000001", "3242119"}, values(installations, false))
- assert.Equal(t, []string{"7472258009383262", "7472258009383263"}, values(installations, true))
+ // all options match: single-item lists are auto-filled by the config UI
+ assert.Equal(t, []service.Option{
+ {Value: "1000001", Label: "Main St 1, Berlin", Hint: "7472258009383262", Match: true},
+ {Value: "2000002", Match: true},
+ {Value: "3242119", Label: "Home", Hint: "7472258009383263, 7472258009383262", Match: true},
+ }, equipmentOptions(installations, false))
+
+ // shared gateway listed once, attributed to the first installation
+ assert.Equal(t, []service.Option{
+ {Value: "7472258009383262", Label: "VitoconnectOPTO2", Hint: "Home", Match: true},
+ {Value: "7472258009383263", Label: "TCU301", Hint: "Home", Match: true},
+ }, equipmentOptions(installations, true))
// empty list, not null
- assert.Equal(t, []string{}, values(nil, false))
- assert.Equal(t, []string{}, values(nil, true))
+ assert.Equal(t, []service.Option{}, equipmentOptions(nil, false))
+ assert.Equal(t, []service.Option{}, equipmentOptions(nil, true))
}
// The service handler must return an empty JSON list when the user has not
From af62776af253405478e435b396ee2caefcca4cee Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 15:05:04 +0200
Subject: [PATCH 41/61] Fritz!DECT: rich device suggestions (#34692)
---
meter/fritz/smarthome/service.go | 37 ++++++++++++++++++++++-----
meter/fritz/smarthome/service_test.go | 25 ++++++++++++++++++
2 files changed, 55 insertions(+), 7 deletions(-)
create mode 100644 meter/fritz/smarthome/service_test.go
diff --git a/meter/fritz/smarthome/service.go b/meter/fritz/smarthome/service.go
index 367ae88854..41b8d09ed9 100644
--- a/meter/fritz/smarthome/service.go
+++ b/meter/fritz/smarthome/service.go
@@ -1,6 +1,7 @@
package smarthome
import (
+ "cmp"
"encoding/json"
"errors"
"fmt"
@@ -13,7 +14,6 @@ import (
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
- "github.com/samber/lo"
)
func init() {
@@ -63,13 +63,36 @@ func getDevices(w http.ResponseWriter, r *http.Request) {
return
}
- ains := lo.Map(devices, func(d Device, _ int) string {
- return d.AIN
- })
- slices.Sort(ains)
-
w.Header().Set("Cache-control", "max-age=60")
- jsonWrite(w, ains)
+ jsonWrite(w, deviceOptions(devices))
+}
+
+func deviceOptions(devices []Device) []service.Option {
+ res := make([]service.Option, 0, len(devices))
+ for _, d := range devices {
+ // every device is a valid choice, a single one auto-fills the ain
+ res = append(res, service.Option{
+ Value: d.AIN,
+ Label: strings.TrimSpace(d.Name),
+ Hint: strings.TrimSpace(d.ProductName),
+ Match: true,
+ })
+ }
+ // connected devices first, then by ain
+ connected := func(o service.Option) bool {
+ i := slices.IndexFunc(devices, func(d Device) bool { return d.AIN == o.Value })
+ return devices[i].IsConnected
+ }
+ slices.SortFunc(res, func(a, b service.Option) int {
+ if ca, cb := connected(a), connected(b); ca != cb {
+ if ca {
+ return -1
+ }
+ return 1
+ }
+ return cmp.Compare(a.Value, b.Value)
+ })
+ return res
}
func jsonWrite(w http.ResponseWriter, data any) {
diff --git a/meter/fritz/smarthome/service_test.go b/meter/fritz/smarthome/service_test.go
new file mode 100644
index 0000000000..ee75f83858
--- /dev/null
+++ b/meter/fritz/smarthome/service_test.go
@@ -0,0 +1,25 @@
+package smarthome
+
+import (
+ "testing"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDeviceOptions(t *testing.T) {
+ devices := []Device{
+ {AIN: "11657 0123456", Name: "Wallbox", ProductName: " FRITZ!DECT 210 ", IsConnected: true},
+ {AIN: "08761 0000001", Name: "Garage", ProductName: "", IsConnected: false},
+ {AIN: "09995 0000002", Name: "Pump", IsConnected: true},
+ }
+
+ // connected devices first, then by ain
+ assert.Equal(t, []service.Option{
+ {Value: "09995 0000002", Label: "Pump", Match: true},
+ {Value: "11657 0123456", Label: "Wallbox", Hint: "FRITZ!DECT 210", Match: true},
+ {Value: "08761 0000001", Label: "Garage", Match: true},
+ }, deviceOptions(devices))
+
+ assert.Empty(t, deviceOptions(nil))
+}
From 12bc7eeea472843150e588ef9604b0ebdcf453d1 Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 15:11:46 +0200
Subject: [PATCH 42/61] Tasmota: channel suggestions (#34703)
---
meter/tasmota/service.go | 117 ++++++++++++++++++++++
meter/tasmota/service_test.go | 111 ++++++++++++++++++++
templates/definition/charger/tasmota.yaml | 1 +
templates/definition/meter/tasmota.yaml | 1 +
4 files changed, 230 insertions(+)
create mode 100644 meter/tasmota/service.go
create mode 100644 meter/tasmota/service_test.go
diff --git a/meter/tasmota/service.go b/meter/tasmota/service.go
new file mode 100644
index 0000000000..901aa006e2
--- /dev/null
+++ b/meter/tasmota/service.go
@@ -0,0 +1,117 @@
+package tasmota
+
+import (
+ "cmp"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/evcc-io/evcc/util"
+)
+
+func init() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /channels", getChannels)
+
+ service.Register("tasmota", mux)
+}
+
+// statusResponse combines the Status and StatusSTS parts of the Status 0 response
+type statusResponse struct {
+ StatusResponse
+ StatusSTSResponse
+}
+
+// relays returns the state of relays 1..8, empty when the relay does not exist
+func (res StatusSTSResponse) relays() [8]string {
+ sts := res.StatusSTS
+ return [8]string{cmp.Or(sts.Power1, sts.Power), sts.Power2, sts.Power3, sts.Power4, sts.Power5, sts.Power6, sts.Power7, sts.Power8}
+}
+
+// channelOptions lists relay and energy channels. Relays match for chargers, channels with energy readings for meters.
+func channelOptions(st statusResponse, sns StatusSNSResponse, meter bool) []service.Option {
+ relays := st.relays()
+ power := sns.StatusSNS.Energy.Power
+
+ var res []service.Option
+ for ch := 1; ch <= 8; ch++ {
+ relay := relays[ch-1] != ""
+ energy := ch <= len(power)
+ if !relay && !energy {
+ continue
+ }
+
+ match := relay
+ if meter {
+ match = energy
+ }
+
+ o := service.Option{
+ Value: strconv.Itoa(ch),
+ Label: fmt.Sprintf("Channel %d", ch),
+ Match: match,
+ }
+ if names := st.Status.FriendlyName; ch <= len(names) {
+ o.Label = cmp.Or(strings.TrimSpace(names[ch-1]), o.Label)
+ }
+ if energy {
+ o.Hint = fmt.Sprintf("%.0f W", power[ch-1])
+ }
+
+ res = append(res, o)
+ }
+
+ return res
+}
+
+// channels queries the device; unreachable or unauthorized devices yield no options
+func channels(uri, user, password string, meter bool) []service.Option {
+ // NewConnection redacts the credentials on the shared logger
+ log := util.NewLogger("tasmota")
+
+ // channel is irrelevant for discovery
+ conn, err := NewConnection(uri, user, password, []int{1}, 0)
+ if err != nil {
+ log.DEBUG.Println(err)
+ return nil
+ }
+
+ var st statusResponse
+ if err := conn.cmd("Status 0", &st); err != nil {
+ log.DEBUG.Println(err)
+ return nil
+ }
+
+ var sns StatusSNSResponse
+ if err := conn.cmd("Status 8", &sns); err != nil {
+ log.DEBUG.Println(err)
+ return nil
+ }
+
+ return channelOptions(st, sns, meter)
+}
+
+func getChannels(w http.ResponseWriter, req *http.Request) {
+ q := req.URL.Query()
+ meter, _ := strconv.ParseBool(q.Get("meter"))
+
+ var res []service.Option
+ if uri := q.Get("uri"); uri != "" {
+ res = channels(uri, q.Get("user"), q.Get("password"), meter)
+ }
+ if res == nil {
+ res = []service.Option{}
+ }
+
+ // short cache: hints carry live values, the form refetches on every change
+ w.Header().Set("Cache-control", "max-age=30")
+ jsonWrite(w, res)
+}
+
+func jsonWrite(w http.ResponseWriter, data any) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(data)
+}
diff --git a/meter/tasmota/service_test.go b/meter/tasmota/service_test.go
new file mode 100644
index 0000000000..314b268a0d
--- /dev/null
+++ b/meter/tasmota/service_test.go
@@ -0,0 +1,111 @@
+package tasmota
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestChannelOptions(t *testing.T) {
+ tc := []struct {
+ name string
+ status string
+ sns string
+ meter bool
+ expect []service.Option
+ }{
+ {
+ name: "single relay charger",
+ status: `{"Status":{"FriendlyName":["Pump"]},"StatusSTS":{"POWER":"ON"}}`,
+ sns: `{"StatusSNS":{"Time":"2023-02-05T20:31:48"}}`,
+ expect: []service.Option{{Value: "1", Label: "Pump", Match: true}},
+ },
+ {
+ name: "single relay meter without energy",
+ status: `{"Status":{"FriendlyName":["Pump"]},"StatusSTS":{"POWER":"ON"}}`,
+ sns: `{"StatusSNS":{"Time":"2023-02-05T20:31:48"}}`,
+ meter: true,
+ expect: []service.Option{{Value: "1", Label: "Pump"}},
+ },
+ {
+ name: "dual relay with energy",
+ status: `{"Status":{"FriendlyName":["Pump"," "]},"StatusSTS":{"POWER1":"ON","POWER2":"OFF"}}`,
+ sns: `{"StatusSNS":{"ENERGY":{"Power":[8.15,0]}}}`,
+ meter: true,
+ expect: []service.Option{
+ {Value: "1", Label: "Pump", Hint: "8 W", Match: true},
+ {Value: "2", Label: "Channel 2", Hint: "0 W", Match: true},
+ },
+ },
+ {
+ name: "energy only meter",
+ status: `{"Status":{"FriendlyName":["Meter"]},"StatusSTS":{}}`,
+ sns: `{"StatusSNS":{"ENERGY":{"Power":[100,200,300]}}}`,
+ meter: true,
+ expect: []service.Option{
+ {Value: "1", Label: "Meter", Hint: "100 W", Match: true},
+ {Value: "2", Label: "Channel 2", Hint: "200 W", Match: true},
+ {Value: "3", Label: "Channel 3", Hint: "300 W", Match: true},
+ },
+ },
+ {
+ name: "no channels",
+ status: `{"Status":{},"StatusSTS":{}}`,
+ sns: `{"StatusSNS":{}}`,
+ },
+ }
+
+ for _, tc := range tc {
+ t.Run(tc.name, func(t *testing.T) {
+ var st statusResponse
+ require.NoError(t, json.Unmarshal([]byte(tc.status), &st))
+
+ var sns StatusSNSResponse
+ require.NoError(t, json.Unmarshal([]byte(tc.sns), &sns))
+
+ assert.Equal(t, tc.expect, channelOptions(st, sns, tc.meter))
+ })
+ }
+}
+
+func TestGetChannels(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ if q.Get("user") != "admin" || q.Get("password") != "secret" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ switch q.Get("cmnd") {
+ case "Status 0":
+ fmt.Fprint(w, `{"Status":{"FriendlyName":["Pump"]},"StatusSTS":{"POWER":"ON"}}`)
+ case "Status 8":
+ fmt.Fprint(w, `{"StatusSNS":{"ENERGY":{"Power":47.11}}}`)
+ default:
+ w.WriteHeader(http.StatusBadRequest)
+ }
+ }))
+ defer srv.Close()
+
+ get := func(query string) []service.Option {
+ w := httptest.NewRecorder()
+ getChannels(w, httptest.NewRequest("GET", "/channels?"+query, nil))
+ require.Equal(t, http.StatusOK, w.Code)
+
+ var res []service.Option
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
+ return res
+ }
+
+ assert.Equal(t, []service.Option{{Value: "1", Label: "Pump", Hint: "47 W", Match: true}},
+ get("uri="+srv.URL+"&user=admin&password=secret"))
+
+ assert.Empty(t, get("uri="+srv.URL+"&user=admin&password=wrong"))
+ assert.Empty(t, get("uri=http://127.0.0.1:1"))
+ assert.Empty(t, get(""))
+}
diff --git a/templates/definition/charger/tasmota.yaml b/templates/definition/charger/tasmota.yaml
index 9c5eb01925..698b925a2c 100644
--- a/templates/definition/charger/tasmota.yaml
+++ b/templates/definition/charger/tasmota.yaml
@@ -21,6 +21,7 @@ params:
description:
de: Schaltkanal (1-8)
en: Relay channel (1-8)
+ service: tasmota/channels?uri={host}&user={user}&password={password}
- preset: switchsocket
render: |
type: tasmota
diff --git a/templates/definition/meter/tasmota.yaml b/templates/definition/meter/tasmota.yaml
index 844e9e5526..7fe46b29b6 100644
--- a/templates/definition/meter/tasmota.yaml
+++ b/templates/definition/meter/tasmota.yaml
@@ -21,6 +21,7 @@ params:
description:
de: Messkanal (1-8)
en: Meter channel (1-8)
+ service: tasmota/channels?uri={host}&user={user}&password={password}&meter=true
render: |
type: tasmota
uri: http://{{ .host }}
From 78ae0eebae2ec37625effbc9059afbdb73aa6f10 Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 15:12:50 +0200
Subject: [PATCH 43/61] Shelly: channel suggestions (#34701)
---
meter/shelly/connection.go | 65 +++++---
meter/shelly/gen1.go | 10 +-
meter/shelly/gen2.go | 9 +-
meter/shelly/service.go | 181 +++++++++++++++++++++
meter/shelly/service_test.go | 141 ++++++++++++++++
templates/definition/charger/shelly.yaml | 1 +
templates/definition/meter/shelly-1pm.yaml | 1 +
7 files changed, 370 insertions(+), 38 deletions(-)
create mode 100644 meter/shelly/service.go
create mode 100644 meter/shelly/service_test.go
diff --git a/meter/shelly/connection.go b/meter/shelly/connection.go
index c30323a632..f4211c2f90 100644
--- a/meter/shelly/connection.go
+++ b/meter/shelly/connection.go
@@ -43,8 +43,38 @@ func (c *Connection) SignedPower() bool {
// NewConnection creates a new Shelly device connection.
func NewConnection(uri, user, password string, channel int, cache time.Duration) (*Connection, error) {
+ log := util.NewLogger("shelly")
+
+ client, uri, resp, err := newClient(log, uri, user, password)
+ if err != nil {
+ return nil, err
+ }
+
+ model, _, _ := strings.Cut(resp.Type+resp.Model, "-")
+
+ var gen Generation
+ if resp.Gen < 2 {
+ // Shelly GEN 1 API
+ // https://shelly-api-docs.shelly.cloud/gen1/#shelly-family-overview
+ gen = newGen1(client, uri, model, channel, cache)
+ } else {
+ // Shelly GEN 2+ API
+ // https://shelly-api-docs.shelly.cloud/gen2/
+ gen, err = newGen2(client, uri, model, channel, cache)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ conn := &Connection{Generation: gen, gen: resp.Gen}
+
+ return conn, nil
+}
+
+// newClient probes the /shelly endpoint and returns an authenticated client, the normalized uri and the device info
+func newClient(log *util.Logger, uri, user, password string) (*request.Helper, string, DeviceInfo, error) {
if uri == "" {
- return nil, errors.New("missing uri")
+ return nil, "", DeviceInfo{}, errors.New("missing uri")
}
for _, suffix := range []string{"/", "/rcp", "/shelly"} {
@@ -52,43 +82,30 @@ func NewConnection(uri, user, password string, channel int, cache time.Duration)
}
uri = util.DefaultScheme(uri, "http")
- log := util.NewLogger("shelly")
client := request.NewHelper(log)
// Shelly Gen1 and Gen2 families expose the /shelly endpoint
var resp DeviceInfo
if err := client.GetJSON(fmt.Sprintf("%s/shelly", uri), &resp); err != nil {
- return nil, err
+ return nil, "", DeviceInfo{}, err
}
if (resp.Auth || resp.AuthEn) && (user == "" || password == "") {
- return nil, fmt.Errorf("%s (%s) missing user/password", resp.Model, resp.Mac)
+ return nil, "", DeviceInfo{}, fmt.Errorf("%s (%s) missing user/password", resp.Model, resp.Mac)
}
- model, _, _ := strings.Cut(resp.Type+resp.Model, "-")
-
client.Transport = request.NewTripper(log, transport.Insecure())
- var gen Generation
- if resp.Gen < 2 {
- // Shelly GEN 1 API
- // https://shelly-api-docs.shelly.cloud/gen1/#shelly-family-overview
- if user != "" {
+ if user != "" {
+ if resp.Gen < 2 {
log.Redact(transport.BasicAuthHeader(user, password))
- }
- gen = newGen1(client, uri, model, channel, user, password, cache)
- } else {
- // Shelly GEN 2+ API
- // https://shelly-api-docs.shelly.cloud/gen2/
-
- var err error
- gen, err = newGen2(client, uri, model, channel, user, password, cache)
- if err != nil {
- return nil, err
+ client.Transport = transport.BasicAuth(user, password, client.Transport)
+ } else {
+ // Shelly gen 2 rfc7616 authentication
+ // https://shelly-api-docs.shelly.cloud/gen2/General/Authentication
+ client.Transport = transport.Digest(user, password, client.Transport)
}
}
- conn := &Connection{Generation: gen, gen: resp.Gen}
-
- return conn, nil
+ return client, uri, resp, nil
}
diff --git a/meter/shelly/gen1.go b/meter/shelly/gen1.go
index 3606015e27..d916e43a2f 100644
--- a/meter/shelly/gen1.go
+++ b/meter/shelly/gen1.go
@@ -8,7 +8,6 @@ import (
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
- "github.com/evcc-io/evcc/util/transport"
)
// Gen1API endpoint reference: https://shelly-api-docs.shelly.cloud/gen1/#shelly-family-overview
@@ -18,6 +17,9 @@ type Gen1SwitchResponse struct {
}
type Gen1Status struct {
+ Relays []struct {
+ Ison bool
+ }
Meters []struct {
Power float64
Current float64
@@ -46,11 +48,7 @@ type gen1 struct {
}
// newGen1 initializes the connection to the shelly gen1 api and sets up the cached gen1Status
-func newGen1(client *request.Helper, uri, model string, channel int, user, password string, cache time.Duration) *gen1 {
- if user != "" {
- client.Transport = transport.BasicAuth(user, password, client.Transport)
- }
-
+func newGen1(client *request.Helper, uri, model string, channel int, cache time.Duration) *gen1 {
c := &gen1{
Helper: client,
uri: uri,
diff --git a/meter/shelly/gen2.go b/meter/shelly/gen2.go
index 2349c1c715..f6a95f07ba 100644
--- a/meter/shelly/gen2.go
+++ b/meter/shelly/gen2.go
@@ -8,7 +8,6 @@ import (
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
- "github.com/evcc-io/evcc/util/transport"
"github.com/samber/lo"
)
@@ -121,7 +120,7 @@ func (c *gen2) apiCall[T any](id int, method string) func() (T, error) {
}
// gen2InitApi initializes the connection to the shelly gen2+ api and sets up the cached gen2SwitchStatus, gen2EM1Status and gen2EMStatus
-func newGen2(helper *request.Helper, uri, model string, channel int, user, password string, cache time.Duration) (*gen2, error) {
+func newGen2(helper *request.Helper, uri, model string, channel int, cache time.Duration) (*gen2, error) {
// Shelly GEN 2+ API
// https://shelly-api-docs.shelly.cloud/gen2/
c := &gen2{
@@ -131,12 +130,6 @@ func newGen2(helper *request.Helper, uri, model string, channel int, user, passw
model: model,
}
- // Shelly gen 2 rfc7616 authentication
- // https://shelly-api-docs.shelly.cloud/gen2/General/Authentication
- if user != "" {
- c.Client.Transport = transport.Digest(user, password, c.Client.Transport)
- }
-
var res Gen2Methods
if err := c.execCmd(channel, "Shelly.ListMethods", &res); err != nil {
return nil, err
diff --git a/meter/shelly/service.go b/meter/shelly/service.go
new file mode 100644
index 0000000000..89ee9d11d8
--- /dev/null
+++ b/meter/shelly/service.go
@@ -0,0 +1,181 @@
+package shelly
+
+import (
+ "cmp"
+ "encoding/json"
+ "fmt"
+ "maps"
+ "net/http"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/evcc-io/evcc/util"
+ "github.com/samber/lo"
+)
+
+var (
+ log = util.NewLogger("shelly")
+ redactPassword = log.RotatingSlot()
+)
+
+func init() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /channels", getChannels)
+
+ service.Register("shelly", mux)
+}
+
+// channel is a relay/meter slot of a device, merged across components sharing the same id
+type channel struct {
+ id int
+ name string
+ relay bool
+ metered bool // has a power meter
+ power float64 // W, valid if metered
+}
+
+// gen2Components are the Shelly.GetStatus components that make up a channel
+var gen2Components = []string{"switch", "pm1", "em1", "em"}
+
+// channelOptions maps channels to options. With metered only channels with a power
+// meter match (meter templates), otherwise only relays do (charger templates).
+func channelOptions(channels []channel, metered bool) []service.Option {
+ res := make([]service.Option, 0, len(channels))
+ for _, ch := range channels {
+ kind := "Meter"
+ if ch.relay {
+ kind = "Switch"
+ }
+
+ o := service.Option{
+ Value: strconv.Itoa(ch.id),
+ Label: cmp.Or(strings.TrimSpace(ch.name), fmt.Sprintf("%s %d", kind, ch.id+1)),
+ Match: ch.relay,
+ }
+ if metered {
+ o.Match = ch.metered
+ }
+ if ch.metered {
+ o.Hint = fmt.Sprintf("%.0f W", ch.power)
+ }
+ res = append(res, o)
+ }
+
+ return res
+}
+
+// gen1Channels lists the relays and (e)meters of a /status response by index
+func gen1Channels(res Gen1Status) []channel {
+ var channels []channel
+ for i := range max(len(res.Relays), len(res.Meters), len(res.EMeters)) {
+ ch := channel{id: i, relay: i < len(res.Relays)}
+ switch {
+ case i < len(res.Meters):
+ ch.metered, ch.power = true, res.Meters[i].Power
+ case i < len(res.EMeters):
+ ch.metered, ch.power = true, res.EMeters[i].Power
+ }
+ channels = append(channels, ch)
+ }
+ return channels
+}
+
+// gen2Channels merges the switch/pm1/em1/em components of Shelly.GetStatus and
+// Shelly.GetConfig by id. Switches without power metering omit apower.
+func gen2Channels(status, config map[string]json.RawMessage) []channel {
+ byId := make(map[int]channel)
+
+ // sorted keys keep the merged name deterministic (em1:0 before switch:0)
+ for _, key := range slices.Sorted(maps.Keys(status)) {
+ typ, idStr, ok := strings.Cut(key, ":")
+ if !ok {
+ continue
+ }
+ id, err := strconv.Atoi(idStr)
+ if err != nil {
+ continue
+ }
+
+ if !slices.Contains(gen2Components, typ) {
+ continue
+ }
+
+ var res struct {
+ Apower *float64 `json:"apower"`
+ ActPower *float64 `json:"act_power"`
+ TotalActPower *float64 `json:"total_act_power"`
+ }
+ var cfg struct {
+ Name string `json:"name"`
+ }
+ _ = json.Unmarshal(status[key], &res)
+ _ = json.Unmarshal(config[key], &cfg)
+
+ ch := byId[id]
+ ch.id = id
+ ch.relay = ch.relay || typ == "switch"
+ if power := cmp.Or(res.Apower, res.ActPower, res.TotalActPower); power != nil {
+ ch.metered, ch.power = true, *power
+ }
+ if strings.TrimSpace(ch.name) == "" {
+ ch.name = cfg.Name
+ }
+ byId[id] = ch
+ }
+
+ res := lo.Values(byId)
+ slices.SortFunc(res, func(a, b channel) int { return a.id - b.id })
+ return res
+}
+
+func getChannels(w http.ResponseWriter, req *http.Request) {
+ q := req.URL.Query()
+ password := q.Get("password")
+ metered, _ := strconv.ParseBool(q.Get("metered"))
+
+ // the logger is shared, a rotating slot keeps the redaction list from growing while typing
+ redactPassword(password)
+
+ res, err := deviceChannels(q.Get("uri"), q.Get("user"), password)
+ if err != nil {
+ // form incomplete or device unreachable, the ui polls while typing
+ log.DEBUG.Printf("channels: %v", err)
+ res = nil
+ }
+
+ // short cache: hints carry live values, the form refetches on every change
+ w.Header().Set("Cache-control", "max-age=30")
+ jsonWrite(w, channelOptions(res, metered))
+}
+
+func deviceChannels(uri, user, password string) ([]channel, error) {
+ client, uri, info, err := newClient(log, uri, user, password)
+ if err != nil {
+ return nil, err
+ }
+
+ if info.Gen < 2 {
+ var res Gen1Status
+ if err := client.GetJSON(uri+"/status", &res); err != nil {
+ return nil, err
+ }
+ return gen1Channels(res), nil
+ }
+
+ var status, config map[string]json.RawMessage
+ if err := client.GetJSON(uri+"/rpc/Shelly.GetStatus", &status); err != nil {
+ return nil, err
+ }
+ if err := client.GetJSON(uri+"/rpc/Shelly.GetConfig", &config); err != nil {
+ return nil, err
+ }
+
+ return gen2Channels(status, config), nil
+}
+
+func jsonWrite(w http.ResponseWriter, data any) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(data)
+}
diff --git a/meter/shelly/service_test.go b/meter/shelly/service_test.go
new file mode 100644
index 0000000000..ac77334f3c
--- /dev/null
+++ b/meter/shelly/service_test.go
@@ -0,0 +1,141 @@
+package shelly
+
+import (
+ "encoding/json"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestChannelOptions(t *testing.T) {
+ channels := []channel{
+ {id: 0, name: " Garden ", relay: true, metered: true, power: 635.4},
+ {id: 1, metered: true, power: 12.6},
+ {id: 2, relay: true},
+ }
+
+ // charger: relays match
+ assert.Equal(t, []service.Option{
+ {Value: "0", Label: "Garden", Hint: "635 W", Match: true},
+ {Value: "1", Label: "Meter 2", Hint: "13 W"},
+ {Value: "2", Label: "Switch 3", Match: true},
+ }, channelOptions(channels, false))
+
+ // meter: metered channels match
+ assert.Equal(t, []service.Option{
+ {Value: "0", Label: "Garden", Hint: "635 W", Match: true},
+ {Value: "1", Label: "Meter 2", Hint: "13 W", Match: true},
+ {Value: "2", Label: "Switch 3"},
+ }, channelOptions(channels, true))
+
+ assert.Empty(t, channelOptions(nil, true))
+}
+
+func TestGen1Channels(t *testing.T) {
+ var res Gen1Status
+
+ // Shelly EM: one relay, two emeters
+ require.NoError(t, json.Unmarshal([]byte(`{"relays":[{"ison":true}],"emeters":[{"power":100.5},{"power":-20}]}`), &res))
+ assert.Equal(t, []channel{
+ {id: 0, relay: true, metered: true, power: 100.5},
+ {id: 1, metered: true, power: -20.0},
+ }, gen1Channels(res))
+
+ // Shelly 1: relay without meter
+ res = Gen1Status{}
+ require.NoError(t, json.Unmarshal([]byte(`{"relays":[{"ison":false}]}`), &res))
+ assert.Equal(t, []channel{{id: 0, relay: true}}, gen1Channels(res))
+}
+
+func TestGen2Channels(t *testing.T) {
+ tc := []struct {
+ name string
+ status, config string
+ expected []channel
+ }{
+ {
+ "pro 4pm, switch 1 unnamed",
+ `{"sys":{},"switch:0":{"id":0,"output":true,"apower":635.4},"switch:1":{"id":1,"output":false,"apower":0}}`,
+ `{"switch:0":{"id":0,"name":"Garden"},"switch:1":{"id":1,"name":null}}`,
+ []channel{
+ {id: 0, name: "Garden", relay: true, metered: true, power: 635.4},
+ {id: 1, relay: true, metered: true, power: 0.0},
+ },
+ },
+ {
+ "plus 1, no power metering",
+ `{"switch:0":{"id":0,"output":true}}`,
+ `{"switch:0":{"id":0,"name":"Pump"}}`,
+ []channel{{id: 0, name: "Pump", relay: true}},
+ },
+ {
+ "pro em, switch and em1 share id 0",
+ `{"switch:0":{"id":0,"output":true},"em1:0":{"id":0,"act_power":-12.3},"em1:1":{"id":1,"act_power":4.5}}`,
+ `{"switch:0":{"id":0,"name":"Relay"},"em1:0":{"id":0,"name":"Grid"},"em1:1":{"id":1,"name":"PV"}}`,
+ []channel{
+ {id: 0, name: "Grid", relay: true, metered: true, power: -12.3},
+ {id: 1, name: "PV", metered: true, power: 4.5},
+ },
+ },
+ {
+ "pro 3em triphase",
+ `{"em:0":{"id":0,"total_act_power":1500}}`,
+ `{"em:0":{"id":0,"name":null}}`,
+ []channel{{id: 0, metered: true, power: 1500.0}},
+ },
+ {
+ "pm mini",
+ `{"pm1:0":{"id":0,"apower":42}}`,
+ `{"pm1:0":{"id":0,"name":"Heater"}}`,
+ []channel{{id: 0, name: "Heater", metered: true, power: 42.0}},
+ },
+ }
+
+ for _, tc := range tc {
+ t.Run(tc.name, func(t *testing.T) {
+ var status, config map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal([]byte(tc.status), &status))
+ require.NoError(t, json.Unmarshal([]byte(tc.config), &config))
+
+ assert.Equal(t, tc.expected, gen2Channels(status, config))
+ })
+ }
+}
+
+func TestGetChannels(t *testing.T) {
+ srv := shellyServer(t, map[string]string{
+ "Shelly.GetStatus": `{"sys":{},"switch:0":{"id":0,"output":true,"apower":635.4},"switch:1":{"id":1,"output":false}}`,
+ "Shelly.GetConfig": `{"switch:0":{"id":0,"name":" Garden "},"switch:1":{"id":1,"name":null}}`,
+ })
+ defer srv.Close()
+
+ get := func(query string) []service.Option {
+ w := httptest.NewRecorder()
+ getChannels(w, httptest.NewRequest("GET", "/channels?"+query, nil))
+ require.Equal(t, 200, w.Code)
+
+ var res []service.Option
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
+ return res
+ }
+
+ uri := "uri=" + url.QueryEscape(srv.URL)
+
+ assert.Equal(t, []service.Option{
+ {Value: "0", Label: "Garden", Hint: "635 W", Match: true},
+ {Value: "1", Label: "Switch 2", Match: true},
+ }, get(uri))
+
+ assert.Equal(t, []service.Option{
+ {Value: "0", Label: "Garden", Hint: "635 W", Match: true},
+ {Value: "1", Label: "Switch 2"},
+ }, get(uri+"&metered=1"))
+
+ // incomplete form and unreachable device yield an empty list
+ assert.Empty(t, get(""))
+ assert.Empty(t, get("uri=http://127.0.0.1:1"))
+}
diff --git a/templates/definition/charger/shelly.yaml b/templates/definition/charger/shelly.yaml
index 7227f46fae..95da9a6f0e 100644
--- a/templates/definition/charger/shelly.yaml
+++ b/templates/definition/charger/shelly.yaml
@@ -17,6 +17,7 @@ params:
- name: password
- name: channel
default: 0
+ service: shelly/channels?uri={host}&user={user}&password={password}
- preset: switchsocket
render: |
type: shelly
diff --git a/templates/definition/meter/shelly-1pm.yaml b/templates/definition/meter/shelly-1pm.yaml
index f36a109159..9f8be855ac 100644
--- a/templates/definition/meter/shelly-1pm.yaml
+++ b/templates/definition/meter/shelly-1pm.yaml
@@ -21,6 +21,7 @@ params:
- name: user
- name: password
- name: channel
+ service: shelly/channels?uri={host}&user={user}&password={password}&metered=1
render: |
type: shelly
uri: http://{{ .host }} # shelly device ip address (local)
From c27b74deb761c259f869fc5a0d234387afef140a Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 15:16:35 +0200
Subject: [PATCH 44/61] Modbus: rich serial port suggestions (#34700)
---
.../components/Config/DeviceModal/Modbus.vue | 9 +++---
util/service/hardware.go | 21 ++++++++++++--
util/service/hardware_darwin.go | 10 +++++++
util/service/hardware_enumerator.go | 29 +++++++++++++++++++
4 files changed, 62 insertions(+), 7 deletions(-)
create mode 100644 util/service/hardware_darwin.go
create mode 100644 util/service/hardware_enumerator.go
diff --git a/assets/js/components/Config/DeviceModal/Modbus.vue b/assets/js/components/Config/DeviceModal/Modbus.vue
index 2cfe8486f8..11317e0513 100644
--- a/assets/js/components/Config/DeviceModal/Modbus.vue
+++ b/assets/js/components/Config/DeviceModal/Modbus.vue
@@ -217,7 +217,7 @@ export default defineComponent({
protocol: MODBUS_PROTOCOL.TCP as MODBUS_PROTOCOL,
MODBUS_PROTOCOL,
MODBUS_CONNECTION,
- deviceServiceValues: [] as string[],
+ deviceServiceValues: [] as ServiceValue[],
localDevice: undefined as string | undefined,
};
},
@@ -333,17 +333,18 @@ export default defineComponent({
return `${name}-${this.componentId}`;
},
async updateServiceValues() {
- this.deviceServiceValues = serviceDefaults(await loadServiceValues("hardware/serial"));
+ this.deviceServiceValues = await loadServiceValues("hardware/serial");
this.applyServiceDefault();
},
applyServiceDefault() {
// auto-apply device value if it's needed and exactly one option exists
+ const defaults = serviceDefaults(this.deviceServiceValues);
if (
this.connection === MODBUS_CONNECTION.SERIAL &&
- this.deviceServiceValues.length === 1 &&
+ defaults.length === 1 &&
!this.device
) {
- this.deviceModel = this.deviceServiceValues[0];
+ this.deviceModel = defaults[0];
}
},
},
diff --git a/util/service/hardware.go b/util/service/hardware.go
index 3a63b13d14..f836ebb5e1 100644
--- a/util/service/hardware.go
+++ b/util/service/hardware.go
@@ -20,17 +20,32 @@ func init() {
var (
once sync.Once
- ports []string
+ ports []service.Option
)
func getSerialPorts(w http.ResponseWriter, req *http.Request) {
once.Do(func() {
if env := os.Getenv("EVCC_SERIAL_PORTS"); env != "" {
- ports = strings.Split(env, ",")
+ ports = plainOptions(strings.Split(env, ","))
} else {
- ports, _ = serialports.GetPortsList()
+ ports = serialPortOptions()
}
})
json.NewEncoder(w).Encode(ports)
}
+
+// plainOptions wraps port names as options without details
+func plainOptions(names []string) []service.Option {
+ res := make([]service.Option, 0, len(names))
+ for _, name := range names {
+ res = append(res, service.Option{Value: name, Match: true})
+ }
+ return res
+}
+
+// plainSerialPorts lists port names without USB details
+func plainSerialPorts() []service.Option {
+ names, _ := serialports.GetPortsList()
+ return plainOptions(names)
+}
diff --git a/util/service/hardware_darwin.go b/util/service/hardware_darwin.go
new file mode 100644
index 0000000000..328b661b49
--- /dev/null
+++ b/util/service/hardware_darwin.go
@@ -0,0 +1,10 @@
+//go:build darwin && !cgo
+
+package service
+
+import "github.com/evcc-io/evcc/server/service"
+
+// serialPortOptions falls back to plain port names: the darwin enumerator needs IOKit via cgo
+func serialPortOptions() []service.Option {
+ return plainSerialPorts()
+}
diff --git a/util/service/hardware_enumerator.go b/util/service/hardware_enumerator.go
new file mode 100644
index 0000000000..fd0fe7c504
--- /dev/null
+++ b/util/service/hardware_enumerator.go
@@ -0,0 +1,29 @@
+//go:build !darwin || cgo
+
+package service
+
+import (
+ "strings"
+
+ "github.com/evcc-io/evcc/server/service"
+ "go.bug.st/serial/enumerator"
+)
+
+// serialPortOptions lists ports with USB product, vid:pid and serial number
+func serialPortOptions() []service.Option {
+ details, err := enumerator.GetDetailedPortsList()
+ if err != nil {
+ return plainSerialPorts()
+ }
+
+ res := make([]service.Option, 0, len(details))
+ for _, d := range details {
+ // match on every port so a single port still auto-fills
+ o := service.Option{Value: d.Name, Label: d.Product, Match: true}
+ if d.VID != "" {
+ o.Hint = strings.TrimSpace(d.VID + ":" + d.PID + " " + d.SerialNumber)
+ }
+ res = append(res, o)
+ }
+ return res
+}
From 08bf5fd2339bde35d833d81aaa971a6269f12dcb Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 15:17:34 +0200
Subject: [PATCH 45/61] Service: http/get label and hint for rich suggestions
(#34699)
---
.../tariff/pvnode-v2-temperature.yaml | 2 +-
templates/definition/tariff/pvnode-v2.yaml | 2 +-
util/service/http.go | 80 ++++++++++++++-----
util/service/http_test.go | 62 ++++++++++++++
4 files changed, 124 insertions(+), 22 deletions(-)
create mode 100644 util/service/http_test.go
diff --git a/templates/definition/tariff/pvnode-v2-temperature.yaml b/templates/definition/tariff/pvnode-v2-temperature.yaml
index 4de1b7e4aa..e52f4ef6a4 100644
--- a/templates/definition/tariff/pvnode-v2-temperature.yaml
+++ b/templates/definition/tariff/pvnode-v2-temperature.yaml
@@ -20,7 +20,7 @@ params:
required: true
- name: siteid
required: true
- service: http/get?uri=https://api.pvnode.com/v2/sites/&token={apikey}&jq=[.[].id]
+ service: http/get?uri=https://api.pvnode.com/v2/sites/&token={apikey}&jq=.&value=.id&label=.name
- name: forecast_days
description:
en: Forecast days
diff --git a/templates/definition/tariff/pvnode-v2.yaml b/templates/definition/tariff/pvnode-v2.yaml
index f7df4cc6e1..737b88e854 100644
--- a/templates/definition/tariff/pvnode-v2.yaml
+++ b/templates/definition/tariff/pvnode-v2.yaml
@@ -26,7 +26,7 @@ params:
en: Site ID
de: Standort-ID
required: true
- service: http/get?uri=https://api.pvnode.com/v2/sites/&token={apikey}&jq=[.[].id]
+ service: http/get?uri=https://api.pvnode.com/v2/sites/&token={apikey}&jq=.&value=.id&label=.name
- name: forecast_days
description:
en: Forecast days
diff --git a/util/service/http.go b/util/service/http.go
index 1bef604486..f7b0d9fb94 100644
--- a/util/service/http.go
+++ b/util/service/http.go
@@ -1,6 +1,8 @@
package service
import (
+ "cmp"
+ "context"
"errors"
"net/http"
@@ -20,9 +22,32 @@ func init() {
service.Register("http", mux)
}
-// httpGet reads uri and extracts the choices using the jq query, driving
-// selection of remote resources in templates. The config UI polls while the
-// form is filled, hence a rejected request yields an empty list.
+// parseQuery compiles a jq expression, an empty expression yields nil
+func parseQuery(expr string) (*gojq.Query, error) {
+ if expr == "" {
+ return nil, nil
+ }
+ return gojq.Parse(expr)
+}
+
+// first returns the first result of query applied to v, or nil. The request
+// context bounds the evaluation like the jq query itself.
+func first(ctx context.Context, query *gojq.Query, v any) any {
+ if query == nil {
+ return nil
+ }
+ res, ok := query.RunWithContext(ctx, v).Next()
+ if !ok {
+ return nil
+ }
+ if _, ok := res.(error); ok {
+ return nil
+ }
+ return res
+}
+
+// httpGet reads uri and maps the jq result elements to options via the optional
+// value, label and hint expressions. The config UI polls, hence errors yield an empty list.
func httpGet(w http.ResponseWriter, req *http.Request) {
q := req.URL.Query()
@@ -32,16 +57,20 @@ func httpGet(w http.ResponseWriter, req *http.Request) {
return
}
- expr := q.Get("jq")
- if expr == "" {
- expr = "."
- }
-
- query, err := gojq.Parse(expr)
- if err != nil {
- jsonError(w, http.StatusBadRequest, err)
- return
+ var queries [4]*gojq.Query
+ for i, expr := range []string{
+ cmp.Or(q.Get("jq"), "."),
+ cmp.Or(q.Get("value"), "."),
+ q.Get("label"),
+ q.Get("hint"),
+ } {
+ var err error
+ if queries[i], err = parseQuery(expr); err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
}
+ query, value, label, hint := queries[0], queries[1], queries[2], queries[3]
log := util.NewLogger("http")
client := request.NewHelper(log)
@@ -51,7 +80,7 @@ func httpGet(w http.ResponseWriter, req *http.Request) {
client.Transport = transport.BearerAuth(token, client.Transport)
}
- res := []string{}
+ res := []service.Option{}
defer func() { jsonWrite(w, res) }()
body, err := client.GetBody(uri)
@@ -59,17 +88,28 @@ func httpGet(w http.ResponseWriter, req *http.Request) {
return
}
- val, err := jq.Query(query, body)
+ val, err := jq.QueryContext(req.Context(), query, body)
if err != nil {
return
}
- if slice, ok := val.([]any); ok {
- for _, v := range slice {
- res = append(res, cast.ToString(v))
- }
- return
+ slice, ok := val.([]any)
+ if !ok {
+ slice = []any{val}
}
- res = append(res, cast.ToString(val))
+ for _, v := range slice {
+ val := first(req.Context(), value, v)
+ if val == nil {
+ continue
+ }
+
+ // match keeps the single-value auto-fill
+ res = append(res, service.Option{
+ Value: cast.ToString(val),
+ Label: cast.ToString(first(req.Context(), label, v)),
+ Hint: cast.ToString(first(req.Context(), hint, v)),
+ Match: true,
+ })
+ }
}
diff --git a/util/service/http_test.go b/util/service/http_test.go
new file mode 100644
index 0000000000..f420368ad8
--- /dev/null
+++ b/util/service/http_test.go
@@ -0,0 +1,62 @@
+package service
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "github.com/evcc-io/evcc/server/service"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHttpGet(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"sn":"ABC","sites":[{"id":"s1","name":"Home","kw":9.9},{"id":"s2","name":null},{"name":"no id"}]}`))
+ }))
+ defer srv.Close()
+
+ tc := []struct {
+ name string
+ params url.Values
+ status int
+ res []service.Option
+ }{
+ {"list of scalars", url.Values{"jq": {"[.sites[].id]"}}, 200, []service.Option{
+ {Value: "s1", Match: true}, {Value: "s2", Match: true},
+ }},
+ {"single scalar", url.Values{"jq": {".sn"}}, 200, []service.Option{
+ {Value: "ABC", Match: true},
+ }},
+ {"label and hint", url.Values{"jq": {".sites"}, "value": {".id"}, "label": {".name"}, "hint": {".kw"}}, 200, []service.Option{
+ {Value: "s1", Label: "Home", Hint: "9.9", Match: true}, {Value: "s2", Match: true},
+ }},
+ {"jq error", url.Values{"jq": {".missing[]"}}, 200, []service.Option{}},
+ {"invalid jq", url.Values{"jq": {"["}}, 400, nil},
+ {"invalid label", url.Values{"label": {"["}}, 400, nil},
+ {"missing uri", url.Values{"uri": {""}}, 400, nil},
+ }
+
+ for _, tc := range tc {
+ t.Run(tc.name, func(t *testing.T) {
+ if _, ok := tc.params["uri"]; !ok {
+ tc.params.Set("uri", srv.URL)
+ }
+
+ w := httptest.NewRecorder()
+ httpGet(w, httptest.NewRequest("GET", "/get?"+tc.params.Encode(), nil))
+
+ require.Equal(t, tc.status, w.Code, w.Body.String())
+ if tc.res == nil {
+ return
+ }
+
+ var res []service.Option
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res))
+ assert.Equal(t, tc.res, res)
+ })
+ }
+}
From fff5c5dfe3f3f8d737438b840e013d2b6a15b842 Mon Sep 17 00:00:00 2001
From: andig
Date: Sun, 11 Oct 2026 15:29:44 +0200
Subject: [PATCH 46/61] Modbus proxy: report upstream timeouts as gateway
target failure (#34705)
---
server/modbus/handler.go | 23 ++++++++++-------
server/modbus/proxy_test.go | 51 +++++++++++++++++++++++++++++++++++++
2 files changed, 65 insertions(+), 9 deletions(-)
diff --git a/server/modbus/handler.go b/server/modbus/handler.go
index a2ffac6836..1c8336c872 100644
--- a/server/modbus/handler.go
+++ b/server/modbus/handler.go
@@ -4,6 +4,7 @@ import (
"encoding/binary"
"errors"
"math/bits"
+ "net"
"github.com/andig/mbserver"
"github.com/evcc-io/evcc/util"
@@ -41,14 +42,21 @@ func (h *handler) logResult(op string, b []byte, err error) {
}
}
-func (h *handler) exceptionToUint16AndError(op string, b []byte, err error) ([]uint16, error) {
- h.logResult(op, b, err)
-
+// mapError passes device exceptions through and reports upstream timeouts as gateway target failure (0x0B)
+// instead of letting mbserver map them to server device failure (0x04)
+func mapError(err error) error {
if me, ok := errors.AsType[*gridx.Error](err); ok {
- err = mbserver.MapExceptionCodeToError(me.ExceptionCode)
+ return mbserver.MapExceptionCodeToError(me.ExceptionCode)
}
+ if ne, ok := errors.AsType[net.Error](err); ok && ne.Timeout() {
+ return mbserver.ErrGWTargetFailedToRespond
+ }
+ return err
+}
- return bytesAsUint16(b), err
+func (h *handler) exceptionToUint16AndError(op string, b []byte, err error) ([]uint16, error) {
+ h.logResult(op, b, err)
+ return bytesAsUint16(b), mapError(err)
}
func coilsToBytes(b []bool) []byte {
@@ -73,10 +81,7 @@ func coilsToBytes(b []bool) []byte {
func (h *handler) bytesToBoolResult(op string, qty uint16, b []byte, err error) ([]bool, error) {
h.logResult(op, b, err)
-
- if me, ok := errors.AsType[*gridx.Error](err); ok {
- err = mbserver.MapExceptionCodeToError(me.ExceptionCode)
- }
+ err = mapError(err)
var res []bool
diff --git a/server/modbus/proxy_test.go b/server/modbus/proxy_test.go
index 50e77c1675..62b216eac8 100644
--- a/server/modbus/proxy_test.go
+++ b/server/modbus/proxy_test.go
@@ -2,6 +2,7 @@ package modbus
import (
"encoding/binary"
+ "errors"
"math/rand/v2"
"net"
"sync"
@@ -11,6 +12,7 @@ import (
"github.com/andig/mbserver"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/modbus"
+ gridx "github.com/grid-x/modbus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -114,6 +116,55 @@ func TestReadCoils(t *testing.T) {
}
}
+func TestUpstreamTimeout(t *testing.T) {
+ // downstream server responding slower than the proxy timeout
+ l, err := net.Listen("tcp", "localhost:0")
+ require.NoError(t, err)
+ defer l.Close()
+
+ srv, _ := mbserver.New(&slowHandler{
+ delay: 500 * time.Millisecond,
+ RequestHandler: new(mbserver.DummyHandler),
+ })
+ require.NoError(t, srv.Start(l))
+ defer func() { _ = srv.Stop() }()
+
+ // proxy server
+ pl, err := net.Listen("tcp", "localhost:0")
+ require.NoError(t, err)
+ defer pl.Close()
+
+ downstreamConn, err := modbus.Settings{URI: l.Addr().String(), ID: 1, Timeout: 100 * time.Millisecond}.Connection(t.Context())
+ require.NoError(t, err)
+
+ proxy, _ := mbserver.New(&handler{
+ log: util.NewLogger("foo"),
+ conn: downstreamConn,
+ })
+ require.NoError(t, proxy.Start(pl))
+ defer func() { _ = proxy.Stop() }()
+
+ // test client
+ conn, err := modbus.NewConnection(t.Context(), pl.Addr().String(), "", "", 0, modbus.Tcp, 1)
+ require.NoError(t, err)
+ conn.Timeout(5 * time.Second)
+
+ _, err = conn.WriteMultipleRegisters(1, 1, []byte{0, 1})
+ me, ok := errors.AsType[*gridx.Error](err)
+ require.True(t, ok, "expected modbus exception, got %v", err)
+ assert.Equal(t, uint8(gridx.ExceptionCodeGatewayTargetDeviceFailedToRespond), me.ExceptionCode)
+}
+
+type slowHandler struct {
+ delay time.Duration
+ mbserver.RequestHandler
+}
+
+func (h *slowHandler) HandleHoldingRegisters(req *mbserver.HoldingRegistersRequest) ([]uint16, error) {
+ time.Sleep(h.delay)
+ return req.Args, nil
+}
+
type echoHandler struct {
id int
mbserver.RequestHandler
From 1a8eeaf4c9fc8a541f1a434d581116558dfec661 Mon Sep 17 00:00:00 2001
From: andig
Date: Mon, 12 Oct 2026 09:36:17 +0200
Subject: [PATCH 47/61] Easee: lock 3p at charger level instead of switching to
auto (#34640)
---
charger/easee.go | 13 +++------
charger/easee_test.go | 48 +++++++++++++++++++++++++++++++
docs/agents/easee-architecture.md | 2 +-
3 files changed, 53 insertions(+), 10 deletions(-)
diff --git a/charger/easee.go b/charger/easee.go
index 438ecaadfa..7af54173c6 100644
--- a/charger/easee.go
+++ b/charger/easee.go
@@ -764,11 +764,7 @@ func (c *Easee) Phases1p3p(phases int) error {
c.dispatcher.CancelOrphan(easee.CIRCUIT_MAX_CURRENT_P1)
}
} else {
- // charger level
- if phases == 3 {
- phases = 2 // mode 2 means auto
- }
-
+ // charger level: phaseMode 1 and 3 lock the phases, 2 would leave them to the charger
// change phaseMode only if necessary
if phases != c.phaseMode {
data := easee.ChargerSettings{
@@ -803,10 +799,9 @@ func (c *Easee) GetPhases() (int, error) {
}
}
} else {
- // charger level
- phases = c.phaseMode
- if phases == 2 { // map automatic to 3p
- phases = 3
+ // charger level: automatic (2, left by earlier versions) is unknown so the loadpoint locks the phases
+ if c.phaseMode != 2 {
+ phases = c.phaseMode
}
}
return phases, nil
diff --git a/charger/easee_test.go b/charger/easee_test.go
index 951ce8d48d..62b606e138 100644
--- a/charger/easee_test.go
+++ b/charger/easee_test.go
@@ -471,6 +471,54 @@ func TestEasee_Phases1p3p_registersExpectedOrphan(t *testing.T) {
"expected orphan should be registered before the POST")
}
+// TestEasee_Phases1p3p_chargerLevel verifies that charger level phase switching locks
+// the requested phases instead of handing 3p to the charger's automatic (#34620)
+func TestEasee_Phases1p3p_chargerLevel(t *testing.T) {
+ const chargerID = "TESTTEST"
+
+ e := newEasee()
+ e.charger = chargerID
+ e.opMode = easee.ModeDisconnected // no pause/resume
+
+ httpmock.ActivateNonDefault(e.Client)
+ defer httpmock.DeactivateAndReset()
+
+ var sent []int
+ uri := fmt.Sprintf("%s/chargers/%s/settings", easee.API, chargerID)
+ httpmock.RegisterResponder(http.MethodPost, uri, func(req *http.Request) (*http.Response, error) {
+ var data easee.ChargerSettings
+ if err := json.NewDecoder(req.Body).Decode(&data); err != nil {
+ return nil, err
+ }
+ sent = append(sent, *data.PhaseMode)
+ return httpmock.NewStringResponse(200, ""), nil
+ })
+
+ for _, tc := range []struct {
+ phaseMode, phases int
+ sent []int
+ }{
+ {1, 3, []int{3}},
+ {2, 3, []int{3}}, // automatic from earlier versions
+ {3, 1, []int{1}},
+ {3, 3, nil}, // already locked
+ } {
+ sent = nil
+ e.phaseMode = tc.phaseMode
+
+ require.NoError(t, e.Phases1p3p(tc.phases))
+ assert.Equal(t, tc.sent, sent, "phaseMode %d to %dp", tc.phaseMode, tc.phases)
+ }
+
+ // automatic is not reported as 3p, so the loadpoint switches to lock it
+ for phaseMode, phases := range map[int]int{1: 1, 2: 0, 3: 3} {
+ e.phaseMode = phaseMode
+ res, err := e.GetPhases()
+ require.NoError(t, err)
+ assert.Equal(t, phases, res, "phaseMode %d", phaseMode)
+ }
+}
+
func TestLivenessCheck_staleObservations(t *testing.T) {
e := newEasee()
e.opMode = easee.ModeCharging
diff --git a/docs/agents/easee-architecture.md b/docs/agents/easee-architecture.md
index 1890e5e240..d8ca6057fd 100644
--- a/docs/agents/easee-architecture.md
+++ b/docs/agents/easee-architecture.md
@@ -198,7 +198,7 @@ This POST returns HTTP `200` but still fires a `CommandResponse` with `ID=22` (e
### Charger-Level (fallback)
-Uses `PhaseMode` setting: `1` for single-phase, `2` (auto) for 3-phase.
+Uses `PhaseMode` setting: `1` locked single-phase, `3` locked 3-phase. `2` (auto, set by earlier versions) is reported as unknown phases so the loadpoint locks it.
After changing PhaseMode, `Enable(false)` is called — the loadpoint then re-enables, because PhaseMode changes only take effect after a charging cycle restart.
## Authorization Mode (`authorize`)
From 95680c40533949f57bac1361bae821bb4c0573f2 Mon Sep 17 00:00:00 2001
From: andig
Date: Mon, 12 Oct 2026 11:03:25 +0200
Subject: [PATCH 48/61] Config UI: fill empty optional params in service urls
(#34702)
Co-authored-by: Claude Fable 5.1
Co-authored-by: Michael Geers
---
.../Config/DeviceModal/index.test.ts | 25 ++++++++++++++++---
.../js/components/Config/DeviceModal/index.ts | 9 +++++--
templates/README.md | 2 +-
tests/config-param-service-demo.tpl.yaml | 6 +++++
tests/config-param-service.spec.ts | 12 +++++++++
util/service/demo.go | 5 ++++
6 files changed, 52 insertions(+), 7 deletions(-)
diff --git a/assets/js/components/Config/DeviceModal/index.test.ts b/assets/js/components/Config/DeviceModal/index.test.ts
index f228eea9b6..fe64b19057 100644
--- a/assets/js/components/Config/DeviceModal/index.test.ts
+++ b/assets/js/components/Config/DeviceModal/index.test.ts
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vite-plus/test";
import { applyDefaultsFromTemplate, createServiceEndpoints, type TemplateParam } from "./index";
-const buildParam = (name: string, service?: string): TemplateParam => ({
+const buildParam = (name: string, service?: string, required = false): TemplateParam => ({
Name: name,
- Required: false,
+ Required: required,
Advanced: false,
Deprecated: false,
Service: service,
@@ -17,7 +17,7 @@ describe("createServiceEndpoints", () => {
});
it("replaces single placeholder", () => {
- const params = [buildParam("home", "homes"), buildParam("power", "homes/{home}/sensors")];
+ const params = [buildParam("home", "homes", true), buildParam("power", "homes/{home}/sensors")];
const endpoints = createServiceEndpoints(params);
const homeEndpoint = endpoints.find(({ name }) => name === "home")!;
const powerEndpoint = endpoints.find(({ name }) => name === "power")!;
@@ -38,7 +38,7 @@ describe("createServiceEndpoints", () => {
});
it("encodes replacements", () => {
- const params = [buildParam("token", "homes/{home}/sensors/{sensor}?token={token}")];
+ const params = [buildParam("token", "homes/{home}/sensors/{sensor}?token={token}", true)];
const endpoints = createServiceEndpoints(params);
const tokenEndpoint = endpoints[0]!;
expect(tokenEndpoint.url({ home: "hq", sensor: "bat/tery", token: "a+b c" })).toBe(
@@ -99,6 +99,23 @@ describe("createServiceEndpoints", () => {
// Non-empty value should replace placeholder
expect(endpoints[0]!.url({ home: "main" })).toBe("homes/main/sensors");
});
+
+ it("fills empty optional params, required ones keep blocking", () => {
+ const params = [
+ buildParam("host", undefined, true),
+ buildParam("user"),
+ buildParam("password"),
+ buildParam("channel", "shelly/channels?uri={host}&user={user}&password={password}"),
+ ];
+ const endpoints = createServiceEndpoints(params);
+ const channel = endpoints.find(({ name }) => name === "channel")!;
+
+ expect(channel.url({ host: "shelly" })).toBe("shelly/channels?uri=shelly&user=&password=");
+ expect(channel.url({ host: "shelly", user: "admin", password: "" })).toBe(
+ "shelly/channels?uri=shelly&user=admin&password="
+ );
+ expect(channel.url({ user: "admin" })).toBe("shelly/channels?uri={host}&user=admin&password=");
+ });
});
describe("applyDefaultsFromTemplate", () => {
diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts
index 8c29d3f041..c33082d1d7 100644
--- a/assets/js/components/Config/DeviceModal/index.ts
+++ b/assets/js/components/Config/DeviceModal/index.ts
@@ -175,7 +175,8 @@ export async function loadServiceValues(path: string, retry?: (seconds: number)
});
const retryAfter = Number(response.headers["retry-after"]);
if (retryAfter > 0) retry?.(retryAfter);
- return (response.data as ServiceValue[]) || [];
+ // 4xx bodies (e.g. a 404 for a path with an empty optional param) are not suggestions
+ return Array.isArray(response.data) ? (response.data as ServiceValue[]) : [];
} catch {
return [];
}
@@ -209,6 +210,10 @@ export const createServiceEndpoints = (params: TemplateParam[]): ParamService[]
if (!param.Service) {
return null;
}
+ // empty optional params (e.g. user/password) must not block the fetch
+ const optional = extractPlaceholders(param.Service).filter((name) =>
+ params.some((p) => p.Name === name && name !== "modbus" && !p.Required)
+ );
const stringValues = (values: Record): Record =>
Object.entries(values).reduce(
(acc, [key, val]) => {
@@ -216,7 +221,7 @@ export const createServiceEndpoints = (params: TemplateParam[]): ParamService[]
acc[key] = String(val);
return acc;
},
- {} as Record
+ Object.fromEntries(optional.map((name) => [name, ""])) as Record
);
return {
diff --git a/templates/README.md b/templates/README.md
index 147f24f698..7a06f93454 100644
--- a/templates/README.md
+++ b/templates/README.md
@@ -363,7 +363,7 @@ auth:
**Format**: `service-name/endpoint` or `service-name/endpoint?param1={param1}¶m2={param2}`
-Parameters from other params can be referenced using `{param-name}` syntax, which will be replaced with the user's input for that parameter. `{template}` is the name of the selected template. The endpoint will only be called once the user has entered values for all referenced parameters. The endpoint is called every time a referenced parameter value changes.
+Parameters from other params can be referenced using `{param-name}` syntax, which will be replaced with the user's input for that parameter. `{template}` is the name of the selected template. The endpoint is only called once all referenced required parameters have a value. Referenced optional parameters, e.g. `user` or `password`, are replaced with an empty string while empty, so the service must handle missing values. The endpoint is called every time a referenced parameter value changes.
**Response formats**:
diff --git a/tests/config-param-service-demo.tpl.yaml b/tests/config-param-service-demo.tpl.yaml
index 3e92678e28..9f970e9ea2 100644
--- a/tests/config-param-service-demo.tpl.yaml
+++ b/tests/config-param-service-demo.tpl.yaml
@@ -32,6 +32,12 @@ params:
generic: Required address
required: true
service: demo/options
+ - name: user
+ - name: credentials
+ description:
+ generic: Credentials
+ required: true
+ service: demo/echo?user={user}
render: |
type: custom
diff --git a/tests/config-param-service.spec.ts b/tests/config-param-service.spec.ts
index 0a054e5b4f..08e5f4146c 100644
--- a/tests/config-param-service.spec.ts
+++ b/tests/config-param-service.spec.ts
@@ -65,6 +65,18 @@ test.describe("config param service", async () => {
await expectDatalistOptions(city, []);
});
+ test("optional placeholders", async ({ page }) => {
+ const meterModal = await openMeterModal(page);
+ const credentials = meterModal.getByLabel("Credentials");
+
+ // empty optional params are sent as empty strings
+ await expect(credentials).toHaveValue("user=");
+
+ await credentials.fill("");
+ await meterModal.getByLabel("Username").fill("admin");
+ await expect(credentials).toHaveValue("user=admin");
+ });
+
test("auto-apply single service value", async ({ page }) => {
const meterModal = await openMeterModal(page);
diff --git a/util/service/demo.go b/util/service/demo.go
index 2c2981b11d..95aaf85da7 100644
--- a/util/service/demo.go
+++ b/util/service/demo.go
@@ -15,6 +15,7 @@ func init() {
mux.HandleFunc("GET /modbus", getModbus)
mux.HandleFunc("GET /insecure", getInsecure)
mux.HandleFunc("GET /options", getOptions)
+ mux.HandleFunc("GET /echo", getEcho)
service.Register("demo", mux)
}
@@ -55,6 +56,10 @@ func getInsecure(w http.ResponseWriter, req *http.Request) {
json.NewEncoder(w).Encode([]string{"insecure=" + req.URL.Query().Get("insecure")})
}
+func getEcho(w http.ResponseWriter, req *http.Request) {
+ json.NewEncoder(w).Encode([]string{req.URL.RawQuery})
+}
+
func getModbus(w http.ResponseWriter, req *http.Request) {
// Verify that either uri or device is provided (mimics modbus connection params)
uri := req.URL.Query().Get("uri")
From c4ff64b4dee9dfe811fa4c8ee4daf9d645484489 Mon Sep 17 00:00:00 2001
From: andig
Date: Mon, 12 Oct 2026 12:47:11 +0200
Subject: [PATCH 49/61] Optimizer: hourly slots beyond 48 hours (#34738)
---
assets/js/components/Optimize/ChargeChart.vue | 32 +++++---
assets/js/components/Optimize/chart.test.ts | 18 ++++-
assets/js/components/Optimize/chart.ts | 18 +++++
core/site_optimizer.go | 75 ++++++++++++++++++-
core/site_optimizer_test.go | 52 +++++++++++++
5 files changed, 183 insertions(+), 12 deletions(-)
diff --git a/assets/js/components/Optimize/ChargeChart.vue b/assets/js/components/Optimize/ChargeChart.vue
index 18f1964f1f..798440afe3 100644
--- a/assets/js/components/Optimize/ChargeChart.vue
+++ b/assets/js/components/Optimize/ChargeChart.vue
@@ -29,6 +29,8 @@ import type { Legend } from "../Sessions/types";
import {
slotTimes,
slotXAxis,
+ slotParents,
+ splitSlots,
dayBoundarySeries,
formatSlotRange,
whToKW,
@@ -97,19 +99,26 @@ export default defineComponent({
...details.map((d, i) => ({
label: demandTitle(d, this.consumptionLabel),
color: this.demandColors[i] || this.consumptionColor,
- data: d.values.map(this.toKW),
+ data: this.split(d.values).map(this.toKW),
id: d.title ? loadpointTitle(d) : undefined,
})),
...this.batteryEntries("vehicle"),
...this.batteryEntries("battery"),
];
},
+ // request slots beyond 48h may be clock hours, the chart shows them as quarters
+ parents(): number[] {
+ return slotParents(this.evopt.req.time_series.dt);
+ },
+ dt(): number[] {
+ return this.split(this.evopt.req.time_series.dt);
+ },
times(): number[] {
- return slotTimes(this.timestamp, this.evopt.req.time_series.dt);
+ return slotTimes(this.timestamp, this.dt);
},
gridPower(): number[] {
- const gridImport = this.evopt.res.grid_import || [];
- const gridExport = this.evopt.res.grid_export || [];
+ const gridImport = this.split(this.evopt.res.grid_import || []);
+ const gridExport = this.split(this.evopt.res.grid_export || []);
return gridImport.map((imp, i) => {
const importKW = this.toKW(imp, i);
const exportKW = this.toKW(gridExport[i] || 0, i);
@@ -138,7 +147,7 @@ export default defineComponent({
name: SOLAR_LABEL,
type: "line",
z: 4,
- data: this.evopt.req.time_series.ft.map(this.toKW),
+ data: this.split(this.evopt.req.time_series.ft).map(this.toKW),
smooth: 0.2,
...transientHoverDot(colors.forecast || ""),
lineStyle: { color: colors.forecast || "", ...lineDefaults },
@@ -212,8 +221,11 @@ export default defineComponent({
},
},
methods: {
+ split(values: number[]): number[] {
+ return splitSlots(values, this.parents);
+ },
toKW(wh: number, index: number): number {
- return whToKW(wh, this.evopt.req.time_series.dt[index] || 0);
+ return whToKW(wh, this.dt[index] || 0);
},
formatValue(value: number): string {
return this.fmtW(value * 1000, POWER_UNIT.AUTO);
@@ -223,9 +235,10 @@ export default defineComponent({
const battery = this.evopt.res.batteries[index];
if (detail.type !== type || !battery) return [];
// charging positive, discharging negative; one of both is always zero
- const data = battery.charging_power.map((charging, i) => {
+ const discharging = this.split(battery.discharging_power);
+ const data = this.split(battery.charging_power).map((charging, i) => {
const chargingKW = this.toKW(charging, i);
- const dischargingKW = this.toKW(battery.discharging_power[i] || 0, i);
+ const dischargingKW = this.toKW(discharging[i] || 0, i);
return chargingKW > 0 ? chargingKW : -dischargingKW;
});
return [
@@ -243,8 +256,7 @@ export default defineComponent({
): string {
const arr = Array.isArray(params) ? params : [params];
if (!arr.length) return "";
- const dt = this.evopt.req.time_series.dt;
- const head = formatSlotRange(this.times, dt, arr[0]!.dataIndex);
+ const head = formatSlotRange(this.times, this.dt, arr[0]!.dataIndex);
const rows: TooltipRow[] = arr
.filter((p) => p.value != null && !Number.isNaN(p.value))
.filter((p) => !p.seriesName?.endsWith("-casing"))
diff --git a/assets/js/components/Optimize/chart.test.ts b/assets/js/components/Optimize/chart.test.ts
index 320582512c..29c7d6ae67 100644
--- a/assets/js/components/Optimize/chart.test.ts
+++ b/assets/js/components/Optimize/chart.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vite-plus/test";
-import { dayBoundarySeries, isMidnight, slotXAxis } from "./chart";
+import { dayBoundarySeries, isMidnight, slotParents, slotXAxis, splitSlots } from "./chart";
// 15min slots starting at the given local hour
function times(startHour: number, count: number): number[] {
@@ -32,3 +32,19 @@ describe("slotXAxis", () => {
expect(dayBoundarySeries(times(0, 4)).markLine.data).toEqual([]);
});
});
+
+describe("splitSlots", () => {
+ // partial first slot, a quarter, a clock hour, a half hour cut by a goal
+ const dt = [420, 900, 3600, 1800];
+ const parents = slotParents(dt);
+
+ test("shows hours as quarters", () => {
+ expect(parents).toEqual([0, 1, 2, 2, 2, 2, 3, 3]);
+ expect(splitSlots(dt, parents)).toEqual([420, 900, 900, 900, 900, 900, 900, 900]);
+ });
+
+ test("shares energy evenly, power per slot unchanged", () => {
+ expect(splitSlots([7, 10, 400, 100], parents)).toEqual([7, 10, 100, 100, 100, 100, 50, 50]);
+ expect(splitSlots([], parents)).toEqual([]);
+ });
+});
diff --git a/assets/js/components/Optimize/chart.ts b/assets/js/components/Optimize/chart.ts
index ddff4a19d6..a3e198e202 100644
--- a/assets/js/components/Optimize/chart.ts
+++ b/assets/js/components/Optimize/chart.ts
@@ -56,6 +56,24 @@ export function slotXAxis(times: number[], weekdayShort: (d: Date) => string) {
};
}
+const QUARTER_SECONDS = 15 * 60;
+
+// source slot index per chart slot: a slot longer than a quarter hour is shown as equal
+// quarters, so the category axis keeps one width per slot when the request merges its
+// horizon tail into clock hours
+export function slotParents(dt: number[]): number[] {
+ return dt.flatMap((s, i) => Array(Math.max(1, Math.round(s / QUARTER_SECONDS))).fill(i));
+}
+
+// values per chart slot, a split slot shares its value evenly among its quarters: energies
+// and dt both shrink, power per slot stays
+export function splitSlots(values: number[], parents: number[]): number[] {
+ if (!values.length) return [];
+ const count: number[] = [];
+ for (const p of parents) count[p] = (count[p] ?? 0) + 1;
+ return parents.map((p) => (values[p] ?? 0) / (count[p] ?? 1));
+}
+
export function formatSlotRange(times: number[], dt: number[], index: number): string {
const start = new Date(times[index] ?? 0);
const end = new Date(start.getTime() + (dt[index] || 0) * 1000);
diff --git a/core/site_optimizer.go b/core/site_optimizer.go
index 4b8d0336dd..db105bc3ab 100644
--- a/core/site_optimizer.go
+++ b/core/site_optimizer.go
@@ -507,7 +507,8 @@ func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.Optim
minLen = min(minLen, len(solar))
}
- if optimizerURI() == OPTIMIZER_URI {
+ hosted := optimizerURI() == OPTIMIZER_URI
+ if hosted {
minLen = slotsUntil(grid, optimizerHorizon(time.Now()), minLen)
}
@@ -669,6 +670,10 @@ func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.Optim
details.DemandDetails = nil
}
+ if hosted {
+ coarsenHorizon(&req, &details)
+ }
+
return req, details, nil
}
@@ -1355,6 +1360,74 @@ func asTimestamps(dt []int, now time.Time) []time.Time {
return res
}
+// coarseSlotsAfter is where the request switches from quarter hour to clock hour slots: the long
+// horizons are the requests that take the hosted optimizer's slow split path and set its p99.
+const coarseSlotsAfter = 48 * time.Hour
+
+// coarsenHorizon merges the quarter hour slots beyond coarseSlotsAfter into clock hours. Quarter
+// slots bridge to the first full hour, and a plan goal ends its slot so the departure keeps its
+// time. Energies are summed, prices averaged by duration.
+func coarsenHorizon(req *optimizer.OptimizationInput, details *requestDetails) {
+ dt, ts := req.TimeSeries.Dt, details.Timestamps
+
+ goalEnds := func(i int) bool {
+ return slices.ContainsFunc(req.Batteries, func(b optimizer.BatteryConfig) bool {
+ return i < len(b.SGoal) && b.SGoal[i] != 0
+ })
+ }
+
+ // first slot index of every output slot; hourly latches at the first full hour past the boundary
+ starts := make([]int, 0, len(dt))
+ coarse := ts[0].Add(coarseSlotsAfter)
+ var hourly bool
+ for i := range dt {
+ if hourly && ts[i].Minute() != 0 && !goalEnds(i-1) {
+ continue
+ }
+ starts = append(starts, i)
+ hourly = hourly || (!ts[i].Before(coarse) && ts[i].Minute() == 0)
+ }
+ if len(starts) == len(dt) {
+ return
+ }
+ ends := append(slices.Clone(starts[1:]), len(dt))
+
+ coarseDt := lo.Map(starts, func(s, g int) int { return lo.Sum(dt[s:ends[g]]) })
+ sum := func(v []float32) []float32 {
+ if v == nil {
+ return nil
+ }
+ return lo.Map(starts, func(s, g int) float32 { return lo.Sum(v[s:ends[g]]) })
+ }
+ mean := func(v []float32) []float32 {
+ return lo.Map(starts, func(s, g int) float32 {
+ var weighted float32
+ for i := s; i < ends[g]; i++ {
+ weighted += v[i] * float32(dt[i])
+ }
+ return weighted / float32(coarseDt[g])
+ })
+ }
+
+ req.TimeSeries.Dt = coarseDt
+ req.TimeSeries.Gt = sum(req.TimeSeries.Gt)
+ req.TimeSeries.Ft = sum(req.TimeSeries.Ft)
+ req.TimeSeries.PN = mean(req.TimeSeries.PN)
+ req.TimeSeries.PE = mean(req.TimeSeries.PE)
+ for i := range req.Batteries {
+ b := &req.Batteries[i]
+ b.PDemand = sum(b.PDemand)
+ if b.SGoal != nil {
+ // a goal ends its group, so it sits in the group's last slot
+ b.SGoal = lo.Map(starts, func(s, g int) float32 { return b.SGoal[ends[g]-1] })
+ }
+ }
+ for i := range details.DemandDetails {
+ details.DemandDetails[i].Values = sum(details.DemandDetails[i].Values)
+ }
+ details.Timestamps = lo.Map(starts, func(s, _ int) time.Time { return ts[s] })
+}
+
func scaleAndPrune(rates api.Rates, scale float64, maxLen int) []float32 {
res := make([]float32, 0, maxLen)
diff --git a/core/site_optimizer_test.go b/core/site_optimizer_test.go
index dfeef80033..1a3975e30c 100644
--- a/core/site_optimizer_test.go
+++ b/core/site_optimizer_test.go
@@ -187,6 +187,58 @@ func TestAsTimestamps(t *testing.T) {
}, got)
}
+func TestCoarsenHorizon(t *testing.T) {
+ now := time.Date(2025, 1, 1, 7, 23, 0, 0, time.Local)
+ minLen := 1 + 4*60 // 07:23 until Jan 3rd 19:30
+ dt := timeSteps(minLen, now)
+ ts := asTimestamps(dt, now)
+
+ series := func(v float32) []float32 { return lo.RepeatBy(minLen, func(i int) float32 { return v + float32(i) }) }
+ goal := make([]float32, minLen)
+ goal[204] = 5000 // slot 10:15-10:30 on Jan 3rd, the departure
+ req := optimizer.OptimizationInput{
+ TimeSeries: optimizer.TimeSeries{Dt: dt, Gt: series(100), Ft: series(200), PN: series(0.3), PE: series(0.1)},
+ Batteries: []optimizer.BatteryConfig{{PDemand: series(10), SGoal: goal}, {}},
+ }
+ details := requestDetails{Timestamps: ts, DemandDetails: []demandDetail{{Values: series(50)}}}
+ gt, ft, home := lo.Sum(req.TimeSeries.Gt), lo.Sum(req.TimeSeries.Ft), lo.Sum(details.DemandDetails[0].Values)
+ pn, demand := req.TimeSeries.PN, req.Batteries[0].PDemand
+
+ coarsenHorizon(&req, &details)
+ res := req.TimeSeries
+
+ // quarter slots up to 48h and on to the full hour, then clock hours, the goal and the end cut short
+ require.Len(t, res.Dt, 195+13)
+ assert.Equal(t, 420, res.Dt[0])
+ assert.Equal(t, lo.RepeatBy(194, func(int) int { return 900 }), res.Dt[1:195], "quarter slots until 08:00 on Jan 3rd")
+ assert.Equal(t, time.Date(2025, 1, 3, 8, 0, 0, 0, time.Local), details.Timestamps[195])
+ assert.Equal(t, []int{3600, 3600, 1800, 1800, 3600}, res.Dt[195:200], "goal at 10:30 ends its hour early")
+ assert.Equal(t, 1800, res.Dt[len(res.Dt)-1], "last hour ends with the forecast at 19:30")
+ schedule := optimizerSchedule{timestamps: details.Timestamps, dt: res.Dt}
+ assert.Equal(t, time.Date(2025, 1, 3, 10, 30, 0, 0, time.Local), schedule.end(197))
+ assert.Equal(t, time.Date(2025, 1, 3, 19, 30, 0, 0, time.Local), schedule.end(len(res.Dt)-1))
+
+ // energies are preserved, prices averaged by duration, the goal keeps its end
+ assert.InDelta(t, gt, lo.Sum(res.Gt), 0.1)
+ assert.InDelta(t, ft, lo.Sum(res.Ft), 0.1)
+ assert.InDelta(t, home, lo.Sum(details.DemandDetails[0].Values), 0.1)
+ assert.InDelta(t, lo.Mean(pn[195:199]), res.PN[195], 1e-4)
+ assert.InDelta(t, lo.Sum(demand[195:199]), req.Batteries[0].PDemand[195], 0.01)
+ assert.Equal(t, float32(5000), req.Batteries[0].SGoal[197])
+ assert.Equal(t, 1, lo.CountBy(req.Batteries[0].SGoal, func(v float32) bool { return v != 0 }))
+ assert.Nil(t, req.Batteries[1].PDemand)
+ assert.Nil(t, req.Batteries[1].SGoal)
+
+ // within 48h nothing changes
+ short := optimizer.OptimizationInput{TimeSeries: optimizer.TimeSeries{Dt: timeSteps(4*47, now)}}
+ short.TimeSeries.Gt = lo.RepeatBy(len(short.TimeSeries.Dt), func(int) float32 { return 1 })
+ before := requestDetails{Timestamps: asTimestamps(short.TimeSeries.Dt, now)}
+ after := before
+ coarsenHorizon(&short, &after)
+ assert.Equal(t, 4*47, len(short.TimeSeries.Dt))
+ assert.Equal(t, before, after)
+}
+
func TestPlanSlot(t *testing.T) {
// now aligned to a 15-minute boundary: s_goal[i] models the SoC at the END of slot i,
// so a plan 2h out (exactly 8 slots away) must land on slot 7, not 8 (#33831)
From bbdccca85b0fd8e445289c79b7e34d275925d2ec Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 12 Oct 2026 12:38:39 +0000
Subject: [PATCH 50/61] Battery UI: replace German "Speicher" with
"Hausbatterie"/"Ladestand" (#34717)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
---
i18n/de.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/i18n/de.json b/i18n/de.json
index ab53b77cbd..0b2386232c 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -49,8 +49,8 @@
"buffer": "Energie der Hausbatterie oberhalb von {soc} darf für die Unterstützung der Ladepunkte genutzt werden und zögert die Abschaltung im Smart-Modus hinaus. PV-Ladung automatisch starten {start}.",
"bufferNone": "Energie der Hausbatterie wird nicht für die Ladepunkte genutzt. Setze einen Wert unter {soc}, um die Unterstützung zu aktivieren.",
"bufferStart": {
- "above": "ab einem Speicherstand von {soc}",
- "full": "erst, wenn der Speicher voll ist",
+ "above": "ab einem Ladestand von {soc}",
+ "full": "erst, wenn die Hausbatterie voll ist",
"never": "nur mit genug Überschuss"
},
"bufferTitle": "Batterie als Ladepuffer",
From 4bee189ae74ce65cb78389a2aca357291e4d0456 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Mon, 12 Oct 2026 12:45:56 +0000
Subject: [PATCH 51/61] Config UI: keep auth provider action visible for long
titles (#34737)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Michael Geers
---
.../Config/AuthProvidersCard.stories.ts | 41 +++++++++++++++++++
.../components/Config/AuthProvidersCard.vue | 12 +++---
2 files changed, 48 insertions(+), 5 deletions(-)
create mode 100644 assets/js/components/Config/AuthProvidersCard.stories.ts
diff --git a/assets/js/components/Config/AuthProvidersCard.stories.ts b/assets/js/components/Config/AuthProvidersCard.stories.ts
new file mode 100644
index 0000000000..c0d2e5e207
--- /dev/null
+++ b/assets/js/components/Config/AuthProvidersCard.stories.ts
@@ -0,0 +1,41 @@
+import AuthProvidersCard from "./AuthProvidersCard.vue";
+import type { Meta, StoryFn } from "@storybook/vue3";
+
+export default {
+ title: "Config/AuthProvidersCard",
+ component: AuthProvidersCard,
+ parameters: {
+ layout: "centered",
+ },
+} as Meta;
+
+// narrow: column width in the config device grid
+const Narrow: StoryFn = (args) => ({
+ components: { AuthProvidersCard },
+ setup() {
+ return { args };
+ },
+ template: '',
+});
+
+export const Mixed = Narrow.bind({});
+Mixed.args = {
+ providers: {
+ "Tesla (Model Y)": { id: "tesla", authenticated: true },
+ "Home Assistant (homeassistant.local)": { id: "ha", authenticated: false },
+ },
+};
+
+export const LongTitles = Narrow.bind({});
+LongTitles.args = {
+ providers: {
+ "Home Assistant (https://homeassistant.example.duckdns.org)": {
+ id: "ha1",
+ authenticated: true,
+ },
+ "Home Assistant (https://homeassistant.another-very-long-domain.duckdns.org)": {
+ id: "ha2",
+ authenticated: false,
+ },
+ },
+};
diff --git a/assets/js/components/Config/AuthProvidersCard.vue b/assets/js/components/Config/AuthProvidersCard.vue
index 39a91ae790..879a6a2abb 100644
--- a/assets/js/components/Config/AuthProvidersCard.vue
+++ b/assets/js/components/Config/AuthProvidersCard.vue
@@ -14,18 +14,20 @@
-
+
- {{ provider.title }}
+ {{
+ provider.title
+ }}