Config UI: allow repairing broken loadpoints and site (#22671)

This commit is contained in:
andig 2025-08-03 12:29:11 +02:00 • committed by GitHub
parent 3a80e9b3ee
commit 97189dc789
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 412 additions and 197 deletions

View file

@ -13,7 +13,7 @@ const auth = reactive({
});
export async function updateAuthStatus() {
if (store.state.offline || isSystemError(store.state.fatal)) {
if (store.state.offline || isSystemError(store.state.fatal ?? [])) {
// system not ready, skip auth check
return;
}

View file

@ -53,7 +53,7 @@
class="me-2 flex-grow-1"
readonly
required
:invalid="!charger"
:invalid="!charger || hasDeviceError('charger', values.charger)"
@click.prevent="editCharger"
/>
<button
@ -91,6 +91,7 @@
class="me-2 flex-grow-1"
required
readonly
:invalid="hasDeviceError('meter', values.meter)"
@click="editMeter"
/>
<button
@ -632,6 +633,7 @@ export default {
chargerValues: { type: Object, default: () => {} },
meters: { type: Array as () => Meter[], default: () => [] },
circuits: { type: Array as () => Circuit[], default: () => [] },
hasDeviceError: { type: Function, default: () => false },
},
emits: ["updated", "openMeterModal", "openChargerModal", "opened"],
data() {

View file

@ -25,9 +25,11 @@ Offline.args = {
export const Fatal = Template.bind({});
Fatal.args = {
fatal: {
error: `cannot create charger 'wallbox_cphl': cannot create charger type 'template': cannot create
fatal: [
{
error: `cannot create charger 'wallbox_cphl': cannot create charger type 'template': cannot create
charger type 'hardybarth-ecbl: Post http://192.168.2.219/api/v1/chargecontrols/1/mode: context deadline exceeded (Client.Timeout
exceeded while awaiting headers)`,
},
},
],
};

View file

@ -38,7 +38,11 @@
{{ $t("offline.configurationError") }}
</strong>
</div>
<div v-if="fatalText" class="text-break">{{ fatalText }}</div>
<div class="d-flex flex-column gap-1">
<div v-for="fatalText in fatalTexts" :key="fatalText" class="text-break">
{{ fatalText }}
</div>
</div>
</div>
<RestartButton error @restart="restart" />
</div>
@ -62,7 +66,7 @@ export default defineComponent({
},
props: {
offline: Boolean,
fatal: Object as PropType<FatalError>,
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
},
data() {
return { dismissed: false };
@ -82,14 +86,14 @@ export default defineComponent({
!this.offline &&
!this.restartNeeded &&
!this.restarting &&
this.fatal?.error &&
this.fatal.length > 0 &&
!this.dismissed
);
},
fatalText() {
const { error, class: errorClass } = this.fatal || {};
if (!error) return;
return errorClass ? `${errorClass}: ${error}` : error;
fatalTexts() {
return this.fatal.map(({ error, class: errorClass }) =>
errorClass ? `${errorClass}: ${error}` : error
);
},
},
watch: {

View file

@ -22,7 +22,10 @@
<Energyflow v-if="loadpoints.length > 0" v-bind="energyflow" />
</div>
<div class="d-flex flex-column justify-content-between content-area">
<div v-if="fatal" class="flex-grow-1 align-items-center d-flex justify-content-center">
<div
v-if="hasFatalError"
class="flex-grow-1 align-items-center d-flex justify-content-center"
>
<h1 class="mb-5 text-gray fs-4">{{ $t("startupError.title") }}</h1>
</div>
<div
@ -89,6 +92,7 @@ import type {
Notification,
SMART_COST_TYPE,
Sponsor,
FatalError,
} from "@/types/evcc";
import type { Grid } from "./types";
@ -151,7 +155,7 @@ export default defineComponent({
smartCostType: String as PropType<SMART_COST_TYPE>,
smartCostAvailable: Boolean,
smartFeedInPriorityAvailable: Boolean,
fatal: Object,
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
forecast: Object as PropType<Forecast>,
telemetry: Boolean,
},
@ -221,6 +225,9 @@ export default defineComponent({
},
};
},
hasFatalError() {
return this.fatal.length > 0;
},
},
methods: {
selectedLoadpointChanged(index: number) {

View file

@ -157,7 +157,7 @@ export default defineComponent({
},
forecast: Object,
battery: Array,
fatal: Object as PropType<FatalError>,
fatal: { type: Array as PropType<FatalError[]>, 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";

View file

@ -27,8 +27,9 @@ export interface Auth {
export type VehicleLogins = Record<string, { authenticated: boolean; uri: string }>;
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[];
}

View file

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

View file

@ -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 @@
<DeviceCard
:title="$t('config.mqtt.title')"
editable
:error="fatalClass === 'mqtt'"
:error="hasClassError('mqtt')"
data-testid="mqtt"
@edit="openModal('mqttModal')"
>
@ -218,7 +218,7 @@
<DeviceCard
:title="$t('config.messaging.title')"
editable
:error="fatalClass === 'messenger'"
:error="hasClassError('messenger')"
data-testid="messaging"
@edit="openModal('messagingModal')"
>
@ -230,7 +230,7 @@
<DeviceCard
:title="$t('config.influx.title')"
editable
:error="fatalClass === 'influx'"
:error="hasClassError('influx')"
data-testid="influx"
@edit="openModal('influxModal')"
>
@ -242,7 +242,7 @@
<DeviceCard
:title="`${$t('config.eebus.title')} 🧪`"
editable
:error="fatalClass === 'eebus'"
:error="hasClassError('eebus')"
data-testid="eebus"
@edit="openModal('eebusModal')"
>
@ -255,7 +255,7 @@
<DeviceCard
:title="`${$t('config.circuits.title')} 🧪`"
editable
:error="fatalClass === 'circuit'"
:error="hasClassError('circuit')"
data-testid="circuits"
@edit="openModal('circuitsModal')"
>
@ -282,7 +282,7 @@
<DeviceCard
:title="$t('config.modbusproxy.title')"
editable
:error="fatalClass === 'modbusproxy'"
:error="hasClassError('modbusproxy')"
data-testid="modbusproxy"
@edit="openModal('modbusProxyModal')"
>
@ -294,7 +294,7 @@
<DeviceCard
:title="$t('config.hems.title')"
editable
:error="fatalClass === 'hems'"
:error="hasClassError('hems')"
data-testid="hems"
@edit="openModal('hemsModal')"
>
@ -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);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

157
tests/config-fatals.spec.ts Normal file
View file

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

View file

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

View file

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

View file

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