Add meter and circuit titles and icons (#19416)
This commit is contained in:
parent
5ed022aa91
commit
78ee91a2b6
18 changed files with 415 additions and 115 deletions
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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})`;
|
||||
});
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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("")
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
|||
66
core/site.go
66
core/site.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
46
server/http_config_helper_test.go
Normal file
46
server/http_config_helper_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
69
tests/config-pv.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue