Config UI: allow repairing broken loadpoints and site (#22671)
This commit is contained in:
parent
3a80e9b3ee
commit
97189dc789
22 changed files with 412 additions and 197 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
52
cmd/setup.go
52
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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
157
tests/config-fatals.spec.ts
Normal 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
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue