Config UI: handle breaking changes in templates (#24618)

This commit is contained in:
Michael Geers 2025-10-21 22:47:54 +02:00 • committed by GitHub
parent 454a1923ce
commit ab37e8d967
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 134 additions and 4 deletions

View file

@ -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

View file

@ -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{}) {

View file

@ -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")
}

View file

@ -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);
});
});

View file

@ -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;