From 78ee91a2b6911c345f9ee57f32a4b7ef50333622 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 11 Mar 2025 20:26:03 +0100 Subject: [PATCH] Add meter and circuit titles and icons (#19416) --- assets/js/components/Config/MeterModal.vue | 55 ++++++++-- .../js/components/Energyflow/Energyflow.vue | 10 +- assets/js/views/Config.vue | 6 +- cmd/config.go | 2 +- core/helper.go | 9 ++ core/site.go | 66 ++++++++---- core/site_battery.go | 7 +- core/site_circuits.go | 5 + core/site_test.go | 3 +- server/http_config_device_handler.go | 58 +++++----- server/http_config_helper.go | 47 +++++++- server/http_config_helper_test.go | 46 ++++++++ server/http_config_loadpoint_handler.go | 2 +- tests/config-battery.spec.js | 6 +- tests/config-onboarding.spec.js | 1 + tests/config-pv.spec.js | 69 ++++++++++++ util/config/config.go | 37 +++++-- util/config/device.go | 101 +++++++++++------- 18 files changed, 415 insertions(+), 115 deletions(-) create mode 100644 server/http_config_helper_test.go create mode 100644 tests/config-pv.spec.js diff --git a/assets/js/components/Config/MeterModal.vue b/assets/js/components/Config/MeterModal.vue index 6d81158d6..44f7c9c04 100644 --- a/assets/js/components/Config/MeterModal.vue +++ b/assets/js/components/Config/MeterModal.vue @@ -20,11 +20,26 @@ />
+ + + +

Loading ...

@@ -145,8 +167,8 @@ import NewDeviceButton from "./NewDeviceButton.vue"; import Modbus from "./Modbus.vue"; import GenericModal from "../GenericModal.vue"; import Markdown from "./Markdown.vue"; - -const initialValues = { type: "template" }; +import PropertyField from "./PropertyField.vue"; +const initialValues = { type: "template", deviceTitle: "", deviceIcon: "" }; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -159,6 +181,7 @@ export default { components: { FormRow, PropertyEntry, + PropertyField, GenericModal, Modbus, TestResult, @@ -201,6 +224,9 @@ export default { meterType() { return this.type || this.selectedType; }, + hasDeviceTitle() { + return ["pv", "battery"].includes(this.meterType); + }, templateOptions() { return this.products.filter((p) => p.group !== "generic"); }, @@ -242,6 +268,9 @@ export default { description() { return this.template?.Requirements?.Description; }, + productName() { + return this.values.deviceProduct || this.templateName; + }, apiData() { return { template: this.templateName, @@ -283,14 +312,21 @@ export default { }, }, methods: { - reset() { - this.values = { ...initialValues }; + reset(keepTitle = false) { + const keep = keepTitle ? { deviceTitle: this.values.deviceTitle } : {}; + this.values = { ...initialValues, ...keep }; this.resetTest(); }, async loadConfiguration() { try { const meter = (await api.get(`config/devices/meter/${this.id}`)).data.result; this.values = meter.config; + // convert structure to flat list + // TODO: adjust GET response to match POST/PUT formats + this.values.type = meter.type; + this.values.deviceTitle = meter.deviceTitle; + this.values.deviceIcon = meter.deviceIcon; + this.values.deviceProduct = meter.deviceProduct; this.applyDefaultsFromTemplate(); this.templateName = this.values.template; } catch (e) { @@ -314,8 +350,8 @@ export default { } }, async loadTemplate() { - if (!this.templateName) return; this.template = null; + if (!this.templateName) return; this.loadingTemplate = true; try { const opts = { @@ -407,8 +443,11 @@ export default { selectType(type) { this.selectedType = type; }, - templateChanged() { - this.reset(); + templateChanged(event) { + this.reset(true); + const select = event.target; + const name = select.options[select.selectedIndex].text; + this.values.deviceProduct = name; }, }, }; diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue index 1449f5dba..1c13b5eea 100644 --- a/assets/js/components/Energyflow/Energyflow.vue +++ b/assets/js/components/Energyflow/Energyflow.vue @@ -352,7 +352,10 @@ export default { if (!Array.isArray(this.pv) || this.pv.length <= 1) { return; } - return this.pv.map(({ power }) => this.fmtW(power, this.powerUnit)); + return this.pv.map( + ({ power, title }) => + `${title ? `${title}: ` : ""}${this.fmtW(power, this.powerUnit)}` + ); }, batteryDischargeTooltip() { return this.batteryTooltip(true); @@ -490,11 +493,12 @@ export default { if (!Array.isArray(this.battery) || this.battery.length <= 1) { return; } - return this.battery.map(({ power, soc }) => { + return this.battery.map(({ power, soc, title }) => { const value = discharge ? this.dischargePower(power) : this.chargePower(power); + const powerFmt = this.fmtW(value, this.powerUnit); const socFmt = this.fmtPercentage(soc, 0); - return `${powerFmt} (${socFmt})`; + return `${title ? `${title}: ` : ""}${powerFmt} (${socFmt})`; }); }, }, diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index 5e6b620d5..af085911f 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -143,7 +143,7 @@ 0 { for i, pv := range site.pvMeters { - site.log.INFO.Println(meterCapabilities(fmt.Sprintf("pv %d", i+1), pv)) + site.log.INFO.Println(meterCapabilities(fmt.Sprintf("pv %d", i+1), pv.Instance())) } } if len(site.batteryMeters) > 0 { - for i, battery := range site.batteryMeters { + for i, dev := range site.batteryMeters { + battery := dev.Instance() _, ok := battery.(api.Battery) _, hasCapacity := battery.(api.BatteryCapacity) @@ -469,11 +476,13 @@ func (site *Site) publish(key string, val interface{}) { site.uiChan <- util.Param{Key: key, Val: val} } -func (site *Site) collectMeters(key string, meters []api.Meter) []measurement { +func (site *Site) collectMeters(key string, meters []config.Device[api.Meter]) []measurement { var wg sync.WaitGroup mm := make([]measurement, len(meters)) - fun := func(i int, meter api.Meter) { + fun := func(i int, dev config.Device[api.Meter]) { + meter := dev.Instance() + // power power, err := backoff.RetryWithData(meter.CurrentPower, bo()) if err == nil { @@ -491,7 +500,10 @@ func (site *Site) collectMeters(key string, meters []api.Meter) []measurement { } } + props := deviceProperties(dev) mm[i] = measurement{ + Title: props.Title, + Icon: props.Icon, Power: power, Energy: energy, } @@ -516,9 +528,13 @@ func (site *Site) updatePvMeters() { mm := site.collectMeters("pv", site.pvMeters) - for i, meter := range site.pvMeters { - power := mm[i].Power + for i, dev := range site.pvMeters { + meter := dev.Instance() + if _, ok := meter.(api.Meter); !ok { + panic("not a meter: pv") + } + power := mm[i].Power if power < -500 { site.log.WARN.Printf("pv %d power: %.0fW is negative - check configuration if sign is correct", i+1, power) } @@ -555,7 +571,14 @@ func (site *Site) updatePvMeters() { site.publish(keys.Pv, mm) // update solar yield - for i, name := range site.Meters.PVMetersRef { + for i, dev := range site.pvMeters { + // use stored devices, not ui-updated instances! + if _, ok := dev.(config.Device[api.Meter]); !ok { + panic(fmt.Sprintf("not a device: pv %d", i+1)) + } + + name := dev.Config().Name + if mm[i].Energy > 0 { site.pvEnergy[name].AddMeterTotal(mm[i].Energy) } else { @@ -577,7 +600,12 @@ func (site *Site) updateBatteryMeters() { mm := site.collectMeters("battery", site.batteryMeters) - for i, meter := range site.batteryMeters { + for i, dev := range site.batteryMeters { + meter := dev.Instance() + if _, ok := meter.(api.Meter); !ok { + panic("not a meter: battery") + } + // battery soc and capacity var batSoc, capacity float64 var err error diff --git a/core/site_battery.go b/core/site_battery.go index 389cbdf3c..5bb617ca6 100644 --- a/core/site_battery.go +++ b/core/site_battery.go @@ -66,7 +66,12 @@ func (site *Site) requiredBatteryMode(batteryGridChargeActive bool, rate api.Rat // applyBatteryMode applies the mode to each battery func (site *Site) applyBatteryMode(mode api.BatteryMode) error { - for _, meter := range site.batteryMeters { + for _, dev := range site.batteryMeters { + meter := dev.Instance() + if _, ok := meter.(api.Meter); !ok { + panic("not a meter: battery") + } + if batCtrl, ok := meter.(api.BatteryController); ok { if err := batCtrl.SetBatteryMode(mode); err != nil && !errors.Is(err, api.ErrNotAvailable) { return err diff --git a/core/site_circuits.go b/core/site_circuits.go index 12e09660a..17ce21a33 100644 --- a/core/site_circuits.go +++ b/core/site_circuits.go @@ -7,6 +7,8 @@ import ( ) type circuitStruct struct { + Title string `json:"title,omitempty"` + Icon string `json:"icon,omitempty"` Power float64 `json:"power"` Current *float64 `json:"current,omitempty"` MaxPower float64 `json:"maxPower,omitempty"` @@ -20,8 +22,11 @@ func (site *Site) publishCircuits() { for _, c := range cc { instance := c.Instance() + props := deviceProperties(c) data := circuitStruct{ + Title: props.Title, + Icon: props.Icon, Power: instance.GetChargePower(), MaxPower: instance.GetMaxPower(), MaxCurrent: instance.GetMaxCurrent(), diff --git a/core/site_test.go b/core/site_test.go index 9bc707a4e..9f176b8a1 100644 --- a/core/site_test.go +++ b/core/site_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util/config" "github.com/stretchr/testify/assert" ) @@ -146,7 +147,7 @@ func TestRequiredBatteryMode(t *testing.T) { t.Logf("%+v", tc) s := &Site{ - batteryMeters: []api.Meter{nil}, + batteryMeters: []config.Device[api.Meter]{nil}, batteryMode: tc.mode, } diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go index dbcffcbb2..6f00eefd8 100644 --- a/server/http_config_device_handler.go +++ b/server/http_config_device_handler.go @@ -8,6 +8,7 @@ import ( "net/http" "strconv" + "dario.cat/mergo" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/charger" "github.com/evcc-io/evcc/core/circuit" @@ -86,24 +87,35 @@ func deviceConfigMap[T any](class templates.Class, dev config.Device[T]) (map[st if configurable, ok := dev.(config.ConfigurableDevice[T]); ok { // from database - params, err := sanitizeMasked(class, conf.Other) + dc["id"] = configurable.ID() + + props, err := propsToMap(configurable.Properties()) if err != nil { return nil, err } - dc["id"] = configurable.ID() + if err := mergo.Merge(&dc, props); err != nil { + return nil, err + } + + params, err := sanitizeMasked(class, conf.Other) + if err != nil { + return nil, err + } dc["config"] = params - } else if title := conf.Other["title"]; title != nil { - // from yaml + } else { + // add title if available config := make(map[string]any) - if s, ok := title.(string); ok { - config["title"] = s + if title, ok := conf.Other["title"].(string); ok { + config["title"] = title } // add icon if available if icon, ok := conf.Other["icon"].(string); ok { config["icon"] = icon } - dc["config"] = config + if len(config) > 0 { + dc["config"] = config + } } return dc, nil @@ -206,13 +218,13 @@ func deviceStatusHandler(w http.ResponseWriter, r *http.Request) { jsonResult(w, testInstance(instance)) } -func newDevice[T any](ctx context.Context, class templates.Class, req map[string]any, newFromConf newFromConfFunc[T], h config.Handler[T]) (*config.Config, error) { - instance, err := newFromConf(ctx, typeTemplate, req) +func newDevice[T any](ctx context.Context, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T]) (*config.Config, error) { + instance, err := newFromConf(ctx, req.Type, req.Other) if err != nil { return nil, err } - conf, err := config.AddConfig(class, typeTemplate, req) + conf, err := config.AddConfig(class, req.Other, config.WithProperties(req.Properties)) if err != nil { return nil, err } @@ -230,14 +242,11 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) { return } - // TODO add application/yaml content type, reject type==template - - var req map[string]any + var req configReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, http.StatusBadRequest, err) return } - delete(req, "type") var conf *config.Config ctx, cancel, done := startDeviceTimeout() @@ -280,8 +289,8 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) { jsonResult(w, res) } -func updateDevice[T any](ctx context.Context, id int, class templates.Class, conf map[string]any, newFromConf newFromConfFunc[T], h config.Handler[T]) error { - dev, instance, merged, err := deviceInstanceFromMergedConfig(ctx, id, class, conf, newFromConf, h) +func updateDevice[T any](ctx context.Context, id int, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T]) error { + dev, instance, merged, err := deviceInstanceFromMergedConfig(ctx, id, class, req, newFromConf, h) if err != nil { return err } @@ -290,8 +299,7 @@ func updateDevice[T any](ctx context.Context, id int, class templates.Class, con if !ok { return errors.New("not configurable") } - - return configurable.Update(merged, instance) + return configurable.Update(merged, instance, config.WithProperties(req.Properties)) } // updateDeviceHandler updates database device's configuration by class @@ -310,14 +318,11 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) { return } - // TODO add application/yaml content type, reject type==template - - var req map[string]any + var req configReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, http.StatusBadRequest, err) return } - delete(req, "type") ctx, cancel, done := startDeviceTimeout() @@ -423,12 +428,12 @@ func deleteDeviceHandler(w http.ResponseWriter, r *http.Request) { jsonResult(w, res) } -func testConfig[T any](ctx context.Context, id int, class templates.Class, conf map[string]any, newFromConf newFromConfFunc[T], h config.Handler[T]) (T, error) { +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, typeTemplate, conf) + return newFromConf(ctx, req.Type, req.Other) } - _, instance, _, err := deviceInstanceFromMergedConfig(ctx, id, class, conf, newFromConf, h) + _, instance, _, err := deviceInstanceFromMergedConfig(ctx, id, class, req, newFromConf, h) return instance, err } @@ -453,12 +458,11 @@ func testConfigHandler(w http.ResponseWriter, r *http.Request) { } } - var req map[string]any + var req configReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, http.StatusBadRequest, err) return } - delete(req, "type") var instance any ctx, cancel, done := startDeviceTimeout() diff --git a/server/http_config_helper.go b/server/http_config_helper.go index 1d187a1df..0ad2cff55 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -2,14 +2,18 @@ package server import ( "context" + "encoding/json" "errors" "slices" "sync" "time" "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/templates" + "github.com/go-viper/mapstructure/v2" + "github.com/samber/lo" ) const ( @@ -20,6 +24,41 @@ const ( masked = "***" ) +type configReq struct { + config.Properties `json:",inline" mapstructure:",squash"` + Other map[string]any `json:",inline" mapstructure:",remain"` +} + +// TODO get rid of this 2-pass unmarshal once https://github.com/golang/go/issues/71497 is implemented +func (c *configReq) UnmarshalJSON(data []byte) error { + var res map[string]any + if err := json.Unmarshal(data, &res); err != nil { + return err + } + + var cr configReq + if err := util.DecodeOther(res, &cr); err != nil { + return err + } + + *c = cr + return nil +} + +func propsToMap(props config.Properties) (map[string]any, error) { + res := make(map[string]any) + if err := mapstructure.Decode(props, &res); err != nil { + return nil, err + } + + return lo.PickBy(res, func(k string, v any) bool { + if k == "Type" || v.(string) == "" { + return false + } + return true + }), nil +} + type newFromConfFunc[T any] func(context.Context, string, map[string]any) (T, error) var ( @@ -107,7 +146,7 @@ func startDeviceTimeout() (context.Context, context.CancelFunc, chan struct{}) { return ctx, cancel, done } -func deviceInstanceFromMergedConfig[T any](ctx context.Context, id int, class templates.Class, conf map[string]any, newFromConf newFromConfFunc[T], h config.Handler[T]) (config.Device[T], T, map[string]any, error) { +func deviceInstanceFromMergedConfig[T any](ctx context.Context, id int, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T]) (config.Device[T], T, map[string]any, error) { var zero T dev, err := h.ByName(config.NameForID(id)) @@ -115,12 +154,14 @@ func deviceInstanceFromMergedConfig[T any](ctx context.Context, id int, class te return nil, zero, nil, err } - merged, err := mergeMasked(class, conf, dev.Config().Other) + conf := dev.Config() + + merged, err := mergeMasked(class, req.Other, conf.Other) if err != nil { return nil, zero, nil, err } - instance, err := newFromConf(ctx, typeTemplate, merged) + instance, err := newFromConf(ctx, conf.Type, merged) return dev, instance, merged, err } diff --git a/server/http_config_helper_test.go b/server/http_config_helper_test.go new file mode 100644 index 000000000..2b45350d8 --- /dev/null +++ b/server/http_config_helper_test.go @@ -0,0 +1,46 @@ +package server + +import ( + "encoding/json" + "testing" + + "github.com/evcc-io/evcc/util/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigReqUnmarshal(t *testing.T) { + var req configReq + require.NoError(t, json.Unmarshal([]byte(`{ + "type": "template", + "deviceTitle": "bar", + "template": "foo", + "deviceProduct": "baz", + "property": 1} + `), &req)) + assert.Equal(t, config.Properties{ + Type: "template", + Title: "bar", + Product: "baz", + }, req.Properties) + assert.Equal(t, map[string]any{ + "template": "foo", + "property": 1.0, + }, req.Other) +} + +func TestConfigReqMarshalToMap(t *testing.T) { + props := 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) +} diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go index c235c3811..d4eb65b56 100644 --- a/server/http_config_loadpoint_handler.go +++ b/server/http_config_loadpoint_handler.go @@ -137,7 +137,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) if err != nil { jsonError(w, http.StatusBadRequest, err) return diff --git a/tests/config-battery.spec.js b/tests/config-battery.spec.js index 99d442d7e..830425505 100644 --- a/tests/config-battery.spec.js +++ b/tests/config-battery.spec.js @@ -34,6 +34,7 @@ test.describe("battery meter", async () => { const meterModal = page.getByTestId("meter-modal"); await meterModal.getByRole("button", { name: "Add battery meter" }).click(); + await meterModal.getByLabel("Title").fill("Battery Basement"); await meterModal.getByLabel("Manufacturer").selectOption("OpenEMS"); await meterModal.getByLabel("IP address or hostname").fill(simulatorHost()); await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); @@ -43,7 +44,7 @@ test.describe("battery meter", async () => { await meterModal.getByRole("button", { name: "Save" }).click(); await expect(meterModal).not.toBeVisible(); await expect(page.getByTestId("battery")).toBeVisible(1); - await expect(page.getByTestId("battery")).toContainText("openems"); + await expect(page.getByTestId("battery")).toContainText("Battery Basement"); // edit #1 await page.getByTestId("battery").getByRole("button", { name: "edit" }).click(); @@ -54,7 +55,7 @@ test.describe("battery meter", async () => { const battery = page.getByTestId("battery"); await expect(battery).toBeVisible(1); - await expect(battery).toContainText("openems"); + await expect(battery).toContainText("Battery Basement"); await expect(battery.getByTestId("device-tag-soc")).toContainText("75.0%"); await expect(battery.getByTestId("device-tag-power")).toContainText("-2.5 kW"); await expect(battery.getByTestId("device-tag-capacity")).toContainText("20.0 kWh"); @@ -81,6 +82,7 @@ test.describe("battery meter", async () => { const meterModal = page.getByTestId("meter-modal"); await meterModal.getByRole("button", { name: "Add battery meter" }).click(); + await meterModal.getByLabel("Title").fill("Battery Basement"); await meterModal.getByLabel("Manufacturer").selectOption("OpenEMS"); await expect(meterModal.getByLabel("Password optional")).not.toBeVisible(); await page.getByRole("button", { name: "Show advanced settings" }).click(); diff --git a/tests/config-onboarding.spec.js b/tests/config-onboarding.spec.js index 15d10c1de..4a77d1108 100644 --- a/tests/config-onboarding.spec.js +++ b/tests/config-onboarding.spec.js @@ -71,6 +71,7 @@ test.describe("onboarding", async () => { const pvModal = page.getByTestId("meter-modal"); await expect(gridModal).toBeVisible(); await pvModal.getByRole("button", { name: "Add solar meter" }).click(); + await pvModal.getByLabel("Title").fill("PV South"); await pvModal.getByLabel("Manufacturer").selectOption("Demo meter"); await pvModal.getByLabel("Power").fill("5000"); await pvModal.getByRole("button", { name: "Save" }).click(); diff --git a/tests/config-pv.spec.js b/tests/config-pv.spec.js new file mode 100644 index 000000000..74825934b --- /dev/null +++ b/tests/config-pv.spec.js @@ -0,0 +1,69 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { enableExperimental } from "./utils"; + +const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml"; + +test.use({ baseURL: baseUrl() }); + +test.beforeAll(async () => { + await start(CONFIG_GRID_ONLY); +}); +test.afterAll(async () => { + await stop(); +}); + +test.describe("pv meter", async () => { + test("create, edit and remove pv meter", async ({ page }) => { + await page.goto("/#/config"); + await enableExperimental(page); + + await expect(page.getByTestId("pv")).toHaveCount(0); + + // create #1 + await page.getByRole("button", { name: "Add solar or battery" }).click(); + + const meterModal = page.getByTestId("meter-modal"); + await meterModal.getByRole("button", { name: "Add solar meter" }).click(); + await meterModal.getByLabel("Title").fill("PV North"); + await meterModal.getByLabel("Manufacturer").selectOption("Demo meter"); + await meterModal.getByLabel("Power (W)").fill("5000"); + await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); + await meterModal.getByRole("link", { name: "validate" }).click(); + await expect(meterModal.getByTestId("device-tag-power")).toContainText("5.0 kW"); + await meterModal.getByRole("button", { name: "Save" }).click(); + await expect(meterModal).not.toBeVisible(); + await expect(page.getByTestId("pv")).toBeVisible(1); + await expect(page.getByTestId("pv")).toContainText("PV North"); + + // edit #1 + await page.getByTestId("pv").getByRole("button", { name: "edit" }).click(); + await expect(meterModal).toBeVisible(); + await meterModal.getByLabel("Power (W)").fill("6000"); + await meterModal.getByRole("button", { name: "Validate & save" }).click(); + await expect(meterModal).not.toBeVisible(); + + const pv = page.getByTestId("pv"); + await expect(pv).toBeVisible(1); + await expect(pv).toContainText("PV North"); + await expect(pv.getByTestId("device-tag-power")).toContainText("6.0 kW"); + + // restart and check in main ui + await restart(CONFIG_GRID_ONLY); + await page.goto("/"); + await page.getByTestId("visualization").click(); + await expect(page.getByTestId("energyflow")).toContainText("Production6.0 kW"); + + // delete #1 + await page.goto("/#/config"); + await page.getByTestId("pv").getByRole("button", { name: "edit" }).click(); + await meterModal.getByRole("button", { name: "Delete" }).click(); + + await expect(page.getByTestId("pv")).toHaveCount(0); + + // restart and check again + await restart(CONFIG_GRID_ONLY); + await page.reload(); + await expect(page.getByTestId("pv")).toHaveCount(0); + }); +}); diff --git a/util/config/config.go b/util/config/config.go index 8e8617135..f50c5bf63 100644 --- a/util/config/config.go +++ b/util/config/config.go @@ -10,11 +10,22 @@ import ( "gorm.io/gorm" ) +// Config is the database mapping for device configurations +// The device prefix ensures unique namespace +// +// TODO migrate vehicle and loadpoints to this schema type Config struct { - ID int `gorm:"primarykey"` - Class templates.Class - Type string - Data map[string]any `gorm:"column:value;type:string;serializer:json"` + ID int `gorm:"primarykey"` + Class templates.Class + Properties `gorm:"embedded"` + Data map[string]any `gorm:"column:value;type:string;serializer:json"` +} + +type Properties struct { + Type string + Title string `json:"deviceTitle,omitempty" mapstructure:"deviceTitle"` + Icon string `json:"deviceIcon,omitempty" mapstructure:"deviceIcon"` + Product string `json:"deviceProduct,omitempty" mapstructure:"deviceProduct"` } // TODO remove- migration only @@ -43,8 +54,14 @@ func (d *Config) Typed() Typed { return res } +func WithProperties(p Properties) func(*Config) { + return func(d *Config) { + d.Properties = p + } +} + // Update updates a config's details to the database -func (d *Config) Update(conf map[string]any) error { +func (d *Config) Update(conf map[string]any, opt ...func(*Config)) error { return db.Transaction(func(tx *gorm.DB) error { var config Config if err := tx.Where(Config{Class: d.Class, ID: d.ID}).First(&config).Error; err != nil { @@ -52,6 +69,9 @@ func (d *Config) Update(conf map[string]any) error { } d.Data = conf + for _, o := range opt { + o(d) + } return tx.Save(&d).Error }) @@ -159,13 +179,16 @@ func ConfigByID(id int) (Config, error) { } // AddConfig adds a new config to the database -func AddConfig(class templates.Class, typ string, conf map[string]any) (Config, error) { +func AddConfig(class templates.Class, conf map[string]any, opt ...func(*Config)) (Config, error) { config := Config{ Class: class, - Type: typ, Data: conf, } + for _, o := range opt { + o(&config) + } + if err := db.Create(&config).Error; err != nil { return Config{}, err } diff --git a/util/config/device.go b/util/config/device.go index d37d05dff..a01a16478 100644 --- a/util/config/device.go +++ b/util/config/device.go @@ -1,51 +1,21 @@ package config +import "sync" + type Device[T any] interface { Config() Named Instance() T } + type ConfigurableDevice[T any] interface { Device[T] ID() int - Update(map[string]any, T) error + Properties() Properties + Update(map[string]any, T, ...func(*Config)) error Delete() error } -type configurableDevice[T any] struct { - config *Config - instance T -} - -func NewConfigurableDevice[T any](config *Config, instance T) ConfigurableDevice[T] { - return &configurableDevice[T]{ - config: config, - instance: instance, - } -} - -func (d *configurableDevice[T]) Config() Named { - return d.config.Named() -} - -func (d *configurableDevice[T]) Instance() T { - return d.instance -} - -func (d *configurableDevice[T]) ID() int { - return d.config.ID -} - -func (d *configurableDevice[T]) Update(config map[string]any, instance T) error { - if err := d.config.Update(config); err != nil { - return err - } - d.instance = instance - return nil -} - -func (d *configurableDevice[T]) Delete() error { - return d.config.Delete() -} +var _ Device[any] = (*staticDevice[any])(nil) type staticDevice[T any] struct { config Named @@ -59,10 +29,6 @@ func NewStaticDevice[T any](config Named, instance T) Device[T] { } } -func (d *staticDevice[T]) Configurable() bool { - return true -} - func (d *staticDevice[T]) Config() Named { return d.config } @@ -70,3 +36,58 @@ func (d *staticDevice[T]) Config() Named { func (d *staticDevice[T]) Instance() T { return d.instance } + +var _ ConfigurableDevice[any] = (*configurableDevice[any])(nil) + +type configurableDevice[T any] struct { + mu sync.Mutex + config *Config + instance T +} + +func NewConfigurableDevice[T any](config *Config, instance T) ConfigurableDevice[T] { + return &configurableDevice[T]{ + config: config, + instance: instance, + } +} + +func (d *configurableDevice[T]) Config() Named { + d.mu.Lock() + defer d.mu.Unlock() + return d.config.Named() +} + +func (d *configurableDevice[T]) Instance() T { + d.mu.Lock() + defer d.mu.Unlock() + return d.instance +} + +func (d *configurableDevice[T]) ID() int { + d.mu.Lock() + defer d.mu.Unlock() + return d.config.ID +} + +func (d *configurableDevice[T]) Properties() Properties { + d.mu.Lock() + defer d.mu.Unlock() + return d.config.Properties +} + +func (d *configurableDevice[T]) Update(config map[string]any, instance T, opt ...func(*Config)) error { + d.mu.Lock() + defer d.mu.Unlock() + if err := d.config.Update(config, opt...); err != nil { + return err + } + d.instance = instance + return nil +} + +func (d *configurableDevice[T]) Delete() error { + d.mu.Lock() + defer d.mu.Unlock() + return d.config.Delete() +}