Run without loadpoints (#26367)

Co-authored-by: andig <andi@evcc.io>
This commit is contained in:
Michael Geers 2026-01-05 13:47:47 +01:00 • committed by GitHub
parent ecaa0e6068
commit b399931c4c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 108 additions and 45 deletions

View file

@ -1,7 +1,6 @@
<template>
<button
class="root d-flex align-items-center justify-content-center"
:class="{ attention }"
tabindex="0"
@click="$emit('click')"
>
@ -17,7 +16,6 @@ export default {
name: "NewDeviceButton",
props: {
title: String,
attention: Boolean,
},
emits: ["click"],
};
@ -40,25 +38,4 @@ export default {
border-color: var(--evcc-default-text);
color: var(--evcc-default-text);
}
.attention {
animation: wiggle 3s cubic-bezier(0.36, 0.07, 0.19, 0.97) infinite;
}
@keyframes wiggle {
0%,
100% {
transform: rotate(0deg);
}
5%,
15% {
transform: rotate(-2deg);
}
10%,
20% {
transform: rotate(2deg);
}
25% {
transform: rotate(0deg);
}
}
</style>

View file

@ -13,3 +13,9 @@ export default {
components: { Markdown },
};
</script>
<style scoped>
.alert {
margin-bottom: 5rem;
}
</style>

View file

@ -1,7 +1,10 @@
<template>
<div
class="energyflow cursor-pointer position-relative"
:class="{ 'energyflow--open': detailsOpen }"
class="energyflow position-relative"
:class="{
'energyflow--open': detailsOpen || detailsAlwaysOpen,
'cursor-pointer': !detailsAlwaysOpen,
}"
data-testid="energyflow"
@click="toggleDetails"
>
@ -445,7 +448,13 @@ export default defineComponent({
outPower() {
return this.homePower + this.loadpointsPower + this.pvExport + this.batteryCharge;
},
detailsAlwaysOpen() {
return this.loadpoints.length === 0;
},
detailsHeight() {
if (this.detailsAlwaysOpen) {
return "auto";
}
return this.detailsOpen ? this.detailsCompleteHeight + "px" : 0;
},
batteryFmt() {
@ -545,6 +554,7 @@ export default defineComponent({
},
mounted() {
window.addEventListener("resize", this.updateHeight);
// height must be calculated in case of initially open details
if (settings.energyflowDetails) {
this.toggleDetails();

View file

@ -1,8 +1,10 @@
<template>
<div
class="container container--loadpoint px-0 mb-md-2 d-flex flex-column justify-content-center"
data-testid="loadpoints"
>
<div
v-if="loadpoints.length > 0"
ref="carousel"
class="carousel d-lg-flex flex-wrap"
:class="[`carousel--${loadpoints.length}`, { 'carousel--fullwidth': fullWidth }]"
@ -186,7 +188,7 @@ export default defineComponent({
});
</script>
<style scoped>
.container--loadpoint {
.container--loadpoint:not(:empty) {
min-height: 300px;
}

View file

@ -50,7 +50,7 @@
</div>
</div>
<Loadpoints
v-else-if="loadpoints.length > 0"
v-else
class="mt-1 mt-sm-2 flex-grow-1"
:loadpoints="orderedVisibleLoadpoints"
:vehicles="vehicleList"

View file

@ -20,8 +20,8 @@
@site-changed="siteChanged"
/>
<WelcomeBanner v-if="setupRequired" />
<h2 class="my-4">{{ $t("config.section.loadpoints") }}</h2>
<WelcomeBanner v-if="loadpointsRequired" class="my-4" />
<div class="p-0 config-list">
<DeviceCard
v-for="loadpoint in loadpoints"
@ -48,7 +48,6 @@
<NewDeviceButton
data-testid="add-loadpoint"
:title="$t('config.main.addLoadpoint')"
:attention="loadpointsRequired"
@click="newLoadpoint"
/>
</div>
@ -571,8 +570,8 @@ export default defineComponent({
authProviders() {
return store.state?.authProviders;
},
loadpointsRequired() {
return this.loadpoints.length === 0;
setupRequired() {
return store.state?.setupRequired;
},
siteTitle() {
return this.site?.title;

View file

@ -279,7 +279,7 @@ func runRoot(cmd *cobra.Command, args []string) {
// signal devices initialized
valueChan <- util.Param{Key: keys.StartupCompleted, Val: true}
// show onboarding UI
valueChan <- util.Param{Key: keys.SetupRequired, Val: site == nil || len(site.Loadpoints()) == 0}
valueChan <- util.Param{Key: keys.SetupRequired, Val: site == nil || !site.IsConfigured()}
// setup mqtt publisher
if err == nil && conf.Mqtt.Broker != "" && conf.Mqtt.Topic != "" {

View file

@ -957,7 +957,7 @@ func (site *Site) update(lp updater) {
// prioritize if possible
var flexiblePower float64
if lp.GetMode() == api.ModePV {
if lp != nil && lp.GetMode() == api.ModePV {
flexiblePower = site.prioritizer.GetChargePowerFlexibility(lp)
}
@ -978,10 +978,12 @@ func (site *Site) update(lp updater) {
greenShareLoadpoints := site.greenShare(nonChargePower, nonChargePower+totalChargePower)
// TODO
lp.Update(
sitePower, max(0, site.batteryPower), consumption, feedin, batteryBuffered, batteryStart,
greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints),
)
if lp != nil {
lp.Update(
sitePower, max(0, site.batteryPower), consumption, feedin, batteryBuffered, batteryStart,
greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints),
)
}
site.Health.Update()
@ -1097,9 +1099,18 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- push.Even
// loopLoadpoints keeps iterating across loadpoints sending the next to the given channel
func (site *Site) loopLoadpoints(next chan<- updater) {
var logOnce sync.Once
for {
for _, lp := range site.loadpoints {
next <- lp
if len(site.loadpoints) == 0 {
logOnce.Do(func() {
site.log.INFO.Println("no loadpoints configured, running in meter-only mode")
})
next <- nil
} else {
for _, lp := range site.loadpoints {
next <- lp
}
}
}
}
@ -1114,7 +1125,7 @@ func (site *Site) Run(stopC chan struct{}, interval time.Duration) {
}
loadpointChan := make(chan updater)
if len(site.loadpoints) > 0 {
if site.IsConfigured() {
go site.loopLoadpoints(loadpointChan)
}

View file

@ -137,6 +137,14 @@ func (site *Site) Loadpoints() []loadpoint.API {
return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) loadpoint.API { return lp })
}
func (site *Site) hasMeters() bool {
return site.gridMeter != nil || len(site.pvMeters) > 0 || len(site.batteryMeters) > 0 || len(site.auxMeters) > 0 || len(site.extMeters) > 0
}
func (site *Site) IsConfigured() bool {
return len(site.loadpoints) > 0 || site.hasMeters()
}
// loadpointsAsCircuitDevices returns the loadpoints as circuit devices
func (site *Site) loadpointsAsCircuitDevices() []api.CircuitLoad {
return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) api.CircuitLoad { return lp })

View file

@ -387,7 +387,7 @@
"title": "Konfiguration",
"unconfigured": "nicht konfiguriert",
"vehicles": "Meine Fahrzeuge",
"welcomeBannerText": "Lege zunächst eine **Wallbox oder Heizung** an. Falls du noch keine echte Wallbox hast, wähle eine **Demowallbox**.",
"welcomeBannerText": "Lege zunächst eine **Wallbox**, **Heizung**, **Netzzähler**, **Solar**, **Batterie** oder **zusätzlichen Zähler** an. Falls du nur testen möchtest, wähle ein **Demogerät**.",
"welcomeBannerTitle": "Los geht's mit der Konfiguration!",
"yaml": "Geräte aus evcc.yaml sind nicht editierbar."
},

View file

@ -387,7 +387,7 @@
"title": "Configuration",
"unconfigured": "not configured",
"vehicles": "My Vehicles",
"welcomeBannerText": "Start with creating a **charger or heater**. If you don't have a real charger yet, choose a **demo charger**.",
"welcomeBannerText": "Start with creating at least one **charger**, **heater**, **grid**, **solar**, **battery** or **additional meter**. If you're just want to test, pick a **demo device**.",
"welcomeBannerTitle": "Let's configure your system!",
"yaml": "Devices from evcc.yaml are not editable."
},

View file

@ -196,9 +196,14 @@ test.describe("backup and restore", async () => {
await expect(page.getByTestId("offline-indicator")).toHaveAttribute("aria-hidden", "false");
await restart(undefined, undefined, true);
await expect(page.getByTestId("offline-indicator")).toHaveAttribute("aria-hidden", "true");
await page.getByRole("link", { name: "Let's start configuration" }).click();
// verify initial state
// redirect to main ui with initial title
await expect(
page.getByTestId("header").getByRole("heading", { name: initialTitle })
).toBeVisible();
// verify initial state in config ui
await page.goto("/#/config");
await expect(page.getByTestId("grid")).toBeVisible();
await expect(page.getByTestId("generalconfig-title")).toContainText(initialTitle);
await stop();

View file

@ -0,0 +1,42 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { expectModalHidden, expectModalVisible } from "./utils";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
await start();
});
test.afterAll(async () => {
await stop();
});
test.describe("meter only", async () => {
test("run without loadpoints", async ({ page }) => {
await page.goto("/#/config");
await expect(page.getByTestId("welcome-banner")).toContainText(
"Start with creating at least one"
);
await page.getByRole("button", { name: "Add grid meter" }).click();
const gridModal = page.getByTestId("meter-modal");
await expectModalVisible(gridModal);
await gridModal.getByLabel("Manufacturer").selectOption("Demo meter");
await gridModal.getByLabel("Power").fill("1000");
await gridModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(gridModal);
await restart();
await expect(page.getByTestId("welcome-banner")).not.toBeVisible();
await expect(page.getByTestId("grid").getByTestId("device-tag-power")).toContainText("1.0 kW");
await page.getByTestId("home-link").click();
await expect(page.locator("body")).not.toContainText("Hello aboard!");
await expect(page.getByTestId("energyflow")).toBeVisible();
await expect(page.getByTestId("energyflow-entry-gridimport")).toContainText("1.0 kW");
await expect(page.getByTestId("loadpoints")).toBeEmpty();
});
});

View file

@ -58,6 +58,9 @@ test.describe("onboarding", async () => {
await expectModalVisible(lpModal);
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
await restart();
await expect(page.getByTestId("welcome-banner")).not.toBeVisible();
// create grid meter

View file

@ -128,7 +128,7 @@ async function _start(config?: string, flags: string | string[] = []) {
steamLog.end();
});
try {
await waitOn({ resources: [baseUrl()], log: LOG_ENABLED, timeout: 50000 });
await waitOn({ resources: [baseUrl()], log: LOG_ENABLED, timeout: 90000 });
} catch (error) {
instance.kill("SIGKILL");
console.error(logPrefix(), `evcc startup failed: ${error}`);