From 914ac751670908f140da39f81d5ea7f14774c2ec Mon Sep 17 00:00:00 2001
From: Michael Geers
{{ description + " " }}
@@ -8,12 +8,12 @@
{{ error }}
Missing features
diff --git a/cmd/helper.go b/cmd/helper.go
index 3ed62876a..8182240c2 100644
--- a/cmd/helper.go
+++ b/cmd/helper.go
@@ -12,6 +12,7 @@ import (
"github.com/evcc-io/evcc/cmd/shutdown"
"github.com/evcc-io/evcc/util"
+ "gopkg.in/yaml.v3"
)
// parseLogLevels parses --log area:level[,...] switch into levels per log area
@@ -112,3 +113,14 @@ func wrapFatalError(err error) error {
return &FatalError{err}
}
+
+func customDevice(other map[string]any) (map[string]any, error) {
+ customYaml, ok := other["yaml"].(string)
+ if !ok {
+ return other, nil
+ }
+
+ var res map[string]any
+ err := yaml.Unmarshal([]byte(customYaml), &res)
+ return res, err
+}
diff --git a/cmd/setup.go b/cmd/setup.go
index 6aaa868da..2c3563515 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -170,7 +170,13 @@ NEXT:
}
log := util.NewLogger("circuit-" + cc.Name)
- instance, err := circuit.NewFromConfig(context.TODO(), log, cc.Other)
+
+ props, err := customDevice(cc.Other)
+ if err != nil {
+ return fmt.Errorf("cannot decode custom circuit '%s': %w", cc.Name, err)
+ }
+
+ instance, err := circuit.NewFromConfig(context.TODO(), log, props)
if err != nil {
return fmt.Errorf("cannot create circuit '%s': %w", cc.Name, err)
}
@@ -260,9 +266,17 @@ func configureMeters(static []config.Named, names ...string) error {
ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name))
- instance, err := meter.NewFromConfig(ctx, cc.Type, cc.Other)
+ props, err := customDevice(cc.Other)
if err != nil {
- err = &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
+ err = &DeviceError{cc.Name, fmt.Errorf("cannot decode custom meter '%s': %w", cc.Name, err)}
+ }
+
+ var instance api.Meter
+ if err == nil {
+ instance, err = meter.NewFromConfig(ctx, cc.Type, props)
+ if err != nil {
+ err = &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
+ }
}
if e := config.Meters().Add(config.NewConfigurableDevice(&conf, instance)); e != nil && err == nil {
@@ -324,9 +338,17 @@ func configureChargers(static []config.Named, names ...string) error {
ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name))
- instance, err := charger.NewFromConfig(ctx, cc.Type, cc.Other)
+ props, err := customDevice(cc.Other)
if err != nil {
- err = &DeviceError{cc.Name, fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)}
+ err = &DeviceError{cc.Name, fmt.Errorf("cannot decode custom charger '%s': %w", cc.Name, err)}
+ }
+
+ var instance api.Charger
+ if err == nil {
+ instance, err = charger.NewFromConfig(ctx, cc.Type, props)
+ if err != nil {
+ err = &DeviceError{cc.Name, fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)}
+ }
}
if e := config.Chargers().Add(config.NewConfigurableDevice(&conf, instance)); e != nil && err == nil {
@@ -343,7 +365,13 @@ func configureChargers(static []config.Named, names ...string) error {
func vehicleInstance(cc config.Named) (api.Vehicle, error) {
ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name))
- instance, err := vehicle.NewFromConfig(ctx, cc.Type, cc.Other)
+ props, err := customDevice(cc.Other)
+
+ var instance api.Vehicle
+ if err == nil {
+ instance, err = vehicle.NewFromConfig(ctx, cc.Type, props)
+ }
+
if err != nil {
if ce := new(util.ConfigError); errors.As(err, &ce) {
return nil, err
@@ -665,7 +693,12 @@ func configureHEMS(conf *globalconfig.Hems, site *core.Site, httpd *server.HTTPd
return nil
}
- hems, err := hems.NewFromConfig(context.TODO(), conf.Type, conf.Other, site, httpd)
+ props, err := customDevice(conf.Other)
+ if err != nil {
+ return fmt.Errorf("cannot decode custom hems '%s': %w", conf.Type, err)
+ }
+
+ hems, err := hems.NewFromConfig(context.TODO(), conf.Type, props, site, httpd)
if err != nil {
return fmt.Errorf("failed configuring hems: %w", err)
}
@@ -740,10 +773,15 @@ func configureMessengers(conf *globalconfig.Messaging, vehicles push.Vehicles, v
return messageChan, fmt.Errorf("failed configuring push services: %w", err)
}
- for _, service := range conf.Services {
- impl, err := push.NewFromConfig(context.TODO(), service.Type, service.Other)
+ for _, conf := range conf.Services {
+ props, err := customDevice(conf.Other)
if err != nil {
- return messageChan, fmt.Errorf("failed configuring push service %s: %w", service.Type, err)
+ return nil, fmt.Errorf("cannot decode push service '%s': %w", conf.Type, err)
+ }
+
+ impl, err := push.NewFromConfig(context.TODO(), conf.Type, props)
+ if err != nil {
+ return messageChan, fmt.Errorf("failed configuring push service %s: %w", conf.Type, err)
}
messageHub.Add(impl)
}
@@ -756,7 +794,12 @@ func configureMessengers(conf *globalconfig.Messaging, vehicles push.Vehicles, v
func tariffInstance(name string, conf config.Typed) (api.Tariff, error) {
ctx := util.WithLogger(context.TODO(), util.NewLogger(name))
- instance, err := tariff.NewFromConfig(ctx, conf.Type, conf.Other)
+ props, err := customDevice(conf.Other)
+ if err != nil {
+ return nil, fmt.Errorf("cannot decode custom tariff '%s': %w", name, err)
+ }
+
+ instance, err := tariff.NewFromConfig(ctx, conf.Type, props)
if err != nil {
if ce := new(util.ConfigError); errors.As(err, &ce) {
return nil, err
diff --git a/i18n/de.json b/i18n/de.json
index 3b079fbce..cfa8d354d 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -113,6 +113,9 @@
"batteryStorage": "Batterie",
"solarSystem": "PV-Anlage"
},
+ "editor": {
+ "loading": "Lade YAML-Editor …"
+ },
"eebus": {
"description": "Grundkonfiguration für die Kommunikation mit anderen EEBus-Geräten.",
"title": "EEBus"
@@ -130,6 +133,8 @@
},
"general": {
"cancel": "Abbrechen",
+ "customHelp": "Erstelle ein benutzerdefiniertes Gerät mit evcc's Plugin-System.",
+ "customOption": "Benutzerdefiniertes Gerät",
"delete": "Löschen",
"docsLink": "Siehe Dokumentation.",
"experimental": "Experimentell",
@@ -155,6 +160,9 @@
"description": "evcc mit einem anderen Heimenergiemanagementsystem verbinden.",
"title": "HEMS"
},
+ "icon": {
+ "change": "ändern"
+ },
"influx": {
"description": "Schreibt Ladedaten und andere Metriken in InfluxDB. Verwende Grafana oder andere Tools, um die Daten zu visualisieren.",
"descriptionToken": "Siehe die InfluxDB-Dokumentation, um zu erfahren, wie du einen erstellst. https://docs.influxdata.com/influxdb/v2/admin/",
@@ -269,6 +277,7 @@
"meter": {
"cancel": "Abbrechen",
"delete": "Löschen",
+ "generic": "Generische Integrationen",
"option": {
"aux": "Intelligenten Verbraucher hinzufügen",
"battery": "Hausbatterie hinzufügen",
@@ -276,6 +285,7 @@
"pv": "PV-Anlage hinzufügen"
},
"save": "Speichern",
+ "specific": "Spezifische Integrationen",
"template": "Hersteller",
"titleChoice": "Was möchtest du hinzufügen?",
"validateSave": "Überprüfen & speichern"
@@ -417,6 +427,7 @@
"minimumCurrent": "Minimaler Strom",
"minimumCurrentHelp": "Nur unter 6A, wenn du weißt, was du tust.",
"online": "Fahrzeuge mit Schnittstelle",
+ "primary": "Generische Integrationen",
"priority": "Priorität",
"priorityHelp": "Höhere Priorität bedeutet, dass dieses Fahrzeug Vorrang vor anderen Fahrzeugen hat.",
"save": "Speichern",
diff --git a/i18n/en.json b/i18n/en.json
index 1e2c5bdbb..68c0fce9a 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -113,6 +113,9 @@
"batteryStorage": "Battery storage",
"solarSystem": "Solar system"
},
+ "editor": {
+ "loading": "Loading YAML editor…"
+ },
"eebus": {
"description": "Configuration that enables evcc to communicate with other EEBus devices.",
"title": "EEBus"
@@ -130,6 +133,8 @@
},
"general": {
"cancel": "Cancel",
+ "customHelp": "Create a user-defined device using evcc's plugin system.",
+ "customOption": "User-defined device",
"delete": "Delete",
"docsLink": "See documentation.",
"experimental": "Experimental",
@@ -155,6 +160,9 @@
"description": "Connect evcc to another home energy management system.",
"title": "HEMS"
},
+ "icon": {
+ "change": "change"
+ },
"influx": {
"description": "Writes charging data and other metrics to InfluxDB. Use Grafana or other tools to visualize the data.",
"descriptionToken": "Check the InfluxDB documentation to learn how to create one. https://docs.influxdata.com/influxdb/v2/admin/",
@@ -269,6 +277,7 @@
"meter": {
"cancel": "Cancel",
"delete": "Delete",
+ "generic": "Generic integrations",
"option": {
"aux": "Add self-regulating consumer",
"battery": "Add battery meter",
@@ -276,6 +285,7 @@
"pv": "Add solar meter"
},
"save": "Save",
+ "specific": "Specific integrations",
"template": "Manufacturer",
"titleChoice": "What Do You Want To Add?",
"validateSave": "Validate & save"
@@ -417,6 +427,7 @@
"minimumCurrent": "Minimum current",
"minimumCurrentHelp": "Only go below 6A if you know what you're doing.",
"online": "Vehicles with online API",
+ "primary": "Generic integrations",
"priority": "Priority",
"priorityHelp": "Higher priority means this vehicle gets preferred access to solar surplus.",
"save": "Save",
diff --git a/plugin/config.go b/plugin/config.go
index a743768a9..7a9425477 100644
--- a/plugin/config.go
+++ b/plugin/config.go
@@ -2,6 +2,7 @@ package plugin
import (
"context"
+ "errors"
"fmt"
reg "github.com/evcc-io/evcc/util/registry"
@@ -60,6 +61,10 @@ func plugin[T any](typ string, ctx context.Context, config *Config) (T, error) {
return zero, nil
}
+ if config.Source == "" {
+ return zero, errors.New("missing plugin source")
+ }
+
factory, err := registry.Get(config.Source)
if err != nil {
return zero, err
diff --git a/server/db/settings/setting.go b/server/db/settings/setting.go
index 429469ab0..2e928d1b5 100644
--- a/server/db/settings/setting.go
+++ b/server/db/settings/setting.go
@@ -6,7 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
- "io"
"reflect"
"slices"
"strconv"
@@ -224,11 +223,12 @@ func Yaml(key string, other, res any) error {
if err != nil {
return err
}
+
if s == "" {
return ErrNotFound
}
- if err := yaml.NewDecoder(bytes.NewBuffer([]byte(s))).Decode(&other); err != nil && err != io.EOF {
+ if err := yaml.Unmarshal([]byte(s), &other); err != nil {
return err
}
diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go
index 4a41ea306..36984e31d 100644
--- a/server/http_config_device_handler.go
+++ b/server/http_config_device_handler.go
@@ -2,7 +2,6 @@ package server
import (
"context"
- "encoding/json"
"errors"
"fmt"
"net/http"
@@ -99,12 +98,20 @@ func deviceConfigMap[T any](class templates.Class, dev config.Device[T]) (map[st
return nil, err
}
- params, err := sanitizeMasked(class, conf.Other)
- if err != nil {
- return nil, err
+ if conf.Type == typeTemplate {
+ // template device, mask config
+ params, err := sanitizeMasked(class, conf.Other)
+ if err != nil {
+ return nil, err
+ }
+ dc["config"] = params
+ } else {
+ // custom device, no masking
+ dc["config"] = conf.Other
}
- dc["config"] = params
- } else {
+ }
+
+ if dc["config"] == nil {
// add title if available
config := make(map[string]any)
if title, ok := conf.Other["title"].(string); ok {
@@ -183,7 +190,7 @@ func deviceStatus[T any](name string, h config.Handler[T]) (T, error) {
return dev.Instance(), nil
}
-// deviceStatusHandler returns a device configuration by class
+// deviceStatusHandler returns the device test status by class
func deviceStatusHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@@ -225,7 +232,7 @@ func newDevice[T any](ctx context.Context, class templates.Class, req configReq,
return nil, err
}
- conf, err := config.AddConfig(class, req.Other, config.WithProperties(req.Properties))
+ conf, err := config.AddConfig(class, req.Serialise(), config.WithProperties(req.Properties))
if err != nil {
return nil, err
}
@@ -243,8 +250,8 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
return
}
- var req configReq
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ req, err := decodeDeviceConfig(r.Body)
+ if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
@@ -300,6 +307,7 @@ func updateDevice[T any](ctx context.Context, id int, class templates.Class, req
if !ok {
return errors.New("not configurable")
}
+
return configurable.Update(merged, instance, config.WithProperties(req.Properties))
}
@@ -319,8 +327,8 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
return
}
- var req configReq
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ req, err := decodeDeviceConfig(r.Body)
+ if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
@@ -528,8 +536,8 @@ func testConfigHandler(w http.ResponseWriter, r *http.Request) {
}
}
- var req configReq
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ req, err := decodeDeviceConfig(r.Body)
+ if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
diff --git a/server/http_config_helper.go b/server/http_config_helper.go
index 56c733b6d..3d6dc4912 100644
--- a/server/http_config_helper.go
+++ b/server/http_config_helper.go
@@ -4,8 +4,10 @@ import (
"context"
"encoding/json"
"errors"
+ "io"
"reflect"
"slices"
+ "strings"
"sync"
"time"
@@ -16,11 +18,12 @@ import (
"github.com/evcc-io/evcc/util/templates"
"github.com/go-viper/mapstructure/v2"
"github.com/samber/lo"
+ "gopkg.in/yaml.v3"
)
const (
- // typeTemplate is the updatable configuration type
- typeTemplate = "template"
+ typeCustom = "custom" // typeCustom is the custom configuration type
+ typeTemplate = "template" // typeTemplate is the updatable configuration type
// masked indicates a masked config parameter value
masked = "***"
@@ -28,6 +31,7 @@ const (
type configReq struct {
config.Properties `json:",inline" mapstructure:",squash"`
+ Yaml string
Other map[string]any `json:",inline" mapstructure:",remain"`
}
@@ -47,6 +51,15 @@ func (c *configReq) UnmarshalJSON(data []byte) error {
return nil
}
+func (c *configReq) Serialise() map[string]any {
+ if c.Yaml != "" {
+ return map[string]any{
+ "yaml": c.Yaml,
+ }
+ }
+ return c.Other
+}
+
func propsToMap(props config.Properties) (map[string]any, error) {
res := make(map[string]any)
if err := mapstructure.Decode(props, &res); err != nil {
@@ -158,6 +171,12 @@ func deviceInstanceFromMergedConfig[T any](ctx context.Context, id int, class te
conf := dev.Config()
+ // TODO merge custom config
+ if req.Yaml != "" {
+ instance, err := newFromConf(ctx, conf.Type, req.Other)
+ return dev, instance, req.Serialise(), err
+ }
+
merged, err := mergeMasked(class, req.Other, conf.Other)
if err != nil {
return nil, zero, nil, err
@@ -307,3 +326,29 @@ func (maskedTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Val
return nil
}
}
+
+func decodeDeviceConfig(r io.Reader) (configReq, error) {
+ var res configReq
+
+ if err := json.NewDecoder(r).Decode(&res); err != nil {
+ return configReq{}, err
+ }
+
+ if res.Yaml == "" {
+ return res, nil
+ }
+
+ if !strings.EqualFold(res.Type, typeCustom) {
+ return configReq{}, errors.New("invalid config: yaml only allowed for custom type")
+ }
+
+ if len(res.Other) != 0 {
+ return configReq{}, errors.New("invalid config: cannot mix yaml and other")
+ }
+
+ if err := yaml.Unmarshal([]byte(res.Yaml), &res.Other); err != nil && err != io.EOF {
+ return configReq{}, err
+ }
+
+ return res, nil
+}
diff --git a/server/http_global_settings_handler.go b/server/http_global_settings_handler.go
index c96abe38d..7a0227e32 100644
--- a/server/http_global_settings_handler.go
+++ b/server/http_global_settings_handler.go
@@ -1,7 +1,6 @@
package server
import (
- "bytes"
"encoding/json"
"io"
"net/http"
@@ -54,7 +53,7 @@ func settingsSetYamlHandler(key string, other, struc any) http.HandlerFunc {
return
}
- if err := yaml.NewDecoder(bytes.NewBuffer(b)).Decode(&other); err != nil && err != io.EOF {
+ if err := yaml.Unmarshal(b, &other); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
diff --git a/templates/definition/charger/tessie.yaml b/templates/definition/charger/tessie.yaml
index 5c9d30f53..bedfd8cc3 100644
--- a/templates/definition/charger/tessie.yaml
+++ b/templates/definition/charger/tessie.yaml
@@ -1,5 +1,4 @@
template: tessie
-group: generic
products:
- description:
generic: Tessie
diff --git a/templates/definition/vehicle/offline.yaml b/templates/definition/vehicle/offline.yaml
index 8c5c3b513..863083ece 100644
--- a/templates/definition/vehicle/offline.yaml
+++ b/templates/definition/vehicle/offline.yaml
@@ -1,8 +1,11 @@
template: offline
products:
- description:
- en: Generic vehicle
- de: Generisches Fahrzeug
+ en: Generic vehicle (without API)
+ de: Generisches Fahrzeug (ohne API)
+requirements:
+ description:
+ de:
group: generic
params:
- preset: vehicle-common
diff --git a/tests/config-aux.spec.js b/tests/config-aux.spec.js
index e436d0998..3a04f45ad 100644
--- a/tests/config-aux.spec.js
+++ b/tests/config-aux.spec.js
@@ -1,15 +1,21 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
-import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils";
+import {
+ editorClear,
+ editorType,
+ enableExperimental,
+ expectModalHidden,
+ expectModalVisible,
+} from "./utils";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
test.use({ baseURL: baseUrl() });
-test.beforeAll(async () => {
+test.beforeEach(async () => {
await start(CONFIG_GRID_ONLY);
});
-test.afterAll(async () => {
+test.afterEach(async () => {
await stop();
});
@@ -58,4 +64,174 @@ test.describe("aux meter", async () => {
await expect(page.getByTestId("aux")).toHaveCount(0);
await expect(page.getByTestId("fatal-error")).not.toBeVisible();
});
+
+ test("user-defined meter", async ({ page }) => {
+ await page.goto("/#/config");
+ await enableExperimental(page);
+
+ await page.getByRole("button", { name: "Add additional meter" }).click();
+ const modal = page.getByTestId("meter-modal");
+ await expectModalVisible(modal);
+ await modal.getByRole("button", { name: "Add self-regulating consumer" }).click();
+
+ await modal.getByLabel("Title").fill("Large heater");
+ await modal.getByLabel("Manufacturer").selectOption("User-defined device");
+ await page.waitForLoadState("networkidle");
+ const editor = modal.getByTestId("yaml-editor");
+ await expect(editor).toContainText("power: # current power");
+
+ await editorClear(editor);
+ await editorType(editor, [
+ // prettier-ignore
+ "power:",
+ " source: const",
+ "value: 3000 # W",
+ "Shift+Tab",
+ "energy:",
+ " source: const",
+ "value: 42.0 # kWh",
+ ]);
+
+ const restResult = modal.getByTestId("test-result");
+ await expect(restResult).toContainText("Status: unknown");
+ await restResult.getByRole("link", { name: "validate" }).click();
+ await expect(restResult).toContainText("Status: successful");
+ await expect(restResult).toContainText(["Power", "3.0 kW"].join(""));
+ await expect(restResult).toContainText(["Energy", "42.0 kWh"].join(""));
+
+ // create
+ await modal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(modal);
+ await expect(page.getByTestId("aux")).toHaveCount(1);
+ await expect(page.getByTestId("aux")).toContainText("Large heater");
+
+ // restart evcc
+ await restart(CONFIG_GRID_ONLY);
+ await page.reload();
+
+ await expect(page.getByTestId("aux")).toHaveCount(1);
+ await expect(page.getByTestId("aux")).toContainText("Large heater");
+
+ await page.getByTestId("aux").getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(modal);
+ await expect(modal.getByLabel("Manufacturer")).toHaveValue("User-defined device");
+ await page.waitForLoadState("networkidle");
+ await expect(editor).toContainText("value: 3000 # W");
+ await expect(editor).toContainText("value: 42.0 # kWh");
+
+ // update
+ await modal.getByLabel("Title").fill("Small heater");
+ await editorClear(editor);
+ await editorType(editor, [
+ // prettier-ignore
+ "power:",
+ " source: const",
+ "value: 300 # W",
+ "Shift+Tab",
+ "energy:",
+ " source: const",
+ "value: 4.2 # kWh",
+ ]);
+ await expect(restResult).toContainText("Status: unknown");
+ await restResult.getByRole("link", { name: "validate" }).click();
+ await expect(restResult).toContainText("Status: successful");
+ await expect(restResult).toContainText(["Power", "0.3 kW"].join(""));
+ await expect(restResult).toContainText(["Energy", "4.2 kWh"].join(""));
+ await modal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(modal);
+ await expect(page.getByTestId("aux")).toHaveCount(1);
+ await expect(page.getByTestId("aux")).toContainText("Small heater");
+
+ // delete
+ await page.getByTestId("aux").getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(modal);
+ await expect(editor).toContainText("value: 300 # W");
+ await expect(editor).toContainText("value: 4.2 # kWh");
+ await modal.getByRole("button", { name: "Delete" }).click();
+ await expectModalHidden(modal);
+ await expect(page.getByTestId("aux")).toHaveCount(0);
+
+ // restart evcc
+ await restart(CONFIG_GRID_ONLY);
+ await page.reload();
+
+ await expect(page.getByTestId("aux")).toHaveCount(0);
+ });
+
+ test("user-defined meter with errors", async ({ page }) => {
+ await page.goto("/#/config");
+ await enableExperimental(page);
+
+ await page.getByRole("button", { name: "Add additional meter" }).click();
+
+ const modal = page.getByTestId("meter-modal");
+ await expectModalVisible(modal);
+ await modal.getByRole("button", { name: "Add self-regulating consumer" }).click();
+
+ await modal.getByLabel("Title").fill("Large heater");
+ await modal.getByLabel("Manufacturer").selectOption("User-defined device");
+ await page.waitForLoadState("networkidle");
+ const editor = modal.getByTestId("yaml-editor");
+
+ // yaml syntax error
+ await editorClear(editor);
+ await editorType(editor, [
+ // prettier-ignore
+ "hello: world",
+ " foo: bar",
+ ]);
+
+ // no errors
+ await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
+ const restResult = modal.getByTestId("test-result");
+ await expect(restResult).toContainText("Status: unknown");
+ await restResult.getByRole("link", { name: "validate" }).click();
+ await expect(restResult).toContainText("Status: failed");
+ await expect(restResult).toContainText(
+ "yaml: line 2: mapping values are not allowed in this context"
+ );
+ await expect(editor.locator(".line-numbers.error")).toHaveCount(1);
+
+ // invalid field error
+ await editorClear(editor);
+ await editorType(editor, [
+ // prettier-ignore
+ "apower:",
+ " source: const",
+ "value: 3000 # W",
+ ]);
+ await expect(restResult).toContainText("Status: unknown");
+ await restResult.getByRole("link", { name: "validate" }).click();
+ await expect(restResult).toContainText("Status: failed");
+ await expect(restResult).toContainText("has invalid keys: apower");
+ await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
+
+ // unknown source error
+ await editorClear(editor);
+ await editorType(editor, [
+ // prettier-ignore
+ "power:",
+ " source: unknown",
+ "value: 3000 # W",
+ ]);
+ await expect(restResult).toContainText("Status: unknown");
+ await restResult.getByRole("link", { name: "validate" }).click();
+ await expect(restResult).toContainText("Status: failed");
+ await expect(restResult).toContainText("invalid plugin type: unknown");
+ await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
+
+ // missing required field error
+ await editorClear(editor);
+ await editorType(editor, [
+ // prettier-ignore
+ "energy:",
+ " source: const",
+ "value: 300 # kWh",
+ ]);
+ await expect(restResult).toContainText("Status: unknown");
+ await restResult.getByRole("link", { name: "validate" }).click();
+ await expect(restResult).toContainText("Status: failed");
+ await expect(restResult).toContainText("power: missing plugin source");
+ await expect(editor.locator(".line-numbers.error")).toHaveCount(0);
+ });
});
diff --git a/tests/config-loadpoint.spec.js b/tests/config-loadpoint.spec.js
index f89111e9d..2bdd032de 100644
--- a/tests/config-loadpoint.spec.js
+++ b/tests/config-loadpoint.spec.js
@@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
-import { expectModalVisible, expectModalHidden } from "./utils";
+import { expectModalVisible, expectModalHidden, editorClear, editorType } from "./utils";
const CONFIG_EMPTY = "config-empty.evcc.yaml";
const CONFIG_ONE_LP = "config-one-lp.evcc.yaml";
@@ -49,7 +49,7 @@ async function addVehicle(page, title) {
await page.getByRole("button", { name: "Add vehicle" }).click();
const modal = page.getByTestId("vehicle-modal");
await expectModalVisible(modal);
- await modal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+ await modal.getByLabel("Manufacturer").selectOption("Generic vehicle (without API)");
await modal.getByLabel("Title").fill(title);
await modal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(modal);
@@ -414,4 +414,78 @@ test.describe("loadpoint", async () => {
lpModal.getByRole("button", { name: "Add dedicated charger meter" })
).toBeVisible();
});
+
+ test("user-defined charger", async ({ page }) => {
+ await start(CONFIG_EMPTY);
+ await page.goto("/#/config");
+ await enableExperimental(page);
+
+ // add loadpoint
+ await newLoadpoint(page, "Carport");
+ const lpModal = page.getByTestId("loadpoint-modal");
+ await lpModal.getByRole("button", { name: "Add charger" }).click();
+
+ // add user-defined charger
+ const chargerModal = page.getByTestId("charger-modal");
+ await expectModalVisible(chargerModal);
+ await chargerModal.getByLabel("Manufacturer").selectOption("User-defined device");
+ await page.waitForLoadState("networkidle");
+ const editor = chargerModal.getByTestId("yaml-editor");
+ await expect(editor).toContainText("status: # charger status [A..F]");
+
+ await editorClear(editor, 10);
+ await editorType(editor, [
+ // prettier-ignore
+ "status:\n source: const\nvalue: 'C'",
+ "Shift+Tab",
+ "enabled:\n source: const\nvalue: true",
+ "Shift+Tab",
+ "enable:\n source: js\nscript: console.log(enable)",
+ "Shift+Tab",
+ "maxcurrent:\n source: js\nscript: console.log(maxcurrent)",
+ "Shift+Tab",
+ "power:\n source: const\nvalue: 11000",
+ ]);
+
+ const restResult = chargerModal.getByTestId("test-result");
+ await expect(restResult).toContainText("Status: unknown");
+ await restResult.getByRole("link", { name: "validate" }).click();
+ await expect(restResult).toContainText("Status: successful");
+ await expect(restResult).toContainText(["Status", "charging"].join(""));
+ await expect(restResult).toContainText(["Enabled", "yes"].join(""));
+ await expect(restResult).toContainText(["Power", "11.0 kW"].join(""));
+
+ // create
+ await chargerModal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(chargerModal);
+ await expectModalVisible(lpModal);
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(lpModal);
+
+ await expect(page.getByTestId("loadpoint")).toHaveCount(1);
+ await expect(page.getByTestId("loadpoint")).toContainText("Carport");
+
+ // restart evcc
+ await restart(CONFIG_EMPTY);
+ await page.reload();
+
+ const lpEntry = page.getByTestId("loadpoint");
+ await expect(lpEntry).toHaveCount(1);
+ await expect(lpEntry).toContainText("Carport");
+ await expect(lpEntry).toContainText(["Status", "charging"].join(""));
+ await expect(lpEntry).toContainText(["Enabled", "yes"].join(""));
+ await expect(lpEntry).toContainText(["Power", "11.0 kW"].join(""));
+
+ await lpEntry.getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(lpModal);
+
+ await expect(lpModal.getByLabel("Charger").first()).toHaveValue("User-defined device [db:1]");
+ await lpModal.getByLabel("Charger").first().click();
+ await expectModalVisible(chargerModal);
+
+ await expect(chargerModal.getByLabel("Manufacturer")).toHaveValue("User-defined device");
+ await page.waitForLoadState("networkidle");
+ await expect(editor).toContainText("value: 'C'");
+ await expect(editor).toContainText("value: 11000");
+ });
});
diff --git a/tests/config-tariffs.spec.js b/tests/config-tariffs.spec.js
index 96bc66d7d..c8c7d8e6f 100644
--- a/tests/config-tariffs.spec.js
+++ b/tests/config-tariffs.spec.js
@@ -1,6 +1,12 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
-import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils";
+import {
+ enableExperimental,
+ expectModalHidden,
+ expectModalVisible,
+ editorClear,
+ editorType,
+} from "./utils";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
const CONFIG_WITH_TARIFFS = "config-with-tariffs.evcc.yaml";
@@ -12,8 +18,6 @@ test.afterEach(async () => {
await stop();
});
-const SELECT_ALL = "ControlOrMeta+KeyA";
-
async function goToConfig(page) {
await page.goto("/#/config");
await enableExperimental(page);
@@ -38,31 +42,24 @@ test.describe("tariffs", async () => {
await page.waitForLoadState("networkidle");
// default content
- await expect(modal).toContainText("#currency: EUR");
+ const editor = modal.getByTestId("yaml-editor");
+ await expect(editor).toContainText("#currency: EUR");
// clear and enter invalid yaml
- await modal.locator(".monaco-editor .view-line").nth(0).click();
-
- for (let i = 0; i < 4; i++) {
- await page.keyboard.press(SELECT_ALL, { delay: 10 });
- await page.keyboard.press("Backspace", { delay: 10 });
- }
-
- await page.keyboard.type("foo: bar\n");
+ await editorClear(editor);
+ await editorType(editor, "foo: bar");
await page.getByRole("button", { name: "Save" }).click();
await expect(modal.getByTestId("error")).toContainText("invalid keys: foo");
// clear and enter valid yaml
- await modal.locator(".monaco-editor .view-line").nth(0).click();
- for (let i = 0; i < 4; i++) {
- await page.keyboard.press(SELECT_ALL, { delay: 10 });
- await page.keyboard.press("Backspace", { delay: 10 });
- }
-
- await page.keyboard.type("currency: CHF\n");
- await page.keyboard.type("grid:\n");
- await page.keyboard.type(" type: fixed\n");
- await page.keyboard.type("price: 0.123\n");
+ await editorClear(editor);
+ await editorType(editor, [
+ // prettier-ignore
+ "currency: CHF",
+ "grid:",
+ " type: fixed",
+ "price: 0.123",
+ ]);
await page.getByRole("button", { name: "Save" }).click();
await expect(modal.getByTestId("error")).not.toBeVisible();
diff --git a/tests/config-vehicles.spec.js b/tests/config-vehicles.spec.js
index ed54613ae..bec6ef39d 100644
--- a/tests/config-vehicles.spec.js
+++ b/tests/config-vehicles.spec.js
@@ -1,6 +1,12 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
-import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils";
+import {
+ editorClear,
+ editorType,
+ enableExperimental,
+ expectModalHidden,
+ expectModalVisible,
+} from "./utils";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
const CONFIG_WITH_VEHICLE = "config-with-vehicle.evcc.yaml";
@@ -12,6 +18,8 @@ test.afterEach(async () => {
await stop();
});
+const GENERIC_VEHICLE = "Generic vehicle (without API)";
+
test.describe("vehicles", async () => {
test("create, edit and delete vehicles", async ({ page }) => {
await start(CONFIG_GRID_ONLY);
@@ -24,7 +32,7 @@ test.describe("vehicles", async () => {
// create #1
await page.getByTestId("add-vehicle").click();
- await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+ await vehicleModal.getByLabel("Manufacturer").selectOption(GENERIC_VEHICLE);
await vehicleModal.getByLabel("Title").fill("Green Car");
await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
@@ -32,7 +40,7 @@ test.describe("vehicles", async () => {
// create #2
await page.getByTestId("add-vehicle").click();
- await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+ await vehicleModal.getByLabel("Manufacturer").selectOption(GENERIC_VEHICLE);
await vehicleModal.getByLabel("Title").fill("Yellow Van");
await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
@@ -79,12 +87,12 @@ test.describe("vehicles", async () => {
// create #1 & #2
await page.getByTestId("add-vehicle").click();
- await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+ await vehicleModal.getByLabel("Manufacturer").selectOption(GENERIC_VEHICLE);
await vehicleModal.getByLabel("Title").fill("Green Car");
await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
await page.getByTestId("add-vehicle").click();
- await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+ await vehicleModal.getByLabel("Manufacturer").selectOption(GENERIC_VEHICLE);
await vehicleModal.getByLabel("Title").fill("Yellow Van");
await vehicleModal.getByLabel("car").click();
await vehicleModal.getByLabel("van").check();
@@ -112,7 +120,7 @@ test.describe("vehicles", async () => {
// create #2
await page.getByTestId("add-vehicle").click();
- await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+ await vehicleModal.getByLabel("Manufacturer").selectOption(GENERIC_VEHICLE);
await vehicleModal.getByLabel("Title").fill("Green Car");
await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
@@ -131,7 +139,7 @@ test.describe("vehicles", async () => {
const vehicleModal = page.getByTestId("vehicle-modal");
// generic
- await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+ await vehicleModal.getByLabel("Manufacturer").selectOption(GENERIC_VEHICLE);
await expect(vehicleModal.getByLabel("Title")).toBeVisible();
await expect(vehicleModal.getByLabel("Car")).toBeVisible(); // icon
await expect(vehicleModal.getByLabel("Battery capacity")).toBeVisible();
@@ -169,7 +177,7 @@ test.describe("vehicles", async () => {
const vehicleModal = page.getByTestId("vehicle-modal");
// generic
- await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+ await vehicleModal.getByLabel("Manufacturer").selectOption(GENERIC_VEHICLE);
await vehicleModal.getByLabel("Title").fill("RFID Car");
await page.getByRole("button", { name: "Show advanced settings" }).click();
await vehicleModal.getByLabel("RFID identifiers").fill("aaa\nbbb \n ccc\n\nddd\n");
@@ -188,4 +196,86 @@ test.describe("vehicles", async () => {
await vehicleModal.getByLabel("Close").click();
await expect(page.getByTestId("fatal-error")).not.toBeVisible();
});
+
+ test("user-defined vehicle", async ({ page }) => {
+ await start(CONFIG_GRID_ONLY);
+
+ await page.goto("/#/config");
+ await enableExperimental(page);
+
+ await page.getByTestId("add-vehicle").click();
+ const modal = page.getByTestId("vehicle-modal");
+ await expectModalVisible(modal);
+
+ await modal.getByLabel("Manufacturer").selectOption("User-defined device");
+ await page.waitForLoadState("networkidle");
+ const editor = modal.getByTestId("yaml-editor");
+ await expect(editor).toContainText("title: green Honda");
+
+ await editorClear(editor);
+ await editorType(editor, [
+ // prettier-ignore
+ "title: blue Honda",
+ "capacity: 12.3",
+ "soc:",
+ " source: const",
+ "value: 42",
+ ]);
+
+ const restResult = modal.getByTestId("test-result");
+ await expect(restResult).toContainText("Status: unknown");
+ await restResult.getByRole("link", { name: "validate" }).click();
+ await expect(restResult).toContainText("Status: successful");
+ await expect(restResult).toContainText(["Capacity", "12.3 kWh"].join(""));
+ await expect(restResult).toContainText(["Charge", "42.0%"].join(""));
+
+ // create
+ await modal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(modal);
+ await expect(page.getByTestId("vehicle")).toHaveCount(1);
+
+ // restart evcc
+ await restart(CONFIG_GRID_ONLY);
+ await page.reload();
+
+ await expect(page.getByTestId("vehicle")).toHaveCount(1);
+ await page.getByTestId("vehicle").getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(modal);
+ await expect(modal.getByLabel("Manufacturer")).toHaveValue("User-defined device");
+ await page.waitForLoadState("networkidle");
+ await expect(editor).toContainText("title: blue Honda");
+
+ // update
+ await editorClear(editor);
+ await editorType(editor, [
+ // prettier-ignore
+ "title: pink Honda",
+ "capacity: 23.4",
+ "soc:",
+ " source: const",
+ "value: 32",
+ ]);
+ await expect(restResult).toContainText("Status: unknown");
+ await restResult.getByRole("link", { name: "validate" }).click();
+ await expect(restResult).toContainText("Status: successful");
+ await expect(restResult).toContainText(["Capacity", "23.4 kWh"].join(""));
+ await expect(restResult).toContainText(["Charge", "32.0%"].join(""));
+ await modal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(modal);
+ await expect(page.getByTestId("vehicle")).toHaveCount(1);
+
+ // delete
+ await page.getByTestId("vehicle").getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(modal);
+ await expect(editor).toContainText("title: pink Honda");
+ await modal.getByRole("button", { name: "Delete" }).click();
+ await expectModalHidden(modal);
+ await expect(page.getByTestId("vehicle")).toHaveCount(0);
+
+ // restart evcc
+ await restart(CONFIG_GRID_ONLY);
+ await page.reload();
+
+ await expect(page.getByTestId("vehicle")).toHaveCount(0);
+ });
});
diff --git a/tests/utils.js b/tests/utils.js
deleted file mode 100644
index 6d200f5b2..000000000
--- a/tests/utils.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import { expect } from "@playwright/test";
-
-export async function enableExperimental(page) {
- await page.getByTestId("topnavigation-button").click();
- await page.getByTestId("topnavigation-settings").click();
- await page.getByLabel("Experimental 🧪").click();
- await page.getByRole("button", { name: "Close" }).click();
- await expect(page.locator(".modal-backdrop")).not.toBeVisible();
-}
-
-export async function expectModalVisible(modal) {
- await expect(modal).toBeVisible();
- await expect(modal).toHaveAttribute("aria-hidden", "false");
-}
-
-export async function expectModalHidden(modal) {
- await expect(modal).not.toBeVisible();
- await expect(modal).toHaveAttribute("aria-hidden", "true");
-}
diff --git a/tests/utils.ts b/tests/utils.ts
new file mode 100644
index 000000000..95f66e1a0
--- /dev/null
+++ b/tests/utils.ts
@@ -0,0 +1,40 @@
+import { expect, Page, Locator } from "@playwright/test";
+
+export async function enableExperimental(page: Page): Promise