diff --git a/Makefile b/Makefile
index a11220f93..b6d421b72 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: default all clean install install-ui ui assets lint lint-ui test build test-release release
+.PHONY: default all clean install install-ui ui assets lint test-ui lint-ui test build test-release release
.PHONY: docker publish-testing publish-latest publish-images
.PHONY: prepare-image image-rootfs image-update
.PHONY: soc stamps
@@ -24,7 +24,7 @@ IMAGE_OPTIONS := -hostname evcc -http_port 8080 github.com/gokrazy/serial-busybo
default: build
-all: clean install install-ui ui assets lint lint-ui test build
+all: clean install install-ui ui assets lint test-ui lint-ui test build
clean:
rm -rf dist/
@@ -47,6 +47,9 @@ lint:
lint-ui:
npm run lint
+test-ui:
+ npm run test
+
test:
@echo "Running testsuite"
go test ./...
diff --git a/assets/css/app.css b/assets/css/app.css
index 6fdde5d8e..df0fbee7b 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -1,29 +1,23 @@
+:root {
+ --evcc-green: #66d85a;
+ --evcc-dark-green: #3aba2c;
+ --evcc-yellow: #ffe000;
+
+ --evcc-grid: var(--bs-gray-dark);
+ --evcc-self: var(--evcc-dark-green);
+ --evcc-export: var(--evcc-yellow);
+
+ --bs-primary: var(--evcc-dark-green);
+}
+
+.bg-primary {
+ background-color: var(--evcc-dark-green) !important;
+}
+
/* reverse loading animation */
.progress-bar-animated {
animation-direction: reverse;
}
-.btn.caption {
- opacity: 1;
-}
-.btn.first {
- border-top-left-radius: 0.2rem;
- border-bottom-left-radius: 0.2rem;
-}
-code {
- font-size: 87.5%;
- color: #e83e8c;
- word-wrap: break-word;
-}
-.value,
-input[type="radio"],
-label.btn {
- white-space: nowrap !important;
-}
-.text-muted a,
-.text-muted a:hover {
- color: #6c757d !important;
- text-decoration: none;
-}
.bg-muted {
opacity: 0.25;
}
diff --git a/assets/js/app.js b/assets/js/app.js
index b3013f5d7..d2098e4ad 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -1,13 +1,16 @@
-import "../css/app.css";
import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap";
+import "../css/app.css";
import Vue from "vue";
+import VueMeta from "vue-meta";
import axios from "axios";
import App from "./views/App";
import router from "./router";
import i18n from "./i18n";
import store from "./store";
+Vue.use(VueMeta);
+
const loc = window.location;
axios.defaults.baseURL =
loc.protocol + "//" + loc.hostname + (loc.port ? ":" + loc.port : "") + loc.pathname + "api";
diff --git a/assets/js/components/Energyflow/BatteryIcon.vue b/assets/js/components/Energyflow/BatteryIcon.vue
new file mode 100644
index 000000000..ca7fa5c53
--- /dev/null
+++ b/assets/js/components/Energyflow/BatteryIcon.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
diff --git a/assets/js/components/Energyflow/Energyflow.spec.js b/assets/js/components/Energyflow/Energyflow.spec.js
new file mode 100644
index 000000000..54983d952
--- /dev/null
+++ b/assets/js/components/Energyflow/Energyflow.spec.js
@@ -0,0 +1,162 @@
+/* globals describe, it, expect */
+import { shallowMount } from "@vue/test-utils";
+import Energyflow from "./Energyflow.vue";
+
+describe("Energyflow.vue", () => {
+ const defaultProps = {
+ gridConfigured: true,
+ gridPower: 0,
+ pvConfigured: true,
+ pvPower: 0,
+ batteryConfigured: false,
+ batteryPower: 0,
+ batterySoC: 0,
+ };
+
+ it("using pv and grid power", async () => {
+ const wrapper = shallowMount(Energyflow, {
+ mocks: { $t: (x) => x },
+ propsData: { ...defaultProps, gridPower: 1000, pvPower: 4000 },
+ });
+ await wrapper.find(".energyflow").trigger("click");
+
+ expect(wrapper.find("[data-test-grid-import]").text()).toMatch("1.0 kW");
+ expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("4.0 kW");
+ expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");
+
+ expect(wrapper.find("[data-test-house-consumption]").text()).toMatch("5.0 kW");
+ expect(wrapper.find("[data-test-pv-production]").text()).toMatch("4.0 kW");
+ expect(wrapper.find("[data-test-battery]").exists()).toBe(false);
+ });
+
+ it("exporting all pv power, no usage", async () => {
+ const wrapper = shallowMount(Energyflow, {
+ mocks: { $t: (x) => x },
+ propsData: { ...defaultProps, gridPower: -4000, pvPower: 4000 },
+ });
+
+ await wrapper.find(".energyflow").trigger("click");
+
+ expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.0 kW");
+ expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0.0 kW");
+ expect(wrapper.find("[data-test-pv-export]").text()).toMatch("4.0 kW");
+
+ expect(wrapper.find("[data-test-house-consumption]").text()).toMatch("0.0 kW");
+ expect(wrapper.find("[data-test-pv-production]").text()).toMatch("4.0 kW");
+ expect(wrapper.find("[data-test-battery]").exists()).toBe(false);
+ });
+
+ it("more grid export than pv, grid value wins (invalid state)", async () => {
+ const wrapper = shallowMount(Energyflow, {
+ mocks: { $t: (x) => x },
+ propsData: { ...defaultProps, gridPower: -4000, pvPower: 3000 },
+ });
+
+ await wrapper.find(".energyflow").trigger("click");
+
+ expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.0 kW");
+ expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0.0 kW");
+ expect(wrapper.find("[data-test-pv-export]").text()).toMatch("4.0 kW");
+
+ expect(wrapper.find("[data-test-house-consumption]").text()).toMatch("0.0 kW");
+ expect(wrapper.find("[data-test-pv-production]").text()).toMatch("3.0 kW");
+ expect(wrapper.find("[data-test-battery]").exists()).toBe(false);
+ });
+
+ it("only grid usage, no pv, idleBattery", async () => {
+ const wrapper = shallowMount(Energyflow, {
+ mocks: { $t: (x) => x },
+ propsData: {
+ ...defaultProps,
+ gridPower: 360,
+ pvPower: 0,
+ batteryConfigured: true,
+ batteryPower: 0,
+ },
+ });
+
+ await wrapper.find(".energyflow").trigger("click");
+
+ expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.4 kW");
+ expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0.0 kW");
+ expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");
+
+ expect(wrapper.find("[data-test-house-consumption]").text()).toMatch("0.4 kW");
+ expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0.0 kW");
+ expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.battery");
+ });
+
+ it("grid and battery usage, no pv", async () => {
+ const wrapper = shallowMount(Energyflow, {
+ mocks: { $t: (x) => x },
+ propsData: {
+ ...defaultProps,
+ gridPower: 300,
+ batteryConfigured: true,
+ batteryPower: 200,
+ batterySoC: 77,
+ pvPower: 0,
+ },
+ });
+
+ await wrapper.find(".energyflow").trigger("click");
+
+ expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.3 kW");
+ expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("0.2 kW");
+ expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");
+
+ expect(wrapper.find("[data-test-house-consumption]").text()).toMatch("0.5 kW");
+ expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0.0 kW");
+ expect(wrapper.find("[data-test-battery]").text()).toMatch("0.2 kW");
+ expect(wrapper.find("[data-test-battery]").text()).toMatch("77%");
+ expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.batteryDischarge");
+ });
+
+ it("battery charge, pv export", async () => {
+ const wrapper = shallowMount(Energyflow, {
+ mocks: { $t: (x) => x },
+ propsData: {
+ ...defaultProps,
+ gridPower: -2500,
+ batteryConfigured: true,
+ batteryPower: -1700,
+ pvPower: 9000,
+ },
+ });
+
+ await wrapper.find(".energyflow").trigger("click");
+
+ expect(wrapper.find("[data-test-grid-import]").text()).toMatch("0.0 kW");
+ expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("6.5 kW");
+ expect(wrapper.find("[data-test-pv-export]").text()).toMatch("2.5 kW");
+
+ expect(wrapper.find("[data-test-house-consumption]").text()).toMatch("4.8 kW");
+ expect(wrapper.find("[data-test-pv-production]").text()).toMatch("9.0 kW");
+ expect(wrapper.find("[data-test-battery]").text()).toMatch("1.7 kW");
+ expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.batteryCharge");
+ });
+
+ it("thresholds", async () => {
+ const wrapper = shallowMount(Energyflow, {
+ mocks: { $t: (x) => x },
+ propsData: {
+ ...defaultProps,
+ gridPower: 5555,
+ batteryConfigured: true,
+ batteryPower: 1234,
+ pvPower: 378,
+ },
+ });
+
+ await wrapper.find(".energyflow").trigger("click");
+
+ expect(wrapper.find("[data-test-grid-import]").text()).toMatch("5.6 kW");
+ expect(wrapper.find("[data-test-self-consumption]").text()).toMatch("1.6 kW");
+ expect(wrapper.find("[data-test-pv-export]").text()).toMatch("0.0 kW");
+
+ expect(wrapper.find("[data-test-house-consumption]").text()).toMatch("7.2 kW");
+ expect(wrapper.find("[data-test-pv-production]").text()).toMatch("0.4 kW");
+ expect(wrapper.find("[data-test-battery]").text()).toMatch("1.2 kW");
+ expect(wrapper.find("[data-test-battery]").text()).toMatch("main.energyflow.batteryDischarge");
+ });
+});
diff --git a/assets/js/components/Energyflow/Energyflow.stories.js b/assets/js/components/Energyflow/Energyflow.stories.js
new file mode 100644
index 000000000..f133aef91
--- /dev/null
+++ b/assets/js/components/Energyflow/Energyflow.stories.js
@@ -0,0 +1,102 @@
+import Energyflow from "./";
+import i18n from "../../i18n";
+
+export default {
+ title: "Main/Energyflow",
+ component: Energyflow,
+ argTypes: {
+ gridPower: { control: { type: "range", min: -5000, max: 20000, step: 100 } },
+ pvPower: { control: { type: "range", min: 0, max: 10000, step: 100 } },
+ batteryPower: { control: { type: "range", min: -4000, max: 4000, step: 100 } },
+ batterySoC: { control: { type: "range", min: 0, max: 100, step: 1 } },
+ },
+};
+
+const Template = (args, { argTypes }) => ({
+ i18n,
+ props: Object.keys(argTypes),
+ components: { Energyflow },
+ template: '',
+});
+
+export const GridAndPV = Template.bind({});
+GridAndPV.args = {
+ gridConfigured: true,
+ pvConfigured: true,
+ gridPower: -2300,
+ pvPower: 7320,
+ batteryConfigured: false,
+ siteTitle: "Home",
+};
+
+export const BatteryAndGrid = Template.bind({});
+BatteryAndGrid.args = {
+ gridConfigured: true,
+ pvConfigured: true,
+ batteryConfigured: true,
+ gridPower: 1200,
+ pvPower: 0,
+ batteryPower: 800,
+ batterySoC: 77,
+ siteTitle: "Home",
+};
+
+export const BatteryCharging = Template.bind({});
+BatteryCharging.args = {
+ gridConfigured: true,
+ pvConfigured: true,
+ batteryConfigured: true,
+ gridPower: -1300,
+ pvPower: 5000,
+ batteryPower: -1500,
+ batterySoC: 75,
+ siteTitle: "Home",
+};
+
+export const GridPvAndBattery = Template.bind({});
+GridPvAndBattery.args = {
+ gridConfigured: true,
+ pvConfigured: true,
+ batteryConfigured: true,
+ gridPower: 700,
+ pvPower: 1000,
+ batteryPower: 1500,
+ batterySoC: 30,
+ siteTitle: "Home",
+};
+
+export const BatteryThresholds = Template.bind({});
+BatteryThresholds.args = {
+ gridConfigured: true,
+ pvConfigured: true,
+ batteryConfigured: true,
+ gridPower: -510,
+ pvPower: 8740,
+ batteryPower: -100,
+ batterySoC: 95,
+ siteTitle: "Home",
+};
+
+export const PvThresholds = Template.bind({});
+PvThresholds.args = {
+ gridConfigured: true,
+ pvConfigured: true,
+ batteryConfigured: true,
+ batteryPower: 800,
+ gridPower: 5555,
+ pvPower: 300,
+ batterySoC: 76,
+ siteTitle: "Home",
+};
+
+export const GridOnly = Template.bind({});
+GridOnly.args = {
+ gridConfigured: true,
+ pvConfigured: false,
+ batteryConfigured: false,
+ gridPower: -6230,
+ pvPower: 0,
+ batteryPower: 0,
+ batterySoC: 0,
+ siteTitle: "Home",
+};
diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue
new file mode 100644
index 000000000..61888d82a
--- /dev/null
+++ b/assets/js/components/Energyflow/Energyflow.vue
@@ -0,0 +1,202 @@
+
+
+
+ {{ siteTitle }}
+
+
+
+
+
+
+ {{
+ $t("main.energyflow.pvProduction")
+ }}
+ {{ kw(pvProduction) }}
+
+
+
+ {{
+ $t("main.energyflow.houseConsumption")
+ }}
+ {{ kw(houseConsumption) }}
+
+
+
+
+
+
+ {{ $t("main.energyflow.batteryCharge") }}
+ {{
+ $t("main.energyflow.batteryDischarge")
+ }}
+ {{ $t("main.energyflow.battery") }}
+
+
+ {{ batterySoC }}% /
+ {{ kw(Math.abs(batteryPower)) }}
+
+
+
+
+
+
+ {{
+ $t("main.energyflow.gridImport")
+ }}
+ {{ kw(gridImport) }}
+
+
+
+ {{
+ $t("main.energyflow.selfConsumption")
+ }}
+ {{ kw(selfConsumption) }}
+
+
+
+ {{
+ $t("main.energyflow.pvExport")
+ }}
+ {{ kw(pvExport) }}
+
+
+
+
+
+
+
+
diff --git a/assets/js/components/Energyflow/Visualization.vue b/assets/js/components/Energyflow/Visualization.vue
new file mode 100644
index 000000000..534e51903
--- /dev/null
+++ b/assets/js/components/Energyflow/Visualization.vue
@@ -0,0 +1,277 @@
+
+
+
+
+
+
+ {{ kw(gridImport) }}
+
+
+
+
+ {{ kw(selfConsumption) }}
+
+
+
+
+ {{ kw(pvExport) }}
+
+
+
+ {{ $t("main.energyflow.noEnergy") }}
+
+
+
+
+
+
+
+
diff --git a/assets/js/components/Energyflow/index.js b/assets/js/components/Energyflow/index.js
new file mode 100644
index 000000000..057959fd0
--- /dev/null
+++ b/assets/js/components/Energyflow/index.js
@@ -0,0 +1,3 @@
+import Energyflow from "./Energyflow.vue";
+
+export default Energyflow;
diff --git a/assets/js/components/Mode.vue b/assets/js/components/Mode.vue
index f747efcbe..21108a8e0 100644
--- a/assets/js/components/Mode.vue
+++ b/assets/js/components/Mode.vue
@@ -4,7 +4,7 @@