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
|
|
@ -226,7 +226,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
|
|||
|
||||
// loadpoint api
|
||||
// TODO any loadpoint
|
||||
for id, lp := range site.Loadpoints() {
|
||||
for id, lp := range site.ActiveLoadpoints() {
|
||||
api := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id+1)).Subrouter()
|
||||
|
||||
routes := map[string]route{
|
||||
|
|
|
|||
|
|
@ -598,7 +598,7 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
|
|||
// cleanup references
|
||||
for _, dev := range h.Devices() {
|
||||
lp := dev.Instance()
|
||||
if lp.GetChargerRef() == config.NameForID(id) {
|
||||
if lp != nil && lp.GetChargerRef() == config.NameForID(id) {
|
||||
lp.SetChargerRef("")
|
||||
}
|
||||
}
|
||||
|
|
@ -628,7 +628,7 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
|
|||
|
||||
for _, dev := range h.Devices() {
|
||||
lp := dev.Instance()
|
||||
if lp.GetMeterRef() == name {
|
||||
if lp != nil && lp.GetMeterRef() == name {
|
||||
lp.SetMeterRef("")
|
||||
}
|
||||
}
|
||||
|
|
@ -639,7 +639,7 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
|
|||
// cleanup references
|
||||
for _, dev := range h.Devices() {
|
||||
lp := dev.Instance()
|
||||
if lp.GetDefaultVehicleRef() == config.NameForID(id) {
|
||||
if lp != nil && lp.GetDefaultVehicleRef() == config.NameForID(id) {
|
||||
lp.SetDefaultVehicleRef("")
|
||||
}
|
||||
}
|
||||
|
|
@ -650,7 +650,7 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
|
|||
// cleanup references
|
||||
for _, dev := range h.Devices() {
|
||||
lp := dev.Instance()
|
||||
if lp.GetCircuitRef() == config.NameForID(id) {
|
||||
if lp != nil && lp.GetCircuitRef() == config.NameForID(id) {
|
||||
lp.SetCircuitRef("")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,9 +69,15 @@ func propsToMap(props config.Properties) (map[string]any, error) {
|
|||
}
|
||||
|
||||
return lo.PickBy(res, func(k string, v any) bool {
|
||||
if k == "Type" || v.(string) == "" {
|
||||
if k == "Type" {
|
||||
return false
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val != ""
|
||||
case bool:
|
||||
return val
|
||||
}
|
||||
return true
|
||||
}), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,19 +106,34 @@ func TestConfigReqUnmarshal(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestConfigReqMarshalToMap(t *testing.T) {
|
||||
props := config.Properties{
|
||||
res, err := propsToMap(config.Properties{
|
||||
Type: "type",
|
||||
Title: "title",
|
||||
Product: "product",
|
||||
}
|
||||
|
||||
res, err := propsToMap(props)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, map[string]any{
|
||||
"deviceTitle": "title",
|
||||
"deviceProduct": "product",
|
||||
}, res)
|
||||
|
||||
// Disable=false is omitted (zero value)
|
||||
res, err = propsToMap(config.Properties{
|
||||
Type: "type",
|
||||
Title: "title",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, res, "deviceDisable")
|
||||
|
||||
// Disable=true is included
|
||||
res, err = propsToMap(config.Properties{
|
||||
Type: "type",
|
||||
Disable: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]any{
|
||||
"deviceDisable": true,
|
||||
}, res)
|
||||
}
|
||||
|
||||
type testStruct struct {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import (
|
|||
"github.com/evcc-io/evcc/util/config"
|
||||
"github.com/evcc-io/evcc/util/templates"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func getLoadpointStaticConfig(lp loadpoint.API) loadpoint.StaticConfig {
|
||||
|
|
@ -50,68 +49,116 @@ func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig {
|
|||
}
|
||||
|
||||
type loadpointFullConfig struct {
|
||||
ID int `json:"id,omitempty"` // db row id
|
||||
Name string `json:"name"` // either slice index (yaml) or db:<row id>
|
||||
ID int `json:"id,omitempty"` // db row id
|
||||
Name string `json:"name"` // either slice index (yaml) or db:<row id>
|
||||
Disable bool `json:"disable,omitempty"`
|
||||
|
||||
// static config
|
||||
loadpoint.StaticConfig
|
||||
loadpoint.DynamicConfig
|
||||
}
|
||||
|
||||
func loadpointSplitConfig(r io.Reader) (loadpoint.DynamicConfig, map[string]any, error) {
|
||||
func loadpointSplitConfig(r io.Reader) (loadpoint.DynamicConfig, map[string]any, map[string]any, *bool, error) {
|
||||
var payload map[string]any
|
||||
|
||||
if err := jsonDecoder(r).Decode(&payload); err != nil {
|
||||
return loadpoint.DynamicConfig{}, nil, err
|
||||
return loadpoint.DynamicConfig{}, nil, nil, nil, err
|
||||
}
|
||||
|
||||
return loadpoint.SplitConfig(payload)
|
||||
// nil if not part of the payload- keeps the persisted flag
|
||||
var disable *bool
|
||||
if v, ok := payload["disable"].(bool); ok {
|
||||
disable = &v
|
||||
}
|
||||
delete(payload, "disable")
|
||||
delete(payload, "id")
|
||||
delete(payload, "name")
|
||||
|
||||
dynamic, static, err := loadpoint.SplitConfig(payload)
|
||||
return dynamic, static, payload, disable, err
|
||||
}
|
||||
|
||||
// setDeviceDisable persists the disable flag on a loadpoint's subdevice
|
||||
func setDeviceDisable[T any](name string, h config.Handler[T], disable bool) error {
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
dev, err := configurableDevice(name, h)
|
||||
if err != nil {
|
||||
// static device- cannot persist
|
||||
return nil
|
||||
}
|
||||
|
||||
props := dev.Properties()
|
||||
if props.Disable == disable {
|
||||
return nil
|
||||
}
|
||||
props.Disable = disable
|
||||
|
||||
return dev.Update(dev.Config().Other, dev.Instance(), config.WithProperties(props))
|
||||
}
|
||||
|
||||
// loadpointConfig returns a single loadpoint's configuration
|
||||
func loadpointConfig(dev config.Device[loadpoint.API]) loadpointFullConfig {
|
||||
var id int
|
||||
func loadpointConfig(dev config.Device[loadpoint.API]) (loadpointFullConfig, error) {
|
||||
var (
|
||||
id int
|
||||
disable bool
|
||||
)
|
||||
if configurable, ok := dev.(config.ConfigurableDevice[loadpoint.API]); ok {
|
||||
id = configurable.ID()
|
||||
disable = configurable.Properties().Disable
|
||||
}
|
||||
|
||||
lp := dev.Instance()
|
||||
|
||||
// // missing instance due to error, decode config from database
|
||||
// if lp == nil || reflect.ValueOf(lp).IsNil() {
|
||||
// cc := dev.Config()
|
||||
// disabled loadpoint has no live instance; decode static config from the database instead
|
||||
if lp == nil {
|
||||
dynamic, staticMap, err := loadpoint.SplitConfig(dev.Config().Other)
|
||||
if err != nil {
|
||||
return loadpointFullConfig{}, err
|
||||
}
|
||||
|
||||
// dynamic, staticMap, _ := loadpoint.SplitConfig(cc.Other)
|
||||
var static loadpoint.StaticConfig
|
||||
if err := util.DecodeOther(staticMap, &static); err != nil {
|
||||
return loadpointFullConfig{}, err
|
||||
}
|
||||
|
||||
// var static loadpoint.StaticConfig
|
||||
// _ = util.DecodeOther(staticMap, &static)
|
||||
|
||||
// res := loadpointFullConfig{
|
||||
// ID: id,
|
||||
// Name: dev.Config().Name,
|
||||
// StaticConfig: static,
|
||||
// DynamicConfig: dynamic,
|
||||
// }
|
||||
|
||||
// return res
|
||||
// }
|
||||
return loadpointFullConfig{
|
||||
ID: id,
|
||||
Name: dev.Config().Name,
|
||||
Disable: disable,
|
||||
StaticConfig: static,
|
||||
DynamicConfig: dynamic,
|
||||
}, nil
|
||||
}
|
||||
|
||||
res := loadpointFullConfig{
|
||||
ID: id,
|
||||
Name: dev.Config().Name,
|
||||
Disable: disable,
|
||||
StaticConfig: getLoadpointStaticConfig(lp),
|
||||
DynamicConfig: getLoadpointDynamicConfig(lp),
|
||||
}
|
||||
|
||||
return res
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// loadpointsConfigHandler returns a device configurations by class
|
||||
func loadpointsConfigHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
res := lo.Map(config.Loadpoints().Devices(), func(dev config.Device[loadpoint.API], _ int) loadpointFullConfig {
|
||||
return loadpointConfig(dev)
|
||||
})
|
||||
devices := config.Loadpoints().Devices()
|
||||
|
||||
res := make([]loadpointFullConfig, 0, len(devices))
|
||||
for _, dev := range devices {
|
||||
c, err := loadpointConfig(dev)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
res = append(res, c)
|
||||
}
|
||||
|
||||
jsonWrite(w, res)
|
||||
}
|
||||
|
|
@ -136,7 +183,11 @@ func loadpointConfigHandler() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
res := loadpointConfig(dev)
|
||||
res, err := loadpointConfig(dev)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonWrite(w, res)
|
||||
}
|
||||
|
|
@ -149,7 +200,7 @@ func newLoadpointHandler() http.HandlerFunc {
|
|||
// TODO revert charger, meter etc
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
dynamic, static, err := loadpointSplitConfig(r.Body)
|
||||
dynamic, static, _, disable, err := loadpointSplitConfig(r.Body)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
|
|
@ -159,7 +210,7 @@ func newLoadpointHandler() http.HandlerFunc {
|
|||
name := "lp-" + strconv.Itoa(id+1)
|
||||
log := util.NewLoggerWithLoadpoint(name, id+1)
|
||||
|
||||
conf, err := config.AddConfig(templates.Loadpoint, static)
|
||||
conf, err := config.AddConfig(templates.Loadpoint, static, config.WithProperties(config.Properties{Disable: disable != nil && *disable}))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
|
|
@ -220,34 +271,58 @@ func updateLoadpointHandler() http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
dynamic, static, err := loadpointSplitConfig(r.Body)
|
||||
dynamic, static, payload, disable, err := loadpointSplitConfig(r.Body)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// static
|
||||
|
||||
// merge here to maintain dynamic part of the config
|
||||
other := configurable.Config().Other
|
||||
if err := mergo.Merge(&other, static, mergo.WithOverride); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
props := configurable.Properties()
|
||||
if disable != nil {
|
||||
props.Disable = *disable
|
||||
}
|
||||
|
||||
instance := dev.Instance()
|
||||
|
||||
if err := configurable.Update(other, instance); err != nil {
|
||||
// merge static config to maintain the dynamic part; without live instance
|
||||
// merge the full payload since dynamic setters cannot persist it
|
||||
src := static
|
||||
if instance == nil {
|
||||
src = payload
|
||||
}
|
||||
|
||||
other := configurable.Config().Other
|
||||
if err := mergo.Merge(&other, src, mergo.WithOverride); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// dynamic
|
||||
if err := dynamic.Apply(instance); err != nil {
|
||||
if err := configurable.Update(other, instance, config.WithProperties(props)); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// propagate disable to the loadpoint's charger and meter
|
||||
chargerRef, _ := other["charger"].(string)
|
||||
if err := setDeviceDisable(chargerRef, config.Chargers(), props.Disable); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
meterRef, _ := other["meter"].(string)
|
||||
if err := setDeviceDisable(meterRef, config.Meters(), props.Disable); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setConfigDirty()
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
|
@ -276,6 +351,20 @@ func deleteLoadpointHandler() http.HandlerFunc {
|
|||
|
||||
instance := lp.Instance()
|
||||
|
||||
// disabled loadpoint has no live instance; delete it without ref cleanup
|
||||
if instance == nil {
|
||||
if err := deleteDevice(id, h); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonWrite(w, struct {
|
||||
ID int `json:"id"`
|
||||
}{ID: id})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if dev, err := configurableDevice(instance.GetChargerRef(), config.Chargers()); err == nil {
|
||||
if err := deleteDevice(dev.ID(), config.Chargers()); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
|
|
|
|||
70
server/http_config_loadpoint_handler_test.go
Normal file
70
server/http_config_loadpoint_handler_test.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/evcc-io/evcc/core/loadpoint"
|
||||
"github.com/evcc-io/evcc/server/db"
|
||||
"github.com/evcc-io/evcc/util/config"
|
||||
"github.com/evcc-io/evcc/util/templates"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestLoadpointConfigDisabledNilInstance ensures loadpointConfig does not panic
|
||||
// for a disabled loadpoint whose instance was never created (nil loadpoint.API).
|
||||
func TestLoadpointConfigDisabledNilInstance(t *testing.T) {
|
||||
conf := config.Config{
|
||||
Class: templates.Loadpoint,
|
||||
Properties: config.Properties{Disable: true},
|
||||
Data: map[string]any{
|
||||
"charger": "wallbox",
|
||||
"meter": "lp-meter",
|
||||
"title": "Garage",
|
||||
},
|
||||
}
|
||||
|
||||
var instance loadpoint.API // nil: disabled loadpoint has no live instance
|
||||
dev := config.NewConfigurableDevice(&conf, instance)
|
||||
|
||||
var res loadpointFullConfig
|
||||
require.NotPanics(t, func() {
|
||||
var err error
|
||||
res, err = loadpointConfig(dev)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
assert.True(t, res.Disable)
|
||||
assert.Equal(t, "wallbox", res.Charger)
|
||||
assert.Equal(t, "lp-meter", res.Meter)
|
||||
}
|
||||
|
||||
// TestDeleteLoadpointDisabledNilInstance ensures deleteLoadpointHandler does not
|
||||
// dereference a nil instance when deleting a disabled loadpoint.
|
||||
func TestDeleteLoadpointDisabledNilInstance(t *testing.T) {
|
||||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
|
||||
conf, err := config.AddConfig(templates.Loadpoint,
|
||||
map[string]any{"charger": "wallbox", "title": "Garage"},
|
||||
config.WithProperties(config.Properties{Disable: true}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var instance loadpoint.API // nil: disabled loadpoint has no live instance
|
||||
dev := config.NewConfigurableDevice(&conf, instance)
|
||||
require.NoError(t, config.Loadpoints().Add(dev))
|
||||
t.Cleanup(func() { _ = config.Loadpoints().Delete(config.NameForID(conf.ID)) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/", nil)
|
||||
req = mux.SetURLVars(req, map[string]string{"id": strconv.Itoa(conf.ID)})
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
deleteLoadpointHandler()(rec, req)
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
|
@ -215,7 +215,7 @@ func updateSmartCostLimit(site site.API, setLimit func(loadpoint.API, *float64))
|
|||
val = &f
|
||||
}
|
||||
|
||||
for _, lp := range site.Loadpoints() {
|
||||
for _, lp := range site.ActiveLoadpoints() {
|
||||
setLimit(lp, val)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -185,6 +185,10 @@ func (m *Influx) Run(site site.API, in <-chan util.Param) {
|
|||
tags := make(map[string]string)
|
||||
if param.Loadpoint != nil {
|
||||
lp := site.Loadpoints()[*param.Loadpoint]
|
||||
if lp == nil {
|
||||
// disabled loadpoint- placeholder params only
|
||||
continue
|
||||
}
|
||||
|
||||
tags["loadpoint"] = lp.GetTitle()
|
||||
if v := lp.GetVehicle(); v != nil {
|
||||
|
|
|
|||
4
server/mcp/openapi.json
generated
4
server/mcp/openapi.json
generated
|
|
@ -3133,6 +3133,10 @@
|
|||
"description": "Duration since the vehicle was connected, in seconds.",
|
||||
"type": "number"
|
||||
},
|
||||
"disabled": {
|
||||
"description": "Loadpoint is disabled via configuration.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"disableDelay": {
|
||||
"description": "Delay before charging stops in solar mode, in seconds.",
|
||||
"type": "number"
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ func (m *MQTT) Listen(site site.API) error {
|
|||
}
|
||||
|
||||
// loadpoint setters
|
||||
for id, lp := range site.Loadpoints() {
|
||||
for id, lp := range site.ActiveLoadpoints() {
|
||||
topic := fmt.Sprintf("%s/loadpoints/%d", m.root, id+1)
|
||||
if err := m.listenLoadpointSetters(topic, site, lp); err != nil {
|
||||
return err
|
||||
|
|
@ -221,12 +221,12 @@ func (m *MQTT) listenSiteSetters(topic string, site site.API) error {
|
|||
{"gridExportLimit", floatSetter(site.SetGridExportLimit)},
|
||||
{"solarAdjusted", boolSetter(pass(site.SetSolarAdjusted))},
|
||||
{"smartCostLimit", floatPtrSetter(pass(func(limit *float64) {
|
||||
for _, lp := range site.Loadpoints() {
|
||||
for _, lp := range site.ActiveLoadpoints() {
|
||||
lp.SetSmartCostLimit(limit)
|
||||
}
|
||||
}))},
|
||||
{"smartFeedInPriorityLimit", floatPtrSetter(pass(func(limit *float64) {
|
||||
for _, lp := range site.Loadpoints() {
|
||||
for _, lp := range site.ActiveLoadpoints() {
|
||||
lp.SetSmartFeedInPriorityLimit(limit)
|
||||
}
|
||||
}))},
|
||||
|
|
|
|||
3
server/openapi.state.yaml
generated
3
server/openapi.state.yaml
generated
|
|
@ -827,6 +827,9 @@ components:
|
|||
connectedDuration:
|
||||
description: Duration since the vehicle was connected, in seconds.
|
||||
type: number
|
||||
disabled:
|
||||
description: Loadpoint is disabled via configuration.
|
||||
type: boolean
|
||||
disableDelay:
|
||||
description: Delay before charging stops in solar mode, in seconds.
|
||||
type: number
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue