Add meter and circuit titles and icons (#19416)

This commit is contained in:
andig 2025-03-11 20:26:03 +01:00 • committed by GitHub
parent 5ed022aa91
commit 78ee91a2b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 415 additions and 115 deletions

View file

@ -20,11 +20,26 @@
/>
</div>
<form v-else ref="form" class="container mx-0 px-0">
<FormRow
v-if="hasDeviceTitle"
id="meterParamDeviceTitle"
label="Title"
help="Will be displayed in the user interface"
>
<PropertyField
id="meterParamDeviceTitle"
v-model.trim="values.deviceTitle"
type="String"
size="w-100"
class="me-2"
required
/>
</FormRow>
<FormRow id="meterTemplate" :label="$t('config.meter.template')">
<select
v-if="isNew"
id="meterTemplate"
v-model="templateName"
:disabled="!isNew"
class="form-select w-100"
@change="templateChanged"
>
@ -44,6 +59,13 @@
{{ option.name }}
</option>
</select>
<input
v-else
type="text"
:value="productName"
disabled
class="form-control w-100"
/>
</FormRow>
<p v-if="loadingTemplate">Loading ...</p>
<Markdown v-if="description" :markdown="description" class="my-4" />
@ -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;
},
},
};

View file

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

View file

@ -143,7 +143,7 @@
<DeviceCard
v-for="meter in pvMeters"
:key="meter.name"
:title="meter.config?.template || 'Solar system'"
:title="meter.deviceTitle || meter.config?.template || 'Solar system'"
:name="meter.name"
:editable="!!meter.id"
:error="deviceError('meter', meter.name)"
@ -160,7 +160,9 @@
<DeviceCard
v-for="meter in batteryMeters"
:key="meter.name"
:title="meter.config?.template || 'Battery storage'"
:title="
meter.deviceTitle || meter.config?.template || 'Battery storage'
"
:name="meter.name"
:editable="!!meter.id"
:error="deviceError('meter', meter.name)"

View file

@ -54,7 +54,7 @@ func runConfig(cmd *cobra.Command, args []string) {
}
for _, c := range configurable {
fmt.Println(config.NameForID(c.ID), "type:"+c.Type, redactMap(c.Data))
fmt.Println(config.NameForID(c.ID), fmt.Sprintf("%+v", c.Properties), redactMap(c.Data))
}
fmt.Println("")

View file

@ -5,6 +5,7 @@ import (
"time"
"github.com/cenkalti/backoff/v4"
"github.com/evcc-io/evcc/util/config"
)
var (
@ -51,3 +52,11 @@ func ptrValueEqual[T comparable](a, b *T) bool {
return a == nil && b == nil || (*a) == (*b)
}
// deviceProperties returns the common device data for the given reference
func deviceProperties[T any](dev config.Device[T]) config.Properties {
if d, ok := dev.(config.ConfigurableDevice[T]); ok {
return d.Properties()
}
return config.Properties{}
}

View file

@ -45,6 +45,8 @@ type updater interface {
// measurement is used as slice element for publishing structured data
type measurement struct {
Title string `json:"title,omitempty"`
Icon string `json:"icon,omitempty"`
Power float64 `json:"power"`
Energy float64 `json:"energy,omitempty"`
Powers []float64 `json:"powers,omitempty"`
@ -77,12 +79,12 @@ type Site struct {
MaxGridSupplyWhileBatteryCharging_ float64 `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value
// meters
circuit api.Circuit // Circuit
gridMeter api.Meter // Grid usage meter
pvMeters []api.Meter // PV generation meters
batteryMeters []api.Meter // Battery charging meters
extMeters []api.Meter // External meters - for monitoring only
auxMeters []api.Meter // Auxiliary meters
circuit api.Circuit // Circuit
gridMeter api.Meter // Grid usage meter
pvMeters []config.Device[api.Meter] // PV generation meters
batteryMeters []config.Device[api.Meter] // Battery charging meters
extMeters []config.Device[api.Meter] // External meters - for monitoring only
auxMeters []config.Device[api.Meter] // Auxiliary meters
// battery settings
prioritySoc float64 // prefer battery up to this Soc
@ -197,7 +199,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
if err != nil {
return err
}
site.pvMeters = append(site.pvMeters, dev.Instance())
site.pvMeters = append(site.pvMeters, dev)
// accumulator
site.pvEnergy[ref] = &meterEnergy{clock: clock.New()}
@ -209,7 +211,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
if err != nil {
return err
}
site.batteryMeters = append(site.batteryMeters, dev.Instance())
site.batteryMeters = append(site.batteryMeters, dev)
}
// meters used only for monitoring
@ -218,7 +220,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
if err != nil {
return err
}
site.extMeters = append(site.extMeters, dev.Instance())
site.extMeters = append(site.extMeters, dev)
}
// auxiliary meters
@ -227,7 +229,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
if err != nil {
return err
}
site.auxMeters = append(site.auxMeters, dev.Instance())
site.auxMeters = append(site.auxMeters, dev)
}
if site.MaxGridSupplyWhileBatteryCharging_ != 0 {
@ -352,7 +354,11 @@ func (site *Site) restoreSettings() error {
return nil
}
func meterCapabilities(name string, meter interface{}) string {
func meterCapabilities(name string, meter any) string {
if _, ok := meter.(api.Meter); !ok {
panic("not a meter: " + name)
}
_, power := meter.(api.Meter)
_, energy := meter.(api.MeterEnergy)
_, currents := meter.(api.PhaseCurrents)
@ -390,12 +396,13 @@ func (site *Site) DumpConfig() {
if len(site.pvMeters) > 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

View file

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

View file

@ -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(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

69
tests/config-pv.spec.js Normal file
View file

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

View file

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

View file

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