From 280976cb7e6a52b8a9aeeede245c61b47177db43 Mon Sep 17 00:00:00 2001
From: Michael Geers
Date: Sat, 22 Jun 2024 10:21:57 +0200
Subject: [PATCH] Non-solar use cases: make grid meter optional (#14341)
---
.../js/components/Energyflow/Energyflow.vue | 14 +++-
.../components/Energyflow/Visualization.vue | 7 +-
assets/js/components/Loadpoint.vue | 14 +++-
assets/js/components/Loadpoints.vue | 4 +
assets/js/components/Mode.vue | 29 +++++--
assets/js/components/Site.vue | 2 +
assets/js/views/Config.vue | 15 ++--
core/site.go | 6 --
i18n/de.toml | 1 +
i18n/en.toml | 1 +
...-meters.spec.js => config-battery.spec.js} | 8 +-
tests/config-empty.evcc.yaml | 10 ---
tests/config-grid-only.evcc.yaml | 34 ++++++++
tests/config-grid.spec.js | 84 +++++++++++++++++++
tests/config-mqtt.spec.js | 2 +-
tests/config-tariffs.spec.js | 8 +-
tests/config-vehicles.spec.js | 6 +-
tests/config.spec.js | 4 +-
tests/smart-cost-only.evcc.yaml | 44 ++++++++++
tests/smart-cost-only.spec.js | 31 +++++++
20 files changed, 275 insertions(+), 49 deletions(-)
rename tests/{config-meters.spec.js => config-battery.spec.js} (94%)
create mode 100755 tests/config-grid-only.evcc.yaml
create mode 100644 tests/config-grid.spec.js
create mode 100755 tests/smart-cost-only.evcc.yaml
create mode 100644 tests/smart-cost-only.spec.js
diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue
index 5d7ef22cf..0f91750de 100644
--- a/assets/js/components/Energyflow/Energyflow.vue
+++ b/assets/js/components/Energyflow/Energyflow.vue
@@ -64,17 +64,19 @@
>
@@ -293,6 +300,9 @@ export default {
co2Available() {
return this.smartCostType === CO2_TYPE;
},
+ pvPossible() {
+ return this.pvConfigured || this.gridConfigured;
+ },
},
mounted() {
window.addEventListener("resize", this.updateHeight);
diff --git a/assets/js/components/Energyflow/Visualization.vue b/assets/js/components/Energyflow/Visualization.vue
index 1622861e6..d8f644e72 100644
--- a/assets/js/components/Energyflow/Visualization.vue
+++ b/assets/js/components/Energyflow/Visualization.vue
@@ -60,11 +60,8 @@
:format="fmtBarValue"
/>
-
-
{{ $t("main.energyflow.noEnergy") }}
+
+ {{ fmtKw(0, false, true) }}
diff --git a/assets/js/components/Loadpoint.vue b/assets/js/components/Loadpoint.vue
index ab9ef1d02..53dda6178 100644
--- a/assets/js/components/Loadpoint.vue
+++ b/assets/js/components/Loadpoint.vue
@@ -15,7 +15,7 @@
-
+
@@ -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() {
diff --git a/assets/js/components/Loadpoints.vue b/assets/js/components/Loadpoints.vue
index 648f0e87b..6c19dc24e 100644
--- a/assets/js/components/Loadpoints.vue
+++ b/assets/js/components/Loadpoints.vue
@@ -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 };
diff --git a/assets/js/components/Mode.vue b/assets/js/components/Mode.vue
index 86d08e96c..0986782b4 100644
--- a/assets/js/components/Mode.vue
+++ b/assets/js/components/Mode.vue
@@ -1,5 +1,5 @@
-
+
@@ -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;
},
diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue
index 5e6d27dd7..267a6b4aa 100644
--- a/assets/js/components/Site.vue
+++ b/assets/js/components/Site.vue
@@ -29,6 +29,8 @@
:tariffGrid="tariffGrid"
:tariffCo2="tariffCo2"
:currency="currency"
+ :gridConfigured="gridConfigured"
+ :pvConfigured="pvConfigured"
/>
diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue
index 1306c85ea..c33d1b706 100644
--- a/assets/js/views/Config.vue
+++ b/assets/js/views/Config.vue
@@ -17,7 +17,6 @@
Missing features
- - grid meter
- aux meters
- loadpoints and chargers
- custom/plugin meters and vehicles
@@ -41,16 +40,22 @@
-
+
i !== name);
}
this.saveSite(type);
diff --git a/core/site.go b/core/site.go
index bad0f9210..a740ea7e3 100644
--- a/core/site.go
+++ b/core/site.go
@@ -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) {
diff --git a/i18n/de.toml b/i18n/de.toml
index 974c05acd..3e3ca8ba2 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -451,6 +451,7 @@ minpv = "Min+PV"
now = "Schnell"
off = "Aus"
pv = "PV"
+smart = "Smart"
[main.provider]
login = "anmelden"
diff --git a/i18n/en.toml b/i18n/en.toml
index ad0a78108..01f38c7a1 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -450,6 +450,7 @@ minpv = "Min+Solar"
now = "Fast"
off = "Off"
pv = "Solar"
+smart = "Smart"
[main.provider]
login = "log in"
diff --git a/tests/config-meters.spec.js b/tests/config-battery.spec.js
similarity index 94%
rename from tests/config-meters.spec.js
rename to tests/config-battery.spec.js
index 24d3b1701..54c6492cd 100644
--- a/tests/config-meters.spec.js
+++ b/tests/config-battery.spec.js
@@ -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");
diff --git a/tests/config-empty.evcc.yaml b/tests/config-empty.evcc.yaml
index 99832e635..c7b0b6519 100755
--- a/tests/config-empty.evcc.yaml
+++ b/tests/config-empty.evcc.yaml
@@ -1,15 +1,5 @@
site:
title: Hello World
- meters:
- grid: grid
-
-meters:
- - name: grid
- type: custom
- power:
- source: js
- script: |
- 1000
loadpoints:
- title: Carport
diff --git a/tests/config-grid-only.evcc.yaml b/tests/config-grid-only.evcc.yaml
new file mode 100755
index 000000000..99832e635
--- /dev/null
+++ b/tests/config-grid-only.evcc.yaml
@@ -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:
diff --git a/tests/config-grid.spec.js b/tests/config-grid.spec.js
new file mode 100644
index 000000000..d4d97b273
--- /dev/null
+++ b/tests/config-grid.spec.js
@@ -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");
+ });
+});
diff --git a/tests/config-mqtt.spec.js b/tests/config-mqtt.spec.js
index 9d253f49e..b75755438 100644
--- a/tests/config-mqtt.spec.js
+++ b/tests/config-mqtt.spec.js
@@ -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() });
diff --git a/tests/config-tariffs.spec.js b/tests/config-tariffs.spec.js
index 853c5f98e..9583fe2e1 100644
--- a/tests/config-tariffs.spec.js
+++ b/tests/config-tariffs.spec.js
@@ -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();
diff --git a/tests/config-vehicles.spec.js b/tests/config-vehicles.spec.js
index c35821e0f..02133c8fd 100644
--- a/tests/config-vehicles.spec.js
+++ b/tests/config-vehicles.spec.js
@@ -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);
diff --git a/tests/config.spec.js b/tests/config.spec.js
index ba326787f..4c7ed54a3 100644
--- a/tests/config.spec.js
+++ b/tests/config.spec.js
@@ -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();
diff --git a/tests/smart-cost-only.evcc.yaml b/tests/smart-cost-only.evcc.yaml
new file mode 100755
index 000000000..10677a225
--- /dev/null
+++ b/tests/smart-cost-only.evcc.yaml
@@ -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
diff --git a/tests/smart-cost-only.spec.js b/tests/smart-cost-only.spec.js
new file mode 100644
index 000000000..bb890f70c
--- /dev/null
+++ b/tests/smart-cost-only.spec.js
@@ -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();
+ });
+});