diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go index e131d6970..f3315233e 100644 --- a/server/http_config_device_handler.go +++ b/server/http_config_device_handler.go @@ -101,7 +101,6 @@ func deviceConfigMap[T any](class templates.Class, dev config.Device[T]) (map[st } if conf.Type == typeTemplate { - // template device, mask config params, err := sanitizeMasked(class, conf.Other) if err != nil { return nil, err diff --git a/server/http_config_helper.go b/server/http_config_helper.go index a22d754df..7ea912ff0 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -26,7 +26,9 @@ const ( masked = "***" // masked indicates a masked config parameter value ) -var customTypes = []string{"custom", "template", "heatpump", "switchsocket", "sgready", "sgready-boost"} +var ( + customTypes = []string{"custom", "template", "heatpump", "switchsocket", "sgready", "sgready-boost"} +) type configReq struct { config.Properties `json:",inline" mapstructure:",squash"` @@ -105,6 +107,23 @@ func templateForConfig(class templates.Class, conf map[string]any) (templates.Te return templates.ByName(class, typ) } +func filterValidTemplateParams(tmpl *templates.Template, conf map[string]any) map[string]any { + res := make(map[string]any) + + for k, v := range conf { + if k == "template" { + res[k] = v + continue + } + + if i, _ := tmpl.ParamByName(k); i >= 0 { + res[k] = v + } + } + + return res +} + func sanitizeMasked(class templates.Class, conf map[string]any) (map[string]any, error) { tmpl, err := templateForConfig(class, conf) if err != nil { @@ -121,7 +140,7 @@ func sanitizeMasked(class templates.Class, conf map[string]any) (map[string]any, res[k] = v } - return res, nil + return filterValidTemplateParams(&tmpl, res), nil } func mergeMasked(class templates.Class, conf, old map[string]any) (map[string]any, error) { @@ -140,7 +159,7 @@ func mergeMasked(class templates.Class, conf, old map[string]any) (map[string]an res[k] = v } - return res, nil + return filterValidTemplateParams(&tmpl, res), nil } func startDeviceTimeout() (context.Context, context.CancelFunc, chan struct{}) { diff --git a/server/http_config_helper_test.go b/server/http_config_helper_test.go index 4cd433437..9db781d87 100644 --- a/server/http_config_helper_test.go +++ b/server/http_config_helper_test.go @@ -7,6 +7,7 @@ import ( "github.com/evcc-io/evcc/api/globalconfig" "github.com/evcc-io/evcc/plugin/mqtt" "github.com/evcc-io/evcc/util/config" + "github.com/evcc-io/evcc/util/templates" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -134,3 +135,47 @@ func TestSquashedMergeMaskedAny(t *testing.T) { assert.Equal(t, "new", new.User) } } + +func TestMergeMaskedFiltersBehavior(t *testing.T) { + conf := map[string]any{ + "template": "demo-meter", + "power": 200.0, + } + + old := map[string]any{ + "template": "demo-meter", + "power": 100.0, + "outdatedField": "old-value", + } + + result, err := mergeMasked(templates.Meter, conf, old) + require.NoError(t, err) + + assert.Equal(t, 200.0, result["power"]) + assert.Equal(t, "demo-meter", result["template"]) + assert.NotContains(t, result, "outdatedField") +} + +func TestFilterValidTemplateParams(t *testing.T) { + conf := map[string]any{ + "template": "generic", + "usage": "grid", + "capacity": 50.0, + "power": 100.0, + "outdatedField": "should-be-removed", + } + + result := filterValidTemplateParams(&templates.Template{ + TemplateDefinition: templates.TemplateDefinition{ + Params: []templates.Param{ + {Name: "usage"}, + {Name: "power"}, + {Name: "capacity"}, + }, + }, + }, conf) + + assert.Equal(t, "generic", result["template"], "template") + assert.Equal(t, "grid", result["usage"], "usage") + assert.NotContains(t, result, "outdatedField") +} diff --git a/tests/config-invalid-template.spec.ts b/tests/config-invalid-template.spec.ts new file mode 100644 index 000000000..51643b9b0 --- /dev/null +++ b/tests/config-invalid-template.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { expectModalVisible, expectModalHidden, enableExperimental } from "./utils"; + +const CONFIG_INVALID_TEMPLATE = "config-invalid-template.sql"; + +test.use({ baseURL: baseUrl() }); + +test.afterEach(async () => { + await stop(); +}); + +test.describe("invalid template migration", async () => { + test("fix broken grid meter with outdated field", async ({ page }) => { + await start(undefined, CONFIG_INVALID_TEMPLATE); + + await page.goto("/#/config"); + await enableExperimental(page, false); + + // startup error + await expect(page.getByTestId("fatal-error")).toBeVisible(); + await expect(page.getByTestId("fatal-error")).toContainText("invalid key: power_old"); + await expect(page.getByTestId("grid")).toBeVisible(); + + // edit and save broken meter + await page.getByTestId("grid").getByRole("button", { name: "edit" }).click(); + const meterModal = page.getByTestId("meter-modal"); + await expectModalVisible(meterModal); + await expect(meterModal.getByLabel("Manufacturer")).toHaveValue("Demo meter"); + await meterModal.getByLabel("Power").fill("222"); + await meterModal.getByRole("button", { name: "Validate & save" }).click(); + await expectModalHidden(meterModal); + await expect(page.getByTestId("grid")).toBeVisible(); + + // verify restart + await restart(); + await page.reload(); + await expect(page.getByTestId("fatal-error")).not.toBeVisible(); + await expect(page.getByTestId("grid")).toBeVisible(); + await page.getByTestId("grid").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + await expect(meterModal.getByLabel("Power")).toHaveValue("222"); + await meterModal.getByRole("button", { name: "Close" }).click(); + await expectModalHidden(meterModal); + }); +}); diff --git a/tests/config-invalid-template.sql b/tests/config-invalid-template.sql new file mode 100644 index 000000000..9325272c8 --- /dev/null +++ b/tests/config-invalid-template.sql @@ -0,0 +1,21 @@ +BEGIN; + +CREATE TABLE `configs` ( + `id` integer PRIMARY KEY AUTOINCREMENT + , `class` integer + , `type` text + , `title` text + , `icon` text + , `product` text + , `value` text +); +CREATE TABLE `settings` ( + `key` text + , `value` text + , PRIMARY KEY(`key`) +); + +INSERT INTO configs(id, class, type, title, icon, product, value) VALUES(1, 2, 'template', '', '', 'Demo meter', '{"maxacpower":"0","power_old":222,"template":"demo-meter","usage":"grid"}'); +INSERT INTO settings("key", value) VALUES('gridMeter', 'db:1'); + +COMMIT; \ No newline at end of file