Config UI: allow disabling devices (#29455)

Co-authored-by: Michael Geers <michael@geers.tv>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: andig <184815+andig@users.noreply.github.com>
This commit is contained in:
andig 2026-08-11 15:24:40 +02:00 • committed by GitHub
parent ced9ed514b
commit 4465100a65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1149 additions and 189 deletions

View file

@ -4,6 +4,7 @@ const (
// loadpoint settings
Name = "name" // loadpoint name (config identifier)
Title = "title" // loadpoint title
Disabled = "disabled" // loadpoint disabled via config
Mode = "mode" // charge mode
DefaultMode = "defaultMode" // default charge mode
Charger = "charger" // charger ref

View file

@ -150,6 +150,23 @@ func NewSiteFromConfig(other map[string]any) (*Site, error) {
return site, nil
}
func activeMeters(refs []string) ([]config.Device[api.Meter], error) {
var res []config.Device[api.Meter]
for _, ref := range refs {
dev, err := config.Meters().ByName(ref)
if err != nil {
return nil, err
}
if dev.Instance() == nil {
continue
}
res = append(res, dev)
}
return res, nil
}
func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tariff.Tariffs) error {
site.loadpoints = loadpoints
site.tariffs = tariffs
@ -180,7 +197,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
tariff := site.GetTariff(api.TariffUsagePlanner)
// give loadpoints access to vehicles and database
for _, lp := range loadpoints {
for _, lp := range site.activeLoadpoints() {
lp.coordinator = coordinator.NewAdapter(lp, site.coordinator)
lp.planner = planner.New(lp.log, tariff)
@ -211,13 +228,17 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
return err
}
site.gridMeter = dev
if dev.Instance() == nil {
site.log.WARN.Println("missing grid meter instance")
} else {
site.gridMeter = dev
me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef, metrics.Grid)
if err != nil {
return err
me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef, metrics.Grid)
if err != nil {
return err
}
site.collectors[site.Meters.GridMeterRef] = me
}
site.collectors[site.Meters.GridMeterRef] = me
}
// multiple pv
@ -226,6 +247,9 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
if err != nil {
return err
}
if dev.Instance() == nil {
continue
}
site.pvMeters = append(site.pvMeters, dev)
// energy collector (for history persistence and forecast scaling)
@ -251,13 +275,13 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.collectors[metrics.Temperature] = tc
// multiple batteries
for _, ref := range site.Meters.BatteryMetersRef {
dev, err := config.Meters().ByName(ref)
if err != nil {
return err
}
site.batteryMeters = append(site.batteryMeters, dev)
mm, err := activeMeters(site.Meters.BatteryMetersRef)
if err != nil {
return err
}
site.batteryMeters = mm
for _, dev := range mm {
ref := dev.Config().Name
me, err := metrics.NewCollector(metrics.Battery, ref, deviceTitleOrName(dev))
if err != nil {
return err
@ -266,13 +290,13 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
}
// additional meters used only for monitoring
for _, ref := range site.Meters.ExtMetersRef {
dev, err := config.Meters().ByName(ref)
if err != nil {
return err
}
site.extMeters = append(site.extMeters, dev)
mm, err = activeMeters(site.Meters.ExtMetersRef)
if err != nil {
return err
}
site.extMeters = mm
for _, dev := range mm {
ref := dev.Config().Name
me, err := metrics.NewCollector(metrics.Meter, ref, deviceTitleOrName(dev))
if err != nil {
return err
@ -281,13 +305,13 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
}
// auxiliary meters (consumers)
for _, ref := range site.Meters.AuxMetersRef {
dev, err := config.Meters().ByName(ref)
if err != nil {
return err
}
site.auxMeters = append(site.auxMeters, dev)
mm, err = activeMeters(site.Meters.AuxMetersRef)
if err != nil {
return err
}
site.auxMeters = mm
for _, dev := range mm {
ref := dev.Config().Name
me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev))
if err != nil {
return err
@ -296,13 +320,13 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
}
// consumer meters
for _, ref := range site.Meters.ConsumerMetersRef {
dev, err := config.Meters().ByName(ref)
if err != nil {
return err
}
site.consumerMeters = append(site.consumerMeters, dev)
mm, err = activeMeters(site.Meters.ConsumerMetersRef)
if err != nil {
return err
}
site.consumerMeters = mm
for _, dev := range mm {
ref := dev.Config().Name
me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev))
if err != nil {
return err
@ -513,6 +537,10 @@ func (site *Site) DumpConfig() {
site.log.INFO.Printf(" solar: %s", presence[site.GetTariff(api.TariffUsageSolar) != nil])
for i, lp := range site.loadpoints {
if lp == nil {
continue
}
lp.log.INFO.Printf("loadpoint %d:", i+1)
lp.log.INFO.Printf(" mode: %s", lp.GetMode())
@ -565,7 +593,7 @@ func (site *Site) publishLoadpoint(id int, key string, val any) {
// clearPlanLocks clears locked plan goals for all loadpoints
func (site *Site) clearPlanLocks() {
for _, lp := range site.Loadpoints() {
for _, lp := range site.activeLoadpoints() {
lp.ClearPlanLock()
}
}
@ -1065,7 +1093,7 @@ func (site *Site) updateLoadpoints(rates api.Rates) float64 {
sum float64
)
for _, lp := range site.loadpoints {
for _, lp := range site.activeLoadpoints() {
wg.Go(func() {
power := lp.UpdateChargePowerAndCurrents()
site.prioritizer.UpdateChargePowerFlexibility(lp, rates)
@ -1090,7 +1118,7 @@ func (site *Site) reservedPVPower(lp updater) float64 {
prio := lp.EffectivePriority()
var reserved float64
for _, other := range site.loadpoints {
for _, other := range site.activeLoadpoints() {
if other == lp {
continue
}
@ -1310,6 +1338,21 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger
lpDevices := config.Loadpoints().Devices()
for id, lp := range site.loadpoints {
// publish name on the loadpoint's behalf — it doesn't know its own
if id < len(lpDevices) {
site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Name, Val: lpDevices[id].Config().Name}
}
// disabled loadpoint- publish minimal placeholder to keep indexes stable
if lp == nil {
if id < len(lpDevices) {
title, _ := lpDevices[id].Config().Other["title"].(string)
site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Title, Val: title}
}
site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Disabled, Val: true}
continue
}
lpUIChan := make(chan util.Param)
lpPushChan := make(chan messenger.Event)
@ -1327,11 +1370,6 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger
}
}(id)
// publish name on the loadpoint's behalf — it doesn't know its own
if id < len(lpDevices) {
site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Name, Val: lpDevices[id].Config().Name}
}
lp.Prepare(site, lpUIChan, lpPushChan, site.lpUpdateChan)
}
}
@ -1339,15 +1377,16 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger
// loopLoadpoints keeps iterating across loadpoints sending the next to the given channel
func (site *Site) loopLoadpoints(next chan<- updater) {
var logOnce sync.Once
active := site.activeLoadpoints()
for {
if len(site.loadpoints) == 0 {
if len(active) == 0 {
logOnce.Do(func() {
site.log.INFO.Println("no loadpoints configured, running in meter-only mode")
})
next <- nil
} else {
for _, lp := range site.loadpoints {
for _, lp := range active {
next <- lp
}
}

View file

@ -1,6 +1,8 @@
package site
import (
"iter"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
)
@ -15,6 +17,7 @@ type API interface {
Publisher
Loadpoints() []loadpoint.API
ActiveLoadpoints() iter.Seq2[int, loadpoint.API]
Vehicles() Vehicles
Optimize()

View file

@ -3,6 +3,7 @@ package core
import (
"errors"
"fmt"
"iter"
"slices"
"strings"
"time"
@ -169,9 +170,26 @@ func (site *Site) GetBatteryMaxDischargePower() float64 {
return site.batteryMaxDischargePower
}
// Loadpoints returns the loadpoints as api interfaces
// Loadpoints returns the loadpoints as api interfaces.
// Disabled loadpoints are returned as nil to keep indexes stable.
func (site *Site) Loadpoints() []loadpoint.API {
return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) loadpoint.API { return lp })
return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) loadpoint.API {
if lp == nil {
return nil
}
return lp
})
}
// ActiveLoadpoints yields enabled loadpoints with their stable index
func (site *Site) ActiveLoadpoints() iter.Seq2[int, loadpoint.API] {
return func(yield func(int, loadpoint.API) bool) {
for id, lp := range site.loadpoints {
if lp != nil && !yield(id, lp) {
return
}
}
}
}
func (site *Site) hasMeters() bool {
@ -179,12 +197,17 @@ func (site *Site) hasMeters() bool {
}
func (site *Site) IsConfigured() bool {
return len(site.loadpoints) > 0 || site.hasMeters()
return slices.ContainsFunc(site.loadpoints, func(lp *Loadpoint) bool { return lp != nil }) || site.hasMeters()
}
// activeLoadpoints returns the non-disabled loadpoints
func (site *Site) activeLoadpoints() []*Loadpoint {
return lo.Filter(site.loadpoints, func(lp *Loadpoint, _ int) bool { return lp != nil })
}
// loadpointsAsCircuitDevices returns the loadpoints as circuit devices
func (site *Site) loadpointsAsCircuitDevices() []api.CircuitLoad {
return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) api.CircuitLoad { return lp })
return lo.Map(site.activeLoadpoints(), func(lp *Loadpoint, _ int) api.CircuitLoad { return lp })
}
// Vehicles returns the site vehicles

22
core/site_api_test.go Normal file
View file

@ -0,0 +1,22 @@
package core
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoadpointsNilSlots(t *testing.T) {
site := &Site{loadpoints: []*Loadpoint{new(Loadpoint), nil, new(Loadpoint)}}
lps := site.Loadpoints()
assert.Len(t, lps, 3, "disabled loadpoints must keep their slot")
// disabled slot must be untyped nil, not a typed-nil interface
assert.True(t, lps[1] == nil)
assert.NotNil(t, lps[0])
assert.NotNil(t, lps[2])
assert.Len(t, site.activeLoadpoints(), 2)
assert.True(t, site.IsConfigured())
}

View file

@ -208,7 +208,7 @@ func (site *Site) dischargeControlActive(rate api.Rate) bool {
return false
}
for _, lp := range site.Loadpoints() {
for _, lp := range site.activeLoadpoints() {
smartCostActive := site.smartCostActive(lp, rate)
if lp.GetStatus() == api.StatusC && (smartCostActive || lp.IsFastChargingActive()) {
return true

View file

@ -248,6 +248,10 @@ func (site *Site) suggestion(key, currentAction string) *types.Suggestion {
// publishSuggestions publishes the loadpoints' suggestions
func (site *Site) publishSuggestions() {
for id, lp := range site.loadpoints {
if lp == nil {
continue
}
var val any
if s := site.suggestion(loadpointKey(id), loadpointCurrentAction(lp)); s != nil {
val = *s
@ -512,7 +516,7 @@ func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.Optim
// uncontrollable power of loadpoints that cannot be modelled as storage
var unmodelled float64
for id, lp := range site.Loadpoints() {
for id, lp := range site.ActiveLoadpoints() {
// ignore disconnected loadpoints, including StatusNone
if s := lp.GetStatus(); s != api.StatusB && s != api.StatusC {
continue