From e711ccaa862e94984b373743b5f6be7d77e5e74e Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 27 Apr 2026 20:01:27 +0200 Subject: [PATCH] Config UI: fix editing custom device with type override (#29379) --- cmd/helper.go | 25 ------------------------- cmd/setup.go | 10 +++++----- server/http_config_device_handler.go | 14 ++++++++++++-- server/http_config_helper.go | 18 ++++++++++-------- tests/config-custom-meter.spec.ts | 7 +++++++ util/config/custom.go | 27 +++++++++++++++++++++++++++ 6 files changed, 61 insertions(+), 40 deletions(-) create mode 100644 util/config/custom.go diff --git a/cmd/helper.go b/cmd/helper.go index f23339c27..202b8afb3 100644 --- a/cmd/helper.go +++ b/cmd/helper.go @@ -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 diff --git a/cmd/setup.go b/cmd/setup.go index a503abf91..bc870e3b7 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -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) } diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go index 9e0eace53..cc322220d 100644 --- a/server/http_config_device_handler.go +++ b/server/http_config_device_handler.go @@ -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) diff --git a/server/http_config_helper.go b/server/http_config_helper.go index ad5826be1..06cd9797b 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -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 } diff --git a/tests/config-custom-meter.spec.ts b/tests/config-custom-meter.spec.ts index 6e082205e..22f8ad5ef 100644 --- a/tests/config-custom-meter.spec.ts +++ b/tests/config-custom-meter.spec.ts @@ -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"); }); }); diff --git a/util/config/custom.go b/util/config/custom.go new file mode 100644 index 000000000..6beed02a5 --- /dev/null +++ b/util/config/custom.go @@ -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 +}