Non-solar use cases: make grid meter optional (#14341)

This commit is contained in:
Michael Geers 2024-06-22 10:21:57 +02:00 • committed by GitHub
parent 598bf557c5
commit 280976cb7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 275 additions and 49 deletions

View file

@ -64,17 +64,19 @@
>
<div class="d-flex justify-content-between align-items-end mb-4">
<h3 class="m-0">In</h3>
<span class="fw-bold">
<span v-if="pvPossible" class="fw-bold">
<AnimatedNumber :to="inPower" :format="kw" />
</span>
</div>
<div>
<EnergyflowEntry
v-if="pvPossible"
:name="$t('main.energyflow.pvProduction')"
icon="sun"
:power="pvProduction"
:powerTooltip="pvTooltip"
:powerInKw="powerInKw"
data-testid="energyflow-entry-production"
/>
<EnergyflowEntry
v-if="batteryConfigured"
@ -106,12 +108,13 @@
>
<div class="d-flex justify-content-between align-items-end mb-4">
<h3 class="m-0">Out</h3>
<span class="fw-bold">
<span v-if="pvPossible" class="fw-bold">
<AnimatedNumber :to="outPower" :format="kw" />
</span>
</div>
<div>
<EnergyflowEntry
v-if="pvPossible"
:name="$t('main.energyflow.homePower')"
icon="home"
:power="homePower"
@ -119,6 +122,7 @@
:details="detailsValue(tariffPriceHome, tariffCo2Home)"
:detailsFmt="detailsFmt"
:detailsTooltip="detailsTooltip(tariffPriceHome, tariffCo2Home)"
data-testid="energyflow-entry-home"
/>
<EnergyflowEntry
:name="
@ -139,6 +143,7 @@
:detailsTooltip="
detailsTooltip(tariffPriceLoadpoints, tariffCo2Loadpoints)
"
data-testid="energyflow-entry-loadpoints"
/>
<EnergyflowEntry
v-if="batteryConfigured"
@ -153,6 +158,7 @@
@details-clicked="openBatterySettingsModal"
/>
<EnergyflowEntry
v-if="pvPossible"
:name="$t('main.energyflow.pvExport')"
icon="powersupply"
:power="pvExport"
@ -160,6 +166,7 @@
:details="detailsValue(-tariffFeedIn)"
:detailsFmt="detailsFmt"
:detailsTooltip="detailsTooltip(-tariffFeedIn)"
data-testid="energyflow-entry-gridexport"
/>
</div>
</div>
@ -293,6 +300,9 @@ export default {
co2Available() {
return this.smartCostType === CO2_TYPE;
},
pvPossible() {
return this.pvConfigured || this.gridConfigured;
},
},
mounted() {
window.addEventListener("resize", this.updateHeight);

View file

@ -60,11 +60,8 @@
:format="fmtBarValue"
/>
</div>
<div
v-if="totalAdjusted <= 0"
class="site-progress-bar bg-light border no-wrap w-100 text-dark"
>
<span>{{ $t("main.energyflow.noEnergy") }}</span>
<div v-if="totalAdjusted <= 0" class="site-progress-bar w-100 grid-import">
<span>{{ fmtKw(0, false, true) }}</span>
</div>
</div>
<div class="label-scale d-flex">

View file

@ -15,7 +15,7 @@
<LoadpointSettingsButton :id="id" class="d-block d-sm-none" />
</div>
<div class="mb-3 d-flex align-items-center">
<Mode class="flex-grow-1" :mode="mode" @updated="setTargetMode" />
<Mode class="flex-grow-1" v-bind="modeProps" @updated="setTargetMode" />
<LoadpointSettingsButton :id="id" class="d-none d-sm-block ms-2" />
</div>
</div>
@ -98,6 +98,7 @@ import LoadpointSettingsButton from "./LoadpointSettingsButton.vue";
import LoadpointSettingsModal from "./LoadpointSettingsModal.vue";
import VehicleIcon from "./VehicleIcon";
import LoadpointSessionInfo from "./LoadpointSessionInfo.vue";
import smartCostAvailable from "../utils/smartCostAvailable";
export default {
name: "Loadpoint",
@ -189,6 +190,8 @@ export default {
tariffCo2: Number,
currency: String,
multipleLoadpoints: Boolean,
gridConfigured: Boolean,
pvConfigured: Boolean,
},
data() {
return {
@ -218,6 +221,9 @@ export default {
phasesProps: function () {
return this.collectProps(Phases);
},
modeProps: function () {
return this.collectProps(Mode);
},
sessionInfoProps: function () {
return this.collectProps(LoadpointSessionInfo);
},
@ -251,6 +257,12 @@ export default {
socBasedPlanning: function () {
return this.socBasedCharging && this.vehicle?.capacity > 0;
},
pvPossible: function () {
return this.pvConfigured || this.gridConfigured;
},
hasSmartCost: function () {
return smartCostAvailable(this.smartCostType);
},
},
watch: {
phaseRemaining() {

View file

@ -22,6 +22,8 @@
:tariffCo2="tariffCo2"
:currency="currency"
:multiple-loadpoints="loadpoints.length > 1"
:grid-configured="gridConfigured"
:pv-configured="pvConfigured"
class="h-100"
:class="{ 'loadpoint-unselected': !selected(index) }"
@click="scrollTo(index)"
@ -67,6 +69,8 @@ export default {
tariffGrid: Number,
tariffCo2: Number,
currency: String,
gridConfigured: Boolean,
pvConfigured: Boolean,
},
data() {
return { selectedIndex: 0, snapTimeout: null };

View file

@ -1,5 +1,5 @@
<template>
<div class="mode-group border d-inline-flex" role="group">
<div class="mode-group border d-inline-flex" role="group" data-testid="mode">
<button
v-for="m in modes"
:key="m"
@ -8,7 +8,7 @@
:class="{ active: isActive(m) }"
@click="setTargetMode(m)"
>
{{ $t(`main.mode.${m}`) }}
{{ label(m) }}
</button>
</div>
</template>
@ -18,14 +18,31 @@ export default {
name: "Mode",
props: {
mode: String,
pvPossible: Boolean,
hasSmartCost: Boolean,
},
emits: ["updated"],
data() {
return {
modes: ["off", "pv", "minpv", "now"],
};
computed: {
modes: function () {
if (this.pvPossible) {
return ["off", "pv", "minpv", "now"];
}
if (this.hasSmartCost) {
return ["off", "pv", "now"];
}
return ["off", "now"];
},
},
methods: {
label: function (mode) {
// rename pv mode to smart for non-pv and dynamic tariffs scenarios
// TODO: rollout smart name for everyting later
if (mode === "pv" && !this.pvPossible && this.hasSmartCost) {
return this.$t("main.mode.smart");
}
return this.$t(`main.mode.${mode}`);
},
isActive: function (mode) {
return this.mode === mode;
},

View file

@ -29,6 +29,8 @@
:tariffGrid="tariffGrid"
:tariffCo2="tariffCo2"
:currency="currency"
:gridConfigured="gridConfigured"
:pvConfigured="pvConfigured"
/>
<Footer v-bind="footer"></Footer>
</div>

View file

@ -17,7 +17,6 @@
</p>
<p class="mb-1"><strong>Missing features</strong></p>
<ul>
<li>grid meter</li>
<li>aux meters</li>
<li>loadpoints and chargers</li>
<li>custom/plugin meters and vehicles</li>
@ -41,16 +40,22 @@
<ul class="p-0 config-list">
<DeviceCard
:name="$t('config.grid.title')"
:editable="!!gridMeter?.id"
:editable="!gridMeter || !!gridMeter.id"
:error="deviceError('meter', gridMeter?.name)"
data-testid="grid"
@edit="editMeter(gridMeter.id, 'grid')"
@edit="gridMeter?.id ? editMeter(gridMeter.id, 'pv') : addMeter('grid')"
>
<template #icon>
<shopicon-regular-powersupply></shopicon-regular-powersupply>
</template>
<template #tags>
<DeviceTags :tags="deviceTags('meter', gridMeter?.name)" />
<DeviceTags
:tags="
gridMeter
? deviceTags('meter', gridMeter.name)
: { configured: { value: false } }
"
/>
</template>
</DeviceCard>
<DeviceCard
@ -547,7 +552,7 @@ export default {
removeMeterFromSite(type, name) {
if (type === "grid") {
this.site.grid = "";
} else {
} else if (this.site[type]) {
this.site[type] = this.site[type].filter((i) => i !== name);
}
this.saveSite(type);

View file

@ -2,7 +2,6 @@ package core
import (
"context"
"errors"
"fmt"
"math"
"strings"
@ -215,11 +214,6 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.auxMeters = append(site.auxMeters, dev.Instance())
}
// configure meter from references
if site.gridMeter == nil && len(site.pvMeters) == 0 {
return errors.New("missing either grid or pv meter")
}
// revert battery mode on shutdown
shutdown.Register(func() {
if mode := site.GetBatteryMode(); batteryModeModified(mode) {

View file

@ -451,6 +451,7 @@ minpv = "Min+PV"
now = "Schnell"
off = "Aus"
pv = "PV"
smart = "Smart"
[main.provider]
login = "anmelden"

View file

@ -450,6 +450,7 @@ minpv = "Min+Solar"
now = "Fast"
off = "Off"
pv = "Solar"
smart = "Smart"
[main.provider]
login = "log in"

View file

@ -2,12 +2,12 @@ import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { startSimulator, stopSimulator, simulatorUrl, simulatorHost } from "./simulator";
const CONFIG_EMPTY = "config-empty.evcc.yaml";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
await start(CONFIG_EMPTY, "password.sql");
await start(CONFIG_GRID_ONLY, "password.sql");
await startSimulator();
});
test.afterAll(async () => {
@ -29,7 +29,7 @@ async function enableExperimental(page) {
await page.getByRole("button", { name: "Close" }).click();
}
test.describe("meters", async () => {
test.describe("battery meter", async () => {
test("create, edit and remove battery meter", async ({ page }) => {
// setup test data for mock openems api
await page.goto(simulatorUrl());
@ -71,7 +71,7 @@ test.describe("meters", async () => {
await expect(battery.getByTestId("device-tag-capacity")).toContainText("20.0 kWh");
// restart and check in main ui
await restart(CONFIG_EMPTY);
await restart(CONFIG_GRID_ONLY);
await page.goto("/");
await page.getByTestId("visualization").click();
await expect(page.getByTestId("energyflow")).toContainText("Battery charging75%2.5 kW");

View file

@ -1,15 +1,5 @@
site:
title: Hello World
meters:
grid: grid
meters:
- name: grid
type: custom
power:
source: js
script: |
1000
loadpoints:
- title: Carport

View file

@ -0,0 +1,34 @@
site:
title: Hello World
meters:
grid: grid
meters:
- name: grid
type: custom
power:
source: js
script: |
1000
loadpoints:
- title: Carport
charger: charger
chargers:
- name: charger
type: custom
enable:
source: js
script:
enabled:
source: js
script: |
false
status:
source: js
script: |
"B"
maxcurrent:
source: js
script:

84
tests/config-grid.spec.js Normal file
View file

@ -0,0 +1,84 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { startSimulator, stopSimulator, simulatorUrl, simulatorHost } from "./simulator";
const CONFIG_EMPTY = "config-empty.evcc.yaml";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
await start(CONFIG_EMPTY, "password.sql");
await startSimulator();
});
test.afterAll(async () => {
await stop();
await stopSimulator();
});
async function login(page) {
await page.locator("#loginPassword").fill("secret");
await page.getByRole("button", { name: "Login" }).click();
}
async function enableExperimental(page) {
await page
.getByTestId("generalconfig-experimental")
.getByRole("button", { name: "edit" })
.click();
await page.getByLabel("Experimental 🧪").click();
await page.getByRole("button", { name: "Close" }).click();
}
test.describe("main screen", async () => {
test("modes", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("button", { name: "Off" })).toBeVisible();
await expect(page.getByRole("button", { name: "Fast" })).toBeVisible();
});
});
test.describe("grid meter", async () => {
test("create, edit and remove grid meter", async ({ page }) => {
// setup test data for mock openems api
await page.goto(simulatorUrl());
await page.getByLabel("Grid Power").fill("5000");
await page.getByRole("button", { name: "Apply changes" }).click();
await page.goto("/#/config");
await login(page);
await enableExperimental(page);
await expect(page.getByTestId("grid")).toHaveCount(1);
await expect(page.getByTestId("grid").getByTestId("device-tag-configured")).toContainText("no");
// create #1
await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
const meterModal = page.getByTestId("meter-modal");
await meterModal.getByLabel("Manufacturer").selectOption("OpenEMS");
await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible();
await meterModal.getByRole("link", { name: "validate" }).click();
await expect(meterModal.getByTestId("device-tag-power")).toContainText("5.0 kW");
await meterModal.getByRole("button", { name: "Save" }).click();
await expect(meterModal).not.toBeVisible();
// restart
await restart(CONFIG_EMPTY);
await expect(page.getByTestId("grid").getByTestId("device-tag-power")).toContainText("5.0 kW");
// check in main ui
await page.goto("/");
await page.getByTestId("visualization").click();
await expect(page.getByTestId("energyflow")).toContainText(["Grid use", "5.0 kW"].join(""));
// delete #1
await page.goto("/#/config");
await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
await meterModal.getByRole("button", { name: "Delete" }).click();
await expect(meterModal).not.toBeVisible();
await expect(page.getByTestId("grid")).toHaveCount(1);
await expect(page.getByTestId("grid").getByTestId("device-tag-configured")).toContainText("no");
});
});

View file

@ -1,7 +1,7 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
const CONFIG = "config-empty.evcc.yaml";
const CONFIG = "config-grid-only.evcc.yaml";
test.use({ baseURL: baseUrl() });

View file

@ -1,7 +1,7 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
const CONFIG_EMPTY = "config-empty.evcc.yaml";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
const CONFIG_WITH_TARIFFS = "config-with-tariffs.evcc.yaml";
test.use({ baseURL: baseUrl() });
@ -34,7 +34,7 @@ async function goToConfig(page) {
test.describe("tariffs", async () => {
test("tariffs not configured", async ({ page }) => {
await start(CONFIG_EMPTY, "password.sql");
await start(CONFIG_GRID_ONLY, "password.sql");
await goToConfig(page);
await expect(page.getByTestId("tariffs")).toBeVisible();
@ -44,7 +44,7 @@ test.describe("tariffs", async () => {
});
test("tariffs via ui", async ({ page }) => {
await start(CONFIG_EMPTY, "password.sql");
await start(CONFIG_GRID_ONLY, "password.sql");
await goToConfig(page);
await page.getByTestId("tariffs").getByRole("button", { name: "edit" }).click();
@ -85,7 +85,7 @@ test.describe("tariffs", async () => {
.getByRole("button", { name: "Restart" });
await expect(restartButton).toBeVisible();
await restart(CONFIG_EMPTY);
await restart(CONFIG_GRID_ONLY);
// restart done
await expect(restartButton).not.toBeVisible();

View file

@ -1,13 +1,13 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, cleanRestart, baseUrl } from "./evcc";
const CONFIG_EMPTY = "config-empty.evcc.yaml";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
const CONFIG_WITH_VEHICLE = "config-with-vehicle.evcc.yaml";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
await start(CONFIG_EMPTY, "password.sql");
await start(CONFIG_GRID_ONLY, "password.sql");
});
test.afterAll(async () => {
await stop();
@ -101,7 +101,7 @@ test.describe("vehicles", async () => {
await expect(page.getByTestId("vehicle")).toHaveCount(2);
// restart evcc
await restart(CONFIG_EMPTY);
await restart(CONFIG_GRID_ONLY);
await page.reload();
await expect(page.getByTestId("vehicle")).toHaveCount(2);

View file

@ -1,12 +1,12 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
const CONFIG_EMPTY = "config-empty.evcc.yaml";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
await start(CONFIG_EMPTY, "password.sql");
await start(CONFIG_GRID_ONLY, "password.sql");
});
test.afterAll(async () => {
await stop();

44
tests/smart-cost-only.evcc.yaml Executable file
View file

@ -0,0 +1,44 @@
interval: 0.1s
site:
title: Smart Cost, No Grid & PV
loadpoints:
- title: Loadpoint
charger: charger
meter: meter
meters:
- name: meter
type: custom
power:
source: js
script: |
11000
chargers:
- name: charger
type: custom
enable:
source: js
script:
enabled:
source: js
script: |
true
status:
source: js
script: |
"C"
maxcurrent:
source: js
script:
tariffs:
currency: EUR
grid:
type: fixed
price: 0.4 # EUR/kWh
zones:
- hours: 1-6
price: 0.2

View file

@ -0,0 +1,31 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
await start("smart-cost-only.evcc.yaml", "password.sql");
});
test.afterAll(async () => {
await stop();
});
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test.describe("main screen", async () => {
test("smart mode", async ({ page }) => {
await expect(page.getByRole("button", { name: "Off" })).toBeVisible();
await expect(page.getByRole("button", { name: "Smart" })).toBeVisible();
await expect(page.getByRole("button", { name: "Fast" })).toBeVisible();
});
test("no production and feedin", async ({ page }) => {
await page.getByTestId("energyflow").click();
await expect(page.getByTestId("energyflow-entry-gridimport")).toBeVisible();
await expect(page.getByTestId("energyflow-entry-home")).not.toBeVisible();
await expect(page.getByTestId("energyflow-entry-loadpoints")).toBeVisible();
await expect(page.getByTestId("energyflow-entry-gridexport")).not.toBeVisible();
});
});