+
{{ $t("startupError.title") }}
,
smartCostAvailable: Boolean,
smartFeedInPriorityAvailable: Boolean,
- fatal: Object,
+ fatal: { type: Array as PropType, default: () => [] },
forecast: Object as PropType,
telemetry: Boolean,
},
@@ -221,6 +225,9 @@ export default defineComponent({
},
};
},
+ hasFatalError() {
+ return this.fatal.length > 0;
+ },
},
methods: {
selectedLoadpointChanged(index: number) {
diff --git a/assets/js/components/Top/Navigation.vue b/assets/js/components/Top/Navigation.vue
index 3d09a0508..11af67685 100644
--- a/assets/js/components/Top/Navigation.vue
+++ b/assets/js/components/Top/Navigation.vue
@@ -157,7 +157,7 @@ export default defineComponent({
},
forecast: Object,
battery: Array,
- fatal: Object as PropType,
+ fatal: { type: Array as PropType, default: () => [] },
},
data() {
return {
@@ -188,7 +188,7 @@ export default defineComponent({
return this.loginRequired || this.sponsor.expiresSoon || userConfigError;
},
badgeClass() {
- if (this.fatal?.error) {
+ if (this.fatal.length > 0) {
return "bg-danger";
}
return "bg-warning";
diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts
index 2ee6aca39..431ea9b77 100644
--- a/assets/js/types/evcc.ts
+++ b/assets/js/types/evcc.ts
@@ -27,8 +27,9 @@ export interface Auth {
export type VehicleLogins = Record;
export interface FatalError {
- error: any;
- class?: any;
+ error: string;
+ class?: string;
+ device?: string;
}
export interface State {
@@ -37,7 +38,7 @@ export interface State {
loadpoints: [];
forecast?: Forecast;
currency?: CURRENCY;
- fatal?: FatalError;
+ fatal?: FatalError[];
auth?: Auth;
vehicles: Vehicle[];
}
diff --git a/assets/js/utils/fatal.ts b/assets/js/utils/fatal.ts
index b78134a39..cfe053881 100644
--- a/assets/js/utils/fatal.ts
+++ b/assets/js/utils/fatal.ts
@@ -2,22 +2,22 @@ import type { FatalError } from "@/types/evcc";
const FATALS = ["configfile", "database"];
-function isError(fatal?: FatalError) {
- return !!fatal?.error;
+function isError(fatal: FatalError[]) {
+ return fatal.length > 0;
}
-export function isUserConfigError(fatal?: FatalError) {
+export function isUserConfigError(fatal: FatalError[]) {
if (!isError(fatal)) {
return false;
}
- const errorClass = fatal?.class;
- if (FATALS.includes(errorClass)) {
+ if (fatal.some((f) => FATALS.includes(f.class ?? ""))) {
return false;
}
+
return true;
}
-export function isSystemError(fatal?: FatalError) {
+export function isSystemError(fatal: FatalError[]) {
return isError(fatal) && !isUserConfigError(fatal);
}
diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue
index c96be4274..102596a61 100644
--- a/assets/js/views/Config.vue
+++ b/assets/js/views/Config.vue
@@ -25,7 +25,7 @@
:title="loadpoint.title"
:name="loadpoint.name"
:editable="!!loadpoint.id"
- :error="deviceError('loadpoint', loadpoint.name)"
+ :error="hasDeviceError('loadpoint', loadpoint.name)"
data-testid="loadpoint"
@edit="editLoadpoint(loadpoint.id)"
>
@@ -57,7 +57,7 @@
:title="vehicle.config?.title || vehicle.name"
:name="vehicle.name"
:editable="vehicle.id >= 0"
- :error="deviceError('vehicle', vehicle.name)"
+ :error="hasDeviceError('vehicle', vehicle.name)"
data-testid="vehicle"
@edit="editVehicle(vehicle.id)"
>
@@ -82,7 +82,7 @@
:title="$t('config.grid.title')"
:name="gridMeter.name"
:editable="!!gridMeter.id"
- :error="deviceError('meter', gridMeter.name)"
+ :error="hasDeviceError('meter', gridMeter.name)"
data-testid="grid"
@edit="editMeter(gridMeter.id, 'grid')"
>
@@ -103,7 +103,7 @@
v-if="tariffTags"
:title="$t('config.tariffs.title')"
editable
- :error="fatalClass === 'tariff'"
+ :error="hasClassError('tariff')"
data-testid="tariffs"
@edit="openModal('tariffsModal')"
>
@@ -133,7 +133,7 @@
"
:name="meter.name"
:editable="!!meter.id"
- :error="deviceError('meter', meter.name)"
+ :error="hasDeviceError('meter', meter.name)"
data-testid="pv"
@edit="editMeter(meter.id, 'pv')"
>
@@ -154,7 +154,7 @@
"
:name="meter.name"
:editable="!!meter.id"
- :error="deviceError('meter', meter.name)"
+ :error="hasDeviceError('meter', meter.name)"
data-testid="battery"
@edit="editMeter(meter.id, 'battery')"
>
@@ -183,7 +183,7 @@
"
:name="meter.name"
:editable="!!meter.id"
- :error="deviceError('meter', meter.name)"
+ :error="hasDeviceError('meter', meter.name)"
data-testid="aux"
@edit="editMeter(meter.id, 'aux')"
>
@@ -206,7 +206,7 @@
@@ -218,7 +218,7 @@
@@ -230,7 +230,7 @@
@@ -242,7 +242,7 @@
@@ -255,7 +255,7 @@
@@ -282,7 +282,7 @@
@@ -294,7 +294,7 @@
@@ -334,6 +334,7 @@
:meters="meters"
:circuits="circuits"
:fade="loadpointSubModalOpen ? 'left' : ''"
+ :hasDeviceError="hasDeviceError"
@updated="loadpointChanged"
@open-charger-modal="editLoadpointCharger"
@open-meter-modal="editLoadpointMeter"
@@ -496,9 +497,6 @@ export default {
loadpointsRequired() {
return this.loadpoints.length === 0;
},
- fatalClass() {
- return store.state?.fatal?.class;
- },
siteTitle() {
return this.site?.title;
},
@@ -930,9 +928,13 @@ export default {
}
return result;
},
- deviceError(type, name) {
- const fatal = store.state?.fatal || {};
- return fatal.class === type && fatal.device === name;
+ hasDeviceError(type, name) {
+ const fatals = store.state?.fatal || [];
+ return fatals.some((fatal) => fatal.class === type && fatal.device === name);
+ },
+ hasClassError(className) {
+ const fatals = store.state?.fatal || [];
+ return fatals.some((fatal) => fatal.class === className);
},
chargerIcon(chargerName) {
const charger = this.chargers.find((c) => c.name === chargerName);
diff --git a/cmd/helper.go b/cmd/helper.go
index d3d1367b8..aa8aa0b9d 100644
--- a/cmd/helper.go
+++ b/cmd/helper.go
@@ -94,6 +94,18 @@ func shutdownDoneC() <-chan struct{} {
return doneC
}
+// joinErrors is like errors.Join but does not wrap single errors (refs https://groups.google.com/g/golang-nuts/c/N0D1g5Ec_ZU)
+func joinErrors(errs ...error) error {
+ switch len(errs) {
+ case 0:
+ return nil
+ case 1:
+ return errs[0]
+ default:
+ return errors.Join(errs...)
+ }
+}
+
func wrapFatalError(err error) error {
if err == nil {
return nil
diff --git a/cmd/root.go b/cmd/root.go
index ab9379b55..ebe416bec 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -355,9 +355,11 @@ func runRoot(cmd *cobra.Command, args []string) {
}
if err != nil {
- // improve error message
- err = wrapFatalError(err)
- valueChan <- util.Param{Key: keys.Fatal, Val: err}
+ if uw, ok := err.(interface{ Unwrap() []error }); ok {
+ valueChan <- util.Param{Key: keys.Fatal, Val: uw.Unwrap()}
+ } else {
+ valueChan <- util.Param{Key: keys.Fatal, Val: []error{wrapFatalError(err)}}
+ }
// TODO stop reboot loop if user updates config (or show countdown in UI)
log.FATAL.Println(err)
diff --git a/cmd/setup.go b/cmd/setup.go
index f61dbeefe..eca90075d 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -914,20 +914,26 @@ func configureDevices(conf globalconfig.All) error {
return err
}
- // TODO: add name/identifier to error for better highlighting in UI
+ // make sure all devices are configured
+ var errs []error
+
if err := configureMeters(conf.Meters, references.meter...); err != nil {
- return &ClassError{ClassMeter, err}
+ errs = append(errs, &ClassError{ClassMeter, err})
}
+
if err := configureChargers(conf.Chargers, references.charger...); err != nil {
- return &ClassError{ClassCharger, err}
+ errs = append(errs, &ClassError{ClassCharger, err})
}
+
if err := configureVehicles(conf.Vehicles); err != nil {
- return &ClassError{ClassVehicle, err}
+ errs = append(errs, &ClassError{ClassVehicle, err})
}
+
if err := configureCircuits(&conf.Circuits); err != nil {
- return &ClassError{ClassCircuit, err}
+ errs = append(errs, &ClassError{ClassCircuit, err})
}
- return nil
+
+ return joinErrors(errs...)
}
func configureModbusProxy(conf *[]globalconfig.ModbusProxy) error {
@@ -962,38 +968,39 @@ func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) {
return nil, err
}
conf.Interval = time.Duration(d)
-
- // TODO remove yaml file
- // } else if conf.Interval != 0 {
- // settings.SetInt(keys.Interval, int64(conf.Interval))
}
+ var errs []error
+
if err := configureDevices(*conf); err != nil {
- return nil, err
+ errs = append(errs, err)
}
if err := configureLoadpoints(*conf); err != nil {
- return nil, &ClassError{ClassLoadpoint, err}
+ errs = append(errs, &ClassError{ClassLoadpoint, err})
}
tariffs, err := configureTariffs(&conf.Tariffs)
if err != nil {
- return nil, &ClassError{ClassTariff, err}
+ errs = append(errs, &ClassError{ClassTariff, err})
}
loadpoints := lo.Map(config.Loadpoints().Devices(), func(dev config.Device[loadpoint.API], _ int) *core.Loadpoint {
- lp := dev.Instance()
- return lp.(*core.Loadpoint)
+ return dev.Instance().(*core.Loadpoint)
})
site, err := configureSite(conf.Site, loadpoints, tariffs)
if err != nil {
- return nil, err
+ errs = append(errs, err)
+ }
+
+ if len(errs) > 0 {
+ return site, joinErrors(errs...)
}
if len(config.Circuits().Devices()) > 0 {
if err := validateCircuits(loadpoints); err != nil {
- return nil, &ClassError{ClassCircuit, err}
+ return site, &ClassError{ClassCircuit, err}
}
}
@@ -1037,11 +1044,11 @@ CONTINUE:
func configureSite(conf map[string]interface{}, loadpoints []*core.Loadpoint, tariffs *tariff.Tariffs) (*core.Site, error) {
site, err := core.NewSiteFromConfig(conf)
if err != nil {
- return nil, err
+ return site, err
}
if err := site.Boot(log, loadpoints, tariffs); err != nil {
- return nil, fmt.Errorf("failed configuring site: %w", err)
+ return site, fmt.Errorf("failed booting site: %w", err)
}
return site, nil
@@ -1094,8 +1101,11 @@ func configureLoadpoints(conf globalconfig.All) error {
err = &DeviceError{cc.Name, e}
}
- if e := dynamic.Apply(instance); e != nil && err == nil {
- err = &DeviceError{cc.Name, e}
+ if instance != nil {
+ // ignore dynamic config in case of startup errors that will leave instance empty
+ if e := dynamic.Apply(instance); e != nil && err == nil {
+ err = &DeviceError{cc.Name, e}
+ }
}
if err != nil {
diff --git a/cmd/setup_circuits_test.go b/cmd/setup_circuits_test.go
index d12029e9a..09b68278b 100644
--- a/cmd/setup_circuits_test.go
+++ b/cmd/setup_circuits_test.go
@@ -33,6 +33,10 @@ func (suite *circuitsTestSuite) SetupTest() {
config.Reset()
}
+func (suite *circuitsTestSuite) charger() api.Charger {
+ return api.NewMockCharger(gomock.NewController(suite.T()))
+}
+
func (suite *circuitsTestSuite) TestCircuitConf() {
var conf globalconfig.All
viper.SetConfigType("yaml")
@@ -58,7 +62,7 @@ loadpoints:
// empty charger
suite.Require().NoError(config.Chargers().Add(config.NewStaticDevice(config.Named{
Name: "test",
- }, api.Charger(nil))))
+ }, suite.charger())))
err := configureLoadpoints(conf)
suite.Require().NoError(err)
@@ -89,7 +93,7 @@ loadpoints:
// empty charger
suite.Require().NoError(config.Chargers().Add(config.NewStaticDevice(config.Named{
Name: "test",
- }, api.Charger(nil))))
+ }, suite.charger())))
err := configureLoadpoints(conf)
suite.Require().NoError(err)
@@ -146,7 +150,7 @@ loadpoints:
// mock charger
suite.Require().NoError(config.Chargers().Add(config.NewStaticDevice(config.Named{
Name: "test",
- }, api.Charger(nil))))
+ }, suite.charger())))
err := configureLoadpoints(conf)
suite.Require().NoError(err)
diff --git a/core/circuit/circuit.go b/core/circuit/circuit.go
index 4d9a416bf..b3461fcc4 100644
--- a/core/circuit/circuit.go
+++ b/core/circuit/circuit.go
@@ -2,6 +2,7 @@ package circuit
import (
"context"
+ "errors"
"fmt"
"math"
"sync"
@@ -66,6 +67,9 @@ func NewFromConfig(ctx context.Context, log *util.Logger, other map[string]inter
return nil, err
}
meter = dev.Instance()
+ if meter == nil {
+ return nil, errors.New("missing meter instance")
+ }
}
circuit, err := New(log, cc.Title, cc.MaxCurrent, cc.MaxPower, meter, cc.Timeout)
@@ -88,7 +92,11 @@ func NewFromConfig(ctx context.Context, log *util.Logger, other map[string]inter
if err != nil {
return nil, err
}
- circuit.setParent(dev.Instance())
+ parent := dev.Instance()
+ if parent == nil {
+ return nil, fmt.Errorf("missing parent circuit instance: %s", cc.ParentRef)
+ }
+ circuit.setParent(parent)
}
return circuit, err
diff --git a/core/loadpoint.go b/core/loadpoint.go
index b273ff875..fbb196751 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -219,6 +219,9 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, other
return lp, fmt.Errorf("circuit: %w", err)
}
lp.circuit = dev.Instance()
+ if lp.circuit == nil {
+ return lp, errors.New("missing circuit instance")
+ }
}
if lp.MeterRef != "" {
@@ -227,6 +230,9 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, other
return lp, fmt.Errorf("meter: %w", err)
}
lp.chargeMeter = dev.Instance()
+ if lp.chargeMeter == nil {
+ return lp, errors.New("missing charge meter instance")
+ }
}
// default vehicle
@@ -236,6 +242,9 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, other
return lp, fmt.Errorf("default vehicle: %w", err)
}
lp.defaultVehicle = dev.Instance()
+ if lp.defaultVehicle == nil {
+ return lp, errors.New("missing default vehicle instance")
+ }
}
if lp.ChargerRef == "" {
@@ -247,6 +256,10 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, other
return lp, fmt.Errorf("charger: %w", err)
}
lp.charger = dev.Instance()
+ if lp.charger == nil {
+ return lp, errors.New("missing charger instance")
+ }
+
lp.configureChargerType(lp.charger)
// phase switching defaults based on charger capabilities
diff --git a/core/site.go b/core/site.go
index c8f718ff6..0745cb1a5 100644
--- a/core/site.go
+++ b/core/site.go
@@ -3,6 +3,7 @@ package core
import (
"bytes"
"context"
+ "errors"
"fmt"
"math"
"strings"
@@ -196,6 +197,9 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
return err
}
site.gridMeter = dev.Instance()
+ if site.gridMeter == nil {
+ return errors.New("missing grid meter instance")
+ }
}
// multiple pv
diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go
index 50f26620b..95528c9cf 100644
--- a/server/http_config_device_handler.go
+++ b/server/http_config_device_handler.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
+ "reflect"
"strconv"
"dario.cat/mergo"
@@ -199,14 +200,22 @@ func deviceConfigHandler(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, res)
}
-func deviceStatus[T any](name string, h config.Handler[T]) (T, error) {
+func deviceStatus[T comparable](name string, h config.Handler[T]) (T, error) {
+ var zero T
+
dev, err := h.ByName(name)
if err != nil {
- var zero T
return zero, err
}
- return dev.Instance(), nil
+ instance := dev.Instance()
+
+ // check if device instance is nil (https://github.com/golang/go/issues/46320#issuecomment-965970859)
+ if rv := reflect.ValueOf(&instance); rv.Elem().IsZero() || rv.Elem().IsNil() {
+ return zero, fmt.Errorf("instance %s not initialized", name)
+ }
+
+ return instance, nil
}
// deviceStatusHandler returns the device test status by class
diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go
index b83c058e4..90972c6bb 100644
--- a/server/http_config_loadpoint_handler.go
+++ b/server/http_config_loadpoint_handler.go
@@ -68,17 +68,35 @@ func loadpointSplitConfig(r io.Reader) (loadpoint.DynamicConfig, map[string]any,
// loadpointConfig returns a single loadpoint's configuration
func loadpointConfig(dev config.Device[loadpoint.API]) loadpointFullConfig {
- lp := dev.Instance()
-
var id int
if configurable, ok := dev.(config.ConfigurableDevice[loadpoint.API]); ok {
id = configurable.ID()
}
- res := loadpointFullConfig{
- ID: id,
- Name: dev.Config().Name,
+ lp := dev.Instance()
+ // // missing instance due to error, decode config from database
+ // if lp == nil || reflect.ValueOf(lp).IsNil() {
+ // cc := dev.Config()
+
+ // dynamic, staticMap, _ := loadpoint.SplitConfig(cc.Other)
+
+ // var static loadpoint.StaticConfig
+ // _ = util.DecodeOther(staticMap, &static)
+
+ // res := loadpointFullConfig{
+ // ID: id,
+ // Name: dev.Config().Name,
+ // StaticConfig: static,
+ // DynamicConfig: dynamic,
+ // }
+
+ // return res
+ // }
+
+ res := loadpointFullConfig{
+ ID: id,
+ Name: dev.Config().Name,
StaticConfig: getLoadpointStaticConfig(lp),
DynamicConfig: getLoadpointDynamicConfig(lp),
}
diff --git a/tests/config-fatals.spec.ts b/tests/config-fatals.spec.ts
new file mode 100644
index 000000000..976a328a5
--- /dev/null
+++ b/tests/config-fatals.spec.ts
@@ -0,0 +1,157 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, restart, baseUrl } from "./evcc";
+import { startSimulator, stopSimulator, simulatorHost } from "./simulator";
+import {
+ expectModalVisible,
+ expectModalHidden,
+ enableExperimental,
+ addDemoCharger,
+ newLoadpoint,
+} from "./utils";
+
+test.use({ baseURL: baseUrl() });
+
+test.afterEach(async () => {
+ await stop();
+});
+
+test.describe("fatal config handling", async () => {
+ test("broken pv meter", async ({ page }) => {
+ await startSimulator();
+ await start();
+
+ await page.goto("/#/config");
+ await enableExperimental(page, false);
+
+ // create meter
+ 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("North Roof");
+ await meterModal.getByLabel("Manufacturer").selectOption("shelly-1pm");
+ await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
+ await meterModal.getByRole("button", { name: "Validate & save" }).click();
+ await expectModalHidden(meterModal);
+ await expect(page.getByTestId("pv")).toBeVisible();
+ await expect(page.getByTestId("pv")).toContainText("North Roof");
+
+ // break meter
+ await stopSimulator();
+ await restart();
+ await page.reload();
+
+ // remove meter
+ await expect(page.getByTestId("fatal-error")).toBeVisible();
+ await expect(page.getByTestId("pv")).toBeVisible();
+ await page.getByTestId("pv").getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(meterModal);
+ await meterModal.getByRole("button", { name: "Delete" }).click();
+ await expectModalHidden(meterModal);
+ await expect(page.getByTestId("pv")).toHaveCount(0);
+
+ // restart and check again
+ await restart();
+ await page.reload();
+ await expect(page.getByTestId("pv")).toHaveCount(0);
+ await expect(page.getByTestId("fatal-error")).not.toBeVisible();
+ });
+
+ test("broken loadpoint meter", async ({ page }) => {
+ await startSimulator();
+ await start();
+
+ await page.goto("/#/config");
+ await enableExperimental(page, false);
+
+ const lpModal = page.getByTestId("loadpoint-modal");
+
+ // create loadpoint with demo charger and shelly meter that will break
+ await newLoadpoint(page, "Test Carport");
+ await addDemoCharger(page);
+
+ // add shelly meter
+ await lpModal.getByRole("button", { name: "Add dedicated energy meter" }).click();
+ const meterModal = page.getByTestId("meter-modal");
+ await expectModalVisible(meterModal);
+ await meterModal.getByLabel("Manufacturer").selectOption("shelly-1pm");
+ await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
+ await meterModal.getByRole("button", { name: "Validate & save" }).click();
+ await expectModalHidden(meterModal);
+
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(lpModal);
+ await expect(page.getByTestId("loadpoint")).toContainText("Test Carport");
+
+ // break meter
+ await stopSimulator();
+ await restart();
+ await page.reload();
+
+ // verify loadpoint still visible with error
+ await expect(page.getByTestId("fatal-error")).toBeVisible();
+ await expect(page.getByTestId("fatal-error")).toContainText(
+ /meter: .+? cannot create meter .+?: cannot create meter type 'template': cannot create meter type 'shelly'/
+ );
+ await expect(page.getByTestId("fatal-error")).toContainText(
+ /loadpoint: .+? missing charge meter instance/
+ );
+ await expect(page.getByTestId("loadpoint")).toBeVisible();
+
+ // open modal and delete meter
+ await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(lpModal);
+ await expect(lpModal.getByRole("textbox", { name: "Energy meter" })).toHaveClass(/is-invalid/);
+ await lpModal.getByRole("textbox", { name: "Energy meter" }).click();
+ await expectModalVisible(meterModal);
+ await meterModal.getByRole("button", { name: "Delete" }).click();
+ await expectModalHidden(meterModal);
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(lpModal);
+
+ // restart and verify
+ await restart();
+ await page.reload();
+ await expect(page.getByTestId("loadpoint")).toBeVisible();
+ await expect(page.getByTestId("fatal-error")).not.toBeVisible(); // error should be gone
+ });
+
+ test("broken grid meter", async ({ page }) => {
+ // setup test data for mock api
+ await startSimulator();
+ await start();
+
+ await page.goto("/#/config");
+ await enableExperimental(page, false);
+
+ // create grid meter
+ await page.getByRole("button", { name: "Add grid meter" }).click();
+ const meterModal = page.getByTestId("meter-modal");
+ await meterModal.getByLabel("Manufacturer").selectOption("shelly-1pm");
+ await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
+ await meterModal.getByRole("button", { name: "Validate & save" }).click();
+ await expectModalHidden(meterModal);
+ await expect(page.getByTestId("grid")).toBeVisible();
+
+ // break meter
+ await stopSimulator();
+ await restart();
+ await page.reload();
+
+ // verify grid meter still visible with error
+ await expect(page.getByTestId("fatal-error")).toBeVisible();
+ await expect(page.getByTestId("grid")).toBeVisible();
+
+ // open modal and delete meter
+ await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(meterModal);
+ await meterModal.getByRole("button", { name: "Delete" }).click();
+ await expectModalHidden(meterModal);
+ await expect(page.getByTestId("grid")).toHaveCount(0);
+
+ // restart and verify
+ await restart();
+ await page.reload();
+ await expect(page.getByTestId("grid")).toHaveCount(0);
+ await expect(page.getByTestId("fatal-error")).not.toBeVisible(); // error should be gone
+ });
+});
diff --git a/tests/config-loadpoint.spec.ts b/tests/config-loadpoint.spec.ts
index 919e2c4e8..e3bf82e5b 100644
--- a/tests/config-loadpoint.spec.ts
+++ b/tests/config-loadpoint.spec.ts
@@ -1,6 +1,17 @@
-import { test, expect, type Page } from "@playwright/test";
+import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
-import { expectModalVisible, expectModalHidden, editorClear, editorPaste } from "./utils";
+import {
+ expectModalVisible,
+ expectModalHidden,
+ editorClear,
+ editorPaste,
+ enableExperimental,
+ LoadpointType,
+ addDemoCharger,
+ addDemoMeter,
+ addVehicle,
+ newLoadpoint,
+} from "./utils";
const CONFIG_ONE_LP = "config-one-lp.evcc.yaml";
@@ -11,79 +22,6 @@ test.afterEach(async () => {
await stop();
});
-enum LoadpointType {
- Charging = "charging",
- Heating = "heating",
-}
-
-async function enableExperimental(page: Page, inline = true) {
- if (inline) {
- await page.getByRole("button", { name: "Enable Experimental Features" }).click();
- } else {
- await page
- .getByTestId("generalconfig-experimental")
- .getByRole("button", { name: "edit" })
- .click();
- await page.getByLabel("Experimental 🧪").click();
- await page.getByRole("button", { name: "Close" }).click();
- }
-}
-
-async function addDemoCharger(page: Page, type: LoadpointType = LoadpointType.Charging) {
- const lpModal = page.getByTestId("loadpoint-modal");
- await lpModal
- .getByRole("button", { name: type === LoadpointType.Heating ? "Add heater" : "Add charger" })
- .click();
-
- const modal = page.getByTestId("charger-modal");
- await expectModalVisible(modal);
- await modal
- .getByLabel("Manufacturer")
- .selectOption(type === LoadpointType.Heating ? "Demo heat pump" : "Demo charger");
- await modal.getByRole("button", { name: "Save" }).click();
- await expectModalHidden(modal);
- await expectModalVisible(lpModal);
-}
-
-async function addDemoMeter(page: Page, power = "0") {
- const lpModal = page.getByTestId("loadpoint-modal");
- await lpModal.getByRole("button", { name: "Add dedicated energy meter" }).click();
-
- const modal = page.getByTestId("meter-modal");
- await expectModalVisible(modal);
- await modal.getByLabel("Manufacturer").selectOption("Demo meter");
- await modal.getByLabel("Power").fill(power);
- await modal.getByRole("button", { name: "Save" }).click();
- await expectModalHidden(modal);
- await expectModalVisible(lpModal);
-}
-
-async function addVehicle(page: Page, title: string) {
- await page.getByRole("button", { name: "Add vehicle" }).click();
- const modal = page.getByTestId("vehicle-modal");
- await expectModalVisible(modal);
- 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);
-}
-
-async function newLoadpoint(
- page: Page,
- title: string,
- type: LoadpointType = LoadpointType.Charging
-) {
- const lpModal = page.getByTestId("loadpoint-modal");
- await page.getByRole("button", { name: "Add charger or heater" }).click();
- await expectModalVisible(lpModal);
- await lpModal
- .getByRole("button", {
- name: type === LoadpointType.Heating ? "Add heating device" : "Add charging point",
- })
- .click();
- await lpModal.getByLabel("Title").fill(title);
-}
-
test.describe("charging loadpoint", async () => {
test("create, update and delete", async ({ page }) => {
await start();
diff --git a/tests/config-pv.spec.ts b/tests/config-pv.spec.ts
index 327dd4f7f..d25925527 100644
--- a/tests/config-pv.spec.ts
+++ b/tests/config-pv.spec.ts
@@ -1,6 +1,5 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
-import { startSimulator, stopSimulator, simulatorHost } from "./simulator";
import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
@@ -71,44 +70,4 @@ test.describe("pv meter", async () => {
await page.reload();
await expect(page.getByTestId("pv")).toHaveCount(0);
});
-
- test("remove broken pv meter", async ({ page }) => {
- // setup test data for mock openems api
- await startSimulator();
-
- await page.goto("/#/config");
- await enableExperimental(page, false);
-
- // create meter
- 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("North Roof");
- await meterModal.getByLabel("Manufacturer").selectOption("shelly-1pm");
- await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
- await meterModal.getByRole("button", { name: "Validate & save" }).click();
- await expectModalHidden(meterModal);
- await expect(page.getByTestId("pv")).toBeVisible();
- await expect(page.getByTestId("pv")).toContainText("North Roof");
-
- // break meter
- await stopSimulator();
- await restart(CONFIG_GRID_ONLY);
- await page.reload();
-
- // remove meter
- await expect(page.getByTestId("fatal-error")).toBeVisible();
- await expect(page.getByTestId("pv")).toBeVisible();
- await page.getByTestId("pv").getByRole("button", { name: "edit" }).click();
- await expectModalVisible(meterModal);
- await meterModal.getByRole("button", { name: "Delete" }).click();
- await expectModalHidden(meterModal);
- 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);
- await expect(page.getByTestId("fatal-error")).not.toBeVisible();
- });
});
diff --git a/tests/utils.ts b/tests/utils.ts
index 133cb9d1f..1b10ca022 100644
--- a/tests/utils.ts
+++ b/tests/utils.ts
@@ -60,3 +60,66 @@ export async function editorPaste(editor: Locator, page: Page, text: string): Pr
await page.evaluate((text) => navigator.clipboard.writeText(text), text);
await page.keyboard.press("ControlOrMeta+KeyV", { delay: 50 });
}
+
+export enum LoadpointType {
+ Charging = "charging",
+ Heating = "heating",
+}
+
+export async function addDemoCharger(
+ page: Page,
+ type: LoadpointType = LoadpointType.Charging
+): Promise {
+ const lpModal = page.getByTestId("loadpoint-modal");
+ await lpModal
+ .getByRole("button", { name: type === LoadpointType.Heating ? "Add heater" : "Add charger" })
+ .click();
+
+ const modal = page.getByTestId("charger-modal");
+ await expectModalVisible(modal);
+ await modal
+ .getByLabel("Manufacturer")
+ .selectOption(type === LoadpointType.Heating ? "Demo heat pump" : "Demo charger");
+ await modal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(modal);
+ await expectModalVisible(lpModal);
+}
+
+export async function addDemoMeter(page: Page, power = "0"): Promise {
+ const lpModal = page.getByTestId("loadpoint-modal");
+ await lpModal.getByRole("button", { name: "Add dedicated energy meter" }).click();
+
+ const modal = page.getByTestId("meter-modal");
+ await expectModalVisible(modal);
+ await modal.getByLabel("Manufacturer").selectOption("Demo meter");
+ await modal.getByLabel("Power").fill(power);
+ await modal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(modal);
+ await expectModalVisible(lpModal);
+}
+
+export async function addVehicle(page: Page, title: string): Promise {
+ await page.getByRole("button", { name: "Add vehicle" }).click();
+ const modal = page.getByTestId("vehicle-modal");
+ await expectModalVisible(modal);
+ 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);
+}
+
+export async function newLoadpoint(
+ page: Page,
+ title: string,
+ type: LoadpointType = LoadpointType.Charging
+): Promise {
+ const lpModal = page.getByTestId("loadpoint-modal");
+ await page.getByRole("button", { name: "Add charger or heater" }).click();
+ await expectModalVisible(lpModal);
+ await lpModal
+ .getByRole("button", {
+ name: type === LoadpointType.Heating ? "Add heating device" : "Add charging point",
+ })
+ .click();
+ await lpModal.getByLabel("Title").fill(title);
+}