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:
parent
ced9ed514b
commit
4465100a65
43 changed files with 1149 additions and 189 deletions
|
|
@ -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
|
||||
|
|
|
|||
127
core/site.go
127
core/site.go
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
22
core/site_api_test.go
Normal 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())
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue