Config UI: fix editing custom device with type override (#29379)

This commit is contained in:
Michael Geers 2026-04-27 20:01:27 +02:00 • committed by GitHub
parent 23c46dd311
commit e711ccaa86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 61 additions and 40 deletions

View file

@ -11,8 +11,6 @@ import (
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/spf13/cast"
"go.yaml.in/yaml/v4"
)
// parseLogLevels parses --log area:level[,...] switch into levels per log area
@ -97,29 +95,6 @@ func wrapFatalError(err error) error {
return &FatalError{err}
}
// customDevice promotes yaml type to top level type
func customDevice(typ string, other map[string]any) (string, map[string]any, error) {
// no embedded yaml
customYaml, ok := other["yaml"].(string)
if !ok {
return typ, other, nil
}
var res map[string]any
if err := yaml.Unmarshal([]byte(customYaml), &res); err != nil {
return typ, nil, err
}
// type override
if typ := cast.ToString(res["type"]); typ != "" {
delete(res, "type")
return typ, res, nil
}
// no override
return typ, res, nil
}
func deviceHeader[T any](dev config.Device[T]) string {
name := dev.Config().Name

View file

@ -222,7 +222,7 @@ NEXT:
}
}
typ, other, err := customDevice(cc.Type, cc.Other)
typ, other, err := config.CustomDevice(cc.Type, cc.Other)
if err != nil {
return fmt.Errorf("cannot decode custom circuit '%s': %w", cc.Name, err)
}
@ -327,7 +327,7 @@ func configurableInstance[T any](typ string, conf *config.Config, newFromConf ne
cc := conf.Named()
ctx, cancel := context.WithCancel(util.WithLogger(context.TODO(), loggerForConfig(conf))) //nolint:govet
typ, other, err := customDevice(cc.Type, cc.Other)
typ, other, err := config.CustomDevice(cc.Type, cc.Other)
if err != nil {
err = &DeviceError{cc.Name, fmt.Errorf("cannot decode custom %s '%s': %w", typ, cc.Name, err)}
}
@ -443,7 +443,7 @@ func configureChargers(static []config.Named, names ...string) error {
func vehicleInstance(cc config.Named) (api.Vehicle, error) {
ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name))
typ, other, err := customDevice(cc.Type, cc.Other)
typ, other, err := config.CustomDevice(cc.Type, cc.Other)
var instance api.Vehicle
if err == nil {
@ -791,7 +791,7 @@ func configureHEMS(conf *globalconfig.Hems, site *core.Site) (hemsapi.API, error
return nil, nil
}
typ, other, err := customDevice(conf.Type, conf.Other)
typ, other, err := config.CustomDevice(conf.Type, conf.Other)
if err != nil {
return nil, fmt.Errorf("cannot decode custom hems '%s': %w", conf.Type, err)
}
@ -968,7 +968,7 @@ func configureMessengers(confMessaging *globalconfig.Messaging, confEvents *glob
func tariffInstance(name string, conf config.Typed) (api.Tariff, error) {
ctx := util.WithLogger(context.TODO(), util.NewLogger(name))
typ, other, err := customDevice(conf.Type, conf.Other)
typ, other, err := config.CustomDevice(conf.Type, conf.Other)
if err != nil {
return nil, fmt.Errorf("cannot decode custom tariff '%s': %w", name, err)
}

View file

@ -280,7 +280,12 @@ func deviceStatusHandler(w http.ResponseWriter, r *http.Request) {
}
func newDevice[T any](ctx context.Context, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T], force bool) (*config.Config, error) {
instance, err := newFromConf(ctx, req.Type, req.Other)
typ, other, err := config.CustomDevice(req.Type, req.Other)
if err != nil && !force {
return nil, err
}
instance, err := newFromConf(ctx, typ, other)
if err != nil && !force {
return nil, err
}
@ -616,7 +621,12 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
func testConfig[T any](ctx context.Context, id int, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T]) (T, error) {
if id == 0 {
return newFromConf(ctx, req.Type, req.Other)
typ, other, err := config.CustomDevice(req.Type, req.Other)
if err != nil {
var zero T
return zero, err
}
return newFromConf(ctx, typ, other)
}
_, instance, _, err := deviceInstanceFromMergedConfig(ctx, id, class, req, newFromConf, h)

View file

@ -18,7 +18,6 @@ import (
"github.com/evcc-io/evcc/util/templates"
"github.com/go-viper/mapstructure/v2"
"github.com/samber/lo"
"github.com/spf13/cast"
"go.yaml.in/yaml/v4"
)
@ -226,7 +225,11 @@ func deviceInstanceFromMergedConfig[T any](ctx context.Context, id int, class te
// TODO merge custom config
if req.Yaml != "" {
instance, err := newFromConf(ctx, conf.Type, req.Other)
typ, other, err := config.CustomDevice(conf.Type, req.Other)
if err != nil {
return nil, zero, nil, err
}
instance, err := newFromConf(ctx, typ, other)
return dev, instance, req.Serialise(), err
}
@ -441,7 +444,7 @@ func (maskedTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Val
}
}
// decodeDeviceConfig extracts device configuration and yaml details plus type override
// decodeDeviceConfig extracts device configuration and yaml details
func decodeDeviceConfig(r io.Reader) (configReq, error) {
var res configReq
@ -463,14 +466,13 @@ func decodeDeviceConfig(r io.Reader) (configReq, error) {
return configReq{}, errors.New("invalid config: cannot mix yaml and other")
}
if err := yaml.Unmarshal([]byte(res.Yaml), &res.Other); err != nil && err != io.EOF {
// validate yaml syntax
var tmp map[string]any
if err := yaml.Unmarshal([]byte(res.Yaml), &tmp); err != nil && err != io.EOF {
return configReq{}, err
}
if typ := cast.ToString(res.Other["type"]); typ != "" {
res.Type = typ
delete(res.Other, "type")
}
res.Other = map[string]any{"yaml": res.Yaml}
return res, nil
}

View file

@ -51,5 +51,12 @@ uri: http://${simulatorHost()}`
await expect(page.getByTestId("fatal-error")).not.toBeVisible();
await expect(page.getByTestId("grid")).toBeVisible();
await expect(page.getByTestId("grid")).toContainText(["Energy", "0.0 kWh"].join(""));
// edit
await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal);
const editAgain = meterModal.getByTestId("yaml-editor");
await expect(editAgain).toBeVisible();
await expect(editAgain).toContainText("type: shelly");
});
});

27
util/config/custom.go Normal file
View file

@ -0,0 +1,27 @@
package config
import (
"github.com/spf13/cast"
"go.yaml.in/yaml/v4"
)
// CustomDevice promotes an embedded yaml type to the top-level type
func CustomDevice(typ string, other map[string]any) (string, map[string]any, error) {
customYaml, ok := other["yaml"].(string)
if !ok {
return typ, other, nil
}
var res map[string]any
if err := yaml.Unmarshal([]byte(customYaml), &res); err != nil {
return typ, nil, err
}
// type override
if override := cast.ToString(res["type"]); override != "" {
delete(res, "type")
return override, res, nil
}
return typ, res, nil
}