Visualisation of Site Energy Flow (#1148)

added energy flow visualisation
This commit is contained in:
Michael Geers 2021-08-04 14:53:51 +02:00 • committed by GitHub
parent 383d450637
commit bb910a5cd5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 6906 additions and 319 deletions

View file

@ -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 ./...

View file

@ -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;
}

View file

@ -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";

View file

@ -0,0 +1,58 @@
<template>
<div :class="{ 'power--in': charge, 'power--out': discharge }">
<fa-icon class="battery" :icon="batteryIcon"></fa-icon
><fa-icon class="arrow" icon="angle-double-right"></fa-icon>
</div>
</template>
<script>
import "../../icons";
export default {
name: "BatteryIcon",
props: {
discharge: { type: Boolean },
charge: { type: Boolean },
soc: { type: Number, default: 0 },
},
computed: {
batteryIcon: function () {
if (this.soc > 80) return "battery-full";
if (this.soc > 60) return "battery-three-quarters";
if (this.soc > 40) return "battery-half";
if (this.soc > 20) return "battery-quarter";
return "battery-empty";
},
},
};
</script>
<style scoped>
.battery {
transform: translateX(0.35rem) rotate(-90deg);
transition-property: transform;
transition-duration: 250ms;
transition-timing-function: ease;
}
.power--in .battery {
transform: translateX(0.7rem) rotate(-90deg);
}
.power--out .battery {
transform: translateX(0) rotate(-90deg);
}
.arrow {
margin-left: -0.2rem;
opacity: 0;
transform: translateX(-0.5rem);
transition-property: opacity, transform;
transition-duration: 250ms;
transition-timing-function: ease;
}
.power--in .arrow {
opacity: 1;
transform: translateX(-1rem) scaleX(1);
}
.power--out .arrow {
opacity: 1;
transform: translateX(0rem) scaleX(1);
}
</style>

View file

@ -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");
});
});

View file

@ -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: '<Energyflow v-bind="$props"></Energyflow>',
});
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",
};

View file

@ -0,0 +1,202 @@
<template>
<div>
<h4 class="d-none d-md-block my-4" v-if="siteTitle">
{{ siteTitle }}
</h4>
<div
class="row align-items-start align-items-md-center mt-4 energyflow"
@click="toggleDetails"
>
<Visualization
class="
col-12
offset-md-1
col-md-6
offset-lg-1
col-lg-8
offset-xl-1
col-xl-6
offset-xxl-1
col-xl-8
order-md-2
"
:showDetails="showDetails"
:gridImport="gridImport"
:selfConsumption="selfConsumption"
:pvExport="pvExport"
:batteryCharge="batteryCharge"
:batteryDischarge="batteryDischarge"
:pvProduction="pvProduction"
:houseConsumption="houseConsumption"
:batterySoC="batterySoC"
/>
<div
class="col-12 col-sm-6 col-md-5 col-lg-3 col-xl-3 order-md-1 mt-2 mt-md-0"
:class="`${showDetails ? 'd-block' : `d-none d-md-block`}`"
>
<div class="d-flex justify-content-between" data-test-pv-production>
<span class="details-icon text-muted"><fa-icon icon="sun"></fa-icon></span>
<span class="text-nowrap flex-grow-1">{{
$t("main.energyflow.pvProduction")
}}</span>
<span class="text-end text-nowrap ps-1">{{ kw(pvProduction) }}</span>
</div>
<div class="d-flex justify-content-between" data-test-house-consumption>
<span class="details-icon text-muted"><fa-icon icon="home"></fa-icon></span>
<span class="text-nowrap flex-grow-1">{{
$t("main.energyflow.houseConsumption")
}}</span>
<span class="text-end text-nowrap ps-1">{{ kw(houseConsumption) }}</span>
</div>
<div
v-if="batteryConfigured"
class="d-flex justify-content-between"
data-test-battery
>
<span class="details-icon text-muted">
<BatteryIcon
:soc="batterySoC"
:charge="batteryCharge > 0"
:discharge="batteryDischarge > 0"
/>
</span>
<span class="text-nowrap flex-grow-1 text-truncate">
<span v-if="batteryCharge">{{ $t("main.energyflow.batteryCharge") }}</span>
<span v-else-if="batteryDischarge">{{
$t("main.energyflow.batteryDischarge")
}}</span>
<span v-else>{{ $t("main.energyflow.battery") }}</span>
</span>
<span class="text-end text-nowrap ps-1">
{{ batterySoC }}% /
{{ kw(Math.abs(batteryPower)) }}
</span>
</div>
</div>
<div
class="
col-12 col-sm-6
offset-md-6
col-md-6
offset-lg-4
col-lg-8
d-block d-md-flex
order-md-3
justify-content-between
mt-2
"
v-if="showDetails"
>
<div class="text-nowrap d-flex d-md-block" data-test-grid-import>
<span class="color-grid details-icon"><fa-icon icon="square"></fa-icon></span>
<span class="text-nowrap flex-grow-1">{{
$t("main.energyflow.gridImport")
}}</span>
<span class="text-end text-nowrap d-md-none">{{ kw(gridImport) }}</span>
</div>
<div
class="text-nowrap d-flex d-md-block"
data-test-self-consumption
v-if="batteryConfigured || pvConfigured"
>
<span class="color-self details-icon"><fa-icon icon="square"></fa-icon></span>
<span class="text-nowrap flex-grow-1">{{
$t("main.energyflow.selfConsumption")
}}</span>
<span class="text-end text-nowrap d-md-none">{{ kw(selfConsumption) }}</span>
</div>
<div class="text-nowrap d-flex d-md-block" data-test-pv-export>
<span class="color-export details-icon"><fa-icon icon="square"></fa-icon></span>
<span class="text-nowrap flex-grow-1">{{
$t("main.energyflow.pvExport")
}}</span>
<span class="text-end text-nowrap d-md-none">{{ kw(pvExport) }}</span>
</div>
</div>
</div>
</div>
</template>
<script>
import "../../icons";
import formatter from "../../mixins/formatter";
import Visualization from "./Visualization.vue";
import BatteryIcon from "./BatteryIcon.vue";
export default {
name: "Energyflow",
components: { Visualization, BatteryIcon },
props: {
gridConfigured: Boolean,
gridPower: { type: Number, default: 0 },
pvConfigured: Boolean,
pvPower: { type: Number, default: 0 },
batteryConfigured: Boolean,
batteryPower: { type: Number, default: 0 },
batterySoC: { type: Number, default: 0 },
siteTitle: { type: String },
},
data: function () {
return { showDetails: false };
},
mixins: [formatter],
computed: {
gridImport: function () {
return Math.max(0, this.gridPower);
},
pvProduction: function () {
return this.pvConfigured ? Math.abs(this.pvPower) : this.pvExport;
},
pvConsumption: function () {
return Math.min(
this.pvProduction,
this.pvProduction + this.gridPower - this.batteryCharge
);
},
batteryDischarge: function () {
return Math.max(0, this.batteryPower);
},
batteryCharge: function () {
return Math.min(0, this.batteryPower) * -1;
},
houseConsumption: function () {
return this.gridImport + this.pvConsumption + this.batteryDischarge;
},
selfConsumption: function () {
return this.batteryDischarge + this.pvConsumption + this.batteryCharge;
},
pvExport: function () {
return Math.min(0, this.gridPower) * -1;
},
},
methods: {
kw: function (watt) {
return Math.max(0, watt / 1000).toFixed(1) + " kW";
},
toggleDetails() {
this.showDetails = !this.showDetails;
},
},
};
</script>
<style scoped>
.energyflow {
cursor: pointer;
}
.color-grid {
color: var(--evcc-grid);
}
.color-self {
color: var(--evcc-self);
}
.color-export {
color: var(--evcc-export);
}
.details-icon {
text-align: center;
width: 30px;
margin-right: 0.25rem;
white-space: nowrap;
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,277 @@
<template>
<div>
<div class="label-scale">
<div class="d-flex justify-content-end">
<div
class="label-bar label-bar--down"
:class="{
'label-bar--hide-icon': hideLabelIcon(batteryDischarge),
'label-bar--hidden': !selfConsumptionAdjusted,
}"
:style="{ width: widthTotal(batteryDischarge) }"
>
<div class="label-bar-scale">
<div class="label-bar-icon">
<BatteryIcon :soc="batterySoC" discharge />
</div>
</div>
</div>
<div
class="label-bar label-bar--down"
:class="{
'label-bar--hide-icon': hideLabelIcon(pvProduction),
'label-bar--hidden': !selfConsumptionAdjusted && !pvExportAdjusted,
}"
:style="{ width: widthTotal(pvProduction) }"
>
<div class="label-bar-scale">
<div class="label-bar-icon">
<fa-icon icon="sun"></fa-icon>
</div>
</div>
</div>
</div>
</div>
<div class="site-progress" ref="site_progress">
<div
class="site-progress-bar grid-import"
:style="{ width: widthTotal(gridImportAdjusted) }"
>
<span class="power" :class="{ 'd-none': hidePowerLabel(gridImport) }">
{{ kw(gridImport) }}
</span>
</div>
<div
class="site-progress-bar self-consumption"
:style="{ width: widthTotal(selfConsumptionAdjusted) }"
>
<span
class="power"
:class="{
'd-none': hidePowerLabel(selfConsumption),
}"
>
{{ kw(selfConsumption) }}
</span>
</div>
<div
class="site-progress-bar pv-export"
:style="{ width: widthTotal(pvExportAdjusted) }"
>
<span class="power" :class="{ 'd-none': hidePowerLabel(pvExport) }">
{{ kw(pvExport) }}
</span>
</div>
<div class="site-progress-bar bg-light border no-wrap w-100" v-if="totalAdjusted <= 0">
<span>{{ $t("main.energyflow.noEnergy") }}</span>
</div>
</div>
<div class="label-scale">
<div class="d-flex justify-content-start">
<div
class="label-bar label-bar--up"
:class="{
'label-bar--hide-icon': hideLabelIcon(houseConsumption),
'label-bar--hidden': !gridImportAdjusted && !selfConsumptionAdjusted,
}"
:style="{ width: widthTotal(houseConsumption) }"
>
<div class="label-bar-scale">
<div class="label-bar-icon">
<fa-icon icon="home"></fa-icon>
</div>
</div>
</div>
<div
class="label-bar label-bar--up"
:class="{
'label-bar--hide-icon': hideLabelIcon(batteryCharge),
'label-bar--hidden': !selfConsumptionAdjusted,
}"
:style="{ width: widthTotal(batteryCharge) }"
>
<div class="label-bar-scale">
<div class="label-bar-icon">
<BatteryIcon :soc="batterySoC" charge />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import "../../icons";
import formatter from "../../mixins/formatter";
import BatteryIcon from "./BatteryIcon.vue";
export default {
name: "Visualization",
components: { BatteryIcon },
props: {
showDetails: Boolean,
gridImport: { type: Number, default: 0 },
selfConsumption: { type: Number, default: 0 },
pvExport: { type: Number, default: 0 },
batteryCharge: { type: Number, default: 0 },
batteryDischarge: { type: Number, default: 0 },
pvProduction: { type: Number, default: 0 },
houseConsumption: { type: Number, default: 0 },
batterySoC: { type: Number, default: 0 },
},
data: function () {
return { width: 0 };
},
mounted: function () {
this.$nextTick(function () {
window.addEventListener("resize", this.updateElementWidth);
this.updateElementWidth();
});
},
beforeDestroy() {
window.removeEventListener("resize", this.updateElementWidth);
},
mixins: [formatter],
computed: {
totalRaw: function () {
return this.gridImport + this.selfConsumption + this.pvExport;
},
gridImportAdjusted: function () {
return this.applyThreshold(this.gridImport);
},
selfConsumptionAdjusted: function () {
return this.applyThreshold(this.selfConsumption);
},
pvExportAdjusted: function () {
return this.applyThreshold(this.pvExport);
},
totalAdjusted: function () {
return this.gridImportAdjusted + this.selfConsumptionAdjusted + this.pvExportAdjusted;
},
},
watch: {
showDetails: function () {
this.$nextTick(() => this.updateElementWidth());
},
},
methods: {
widthTotal: function (power) {
if (this.totalAdjusted === 0) return "0%";
return (100 / this.totalAdjusted) * power + "%";
},
kw: function (watt) {
return Math.max(0, watt / 1000).toFixed(1) + " kW";
},
hidePowerLabel(power) {
if (this.totalAdjusted === 0) return true;
const minWidth = 75;
const percent = (100 / this.totalAdjusted) * power;
return (this.width / 100) * percent < minWidth;
},
hideLabelIcon(power) {
if (this.totalAdjusted === 0) return true;
const minWidth = 60;
const percent = (100 / this.totalAdjusted) * power;
return (this.width / 100) * percent < minWidth;
},
applyThreshold(power) {
const percent = (100 / this.totalRaw) * power;
return percent < 2 ? 0 : power;
},
updateElementWidth() {
this.width = this.$refs.site_progress.getBoundingClientRect().width;
},
},
};
</script>
<style scoped>
.site-progress {
--height: 38px;
height: var(--height);
margin: 0.25rem 0;
border-radius: 5px;
display: flex;
overflow: hidden;
}
.site-progress-bar {
display: flex;
transition-property: width;
transition-duration: 500ms;
transition-timing-function: linear;
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
width: 0;
}
.grid-import {
background-color: var(--evcc-grid);
color: var(--bs-white);
}
.self-consumption {
background-color: var(--evcc-self);
color: var(--bs-white);
}
.pv-export {
background-color: var(--evcc-export);
color: var(--bs-dark);
}
.power {
display: block;
margin: 0 0.5rem;
white-space: nowrap;
}
.label-bar {
width: 0;
margin: 0;
height: 1.7rem;
padding: 0.6rem 0;
opacity: 1;
transition-property: width, opacity;
transition-duration: 500ms, 250ms;
transition-timing-function: linear, ease;
overflow: hidden;
}
.label-bar--down:first-child {
margin-right: -1px;
}
.label-bar--up:last-child {
margin-left: -1px;
}
.label-bar-scale {
border: 1px solid var(--bs-gray);
height: 0.5rem;
background: none;
display: flex;
justify-content: center;
align-items: center;
white-space: nowrap;
}
.label-bar--down .label-bar-scale {
border-bottom: none;
}
.label-bar--up .label-bar-scale {
border-top: none;
}
.label-bar-icon {
background-color: white;
color: var(--bs-gray);
padding: 0 0.75rem;
opacity: 1;
transition: opacity 250ms ease-in;
}
.label-bar--down .label-bar-icon {
margin-top: -6px;
}
.label-bar--up .label-bar-icon {
margin-top: 6px;
}
.label-bar--hide-icon .label-bar-icon {
opacity: 0;
}
.label-bar--hidden {
opacity: 0;
}
</style>

View file

@ -0,0 +1,3 @@
import Energyflow from "./Energyflow.vue";
export default Energyflow;

View file

@ -4,7 +4,7 @@
<div class="btn-group w-100" role="group">
<button
type="button"
class="btn btn-outline-primary"
class="btn btn-outline-secondary"
:class="{ active: mode == 'off' }"
@click="setTargetMode('off')"
>
@ -12,7 +12,7 @@
</button>
<button
type="button"
class="btn btn-outline-primary"
class="btn btn-outline-secondary"
:class="{ active: mode == 'now' }"
@click="setTargetMode('now')"
>
@ -20,7 +20,7 @@
</button>
<button
type="button"
class="btn btn-outline-primary"
class="btn btn-outline-secondary"
:class="{ active: mode == 'minpv' }"
@click="setTargetMode('minpv')"
>
@ -29,7 +29,7 @@
</button>
<button
type="button"
class="btn btn-outline-primary"
class="btn btn-outline-secondary"
:class="{ active: mode == 'pv' }"
@click="setTargetMode('pv')"
>

View file

@ -18,11 +18,11 @@ export const Base = Template.bind({});
Base.args = {
gridConfigured: true,
pvConfigured: true,
gridPower: 100,
pvPower: 100,
gridPower: 1340,
pvPower: 6420,
batteryConfigured: true,
batteryPower: 100,
batterySoC: 0,
batteryPower: 800,
batterySoC: 66,
loadpoints: [],
};
@ -30,11 +30,11 @@ export const Single = Template.bind({});
Single.args = {
gridConfigured: true,
pvConfigured: true,
gridPower: 100,
pvPower: 100,
gridPower: -2300,
pvPower: 7000,
batteryConfigured: true,
batteryPower: 100,
batterySoC: 0,
batteryPower: -2000,
batterySoC: 24,
loadpoints: [
{
title: "Ladepunkt 1",
@ -59,10 +59,10 @@ export const Multi = Template.bind({});
Multi.args = {
gridConfigured: true,
pvConfigured: true,
gridPower: -100,
pvPower: 100,
batteryConfigured: true,
batteryPower: 100,
gridPower: -1000,
pvPower: 5500,
batteryConfigured: false,
batteryPower: 70,
batterySoC: 60,
loadpoints: [
{

View file

@ -1,13 +1,6 @@
<template>
<div class="flex-grow-1 d-flex flex-column">
<div class="row mt-4 pt-2">
<div class="d-none d-md-flex col-12 col-md-3 col-lg-4 align-items-end">
<p class="h1 text-truncate">{{ siteTitle || "Home" }}</p>
</div>
<div class="col-12 col-md-9 col-lg-8 flex-grow-1">
<SiteDetails v-bind="details"></SiteDetails>
</div>
</div>
<Energyflow v-bind="energyflow" />
<hr class="w-100 my-4" />
<div class="flex-grow-1 d-flex justify-content-around flex-column">
<template v-for="(loadpoint, id) in loadpoints">
@ -24,7 +17,7 @@
</template>
<script>
import SiteDetails from "./SiteDetails";
import Energyflow from "./Energyflow";
import Loadpoint from "./Loadpoint";
import formatter from "../mixins/formatter";
import collector from "../mixins/collector";
@ -32,7 +25,6 @@ import collector from "../mixins/collector";
export default {
name: "Site",
props: {
siteTitle: String,
loadpoints: Array,
// details
@ -45,12 +37,13 @@ export default {
batterySoC: Number,
gridCurrents: Array,
prioritySoC: Number,
siteTitle: String,
},
components: { SiteDetails, Loadpoint },
components: { Loadpoint, Energyflow },
mixins: [formatter, collector],
computed: {
details: function () {
return this.collectProps(SiteDetails);
energyflow: function () {
return this.collectProps(Energyflow);
},
},
};

View file

@ -1,34 +0,0 @@
import SiteDetails from "./SiteDetails.vue";
import i18n from "../i18n";
export default {
title: "Main/SiteDetails",
component: SiteDetails,
argTypes: {},
};
const Template = (args, { argTypes }) => ({
i18n,
props: Object.keys(argTypes),
components: { SiteDetails },
template: '<SiteDetails v-bind="$props"></SiteDetails>',
});
export const GridAndPV = Template.bind({});
GridAndPV.args = {
gridConfigured: true,
pvConfigured: true,
gridPower: 100,
pvPower: 100,
};
export const WithBattery = Template.bind({});
WithBattery.args = {
gridConfigured: true,
pvConfigured: true,
batteryConfigured: true,
gridPower: 100,
pvPower: 100,
batteryPower: 100,
batterySoC: 0,
};

View file

@ -1,126 +0,0 @@
<template>
<div class="row justify-content-md-start overflow-hidden" :class="`row-cols-${numberOfPanels}`">
<div class="px-3" :class="panelClass" v-if="gridConfigured">
<div class="mb-2 value" v-if="gridPower > 0">
{{ $t("main.siteDetails.import") }}
<fa-icon icon="arrow-down" class="text-primary" />
</div>
<div class="mb-2 value" v-else>
{{ $t("main.siteDetails.export") }}
<fa-icon icon="arrow-up" class="text-primary"></fa-icon>
</div>
<h3 class="value">
{{ fmt(gridPower) }}
<small class="text-muted">{{ fmtUnit(gridPower) }}W</small>
</h3>
</div>
<div class="px-3" :class="panelClass" v-if="pvConfigured">
<div class="mb-2 value">
{{ $t("main.siteDetails.production") }}
<fa-icon
icon="sun"
:class="{
'text-primary': pvPower > 0,
'text-muted': pvPower <= 0,
}"
></fa-icon>
</div>
<h3 class="value">
{{ fmt(pvPower) }}
<small class="text-muted">{{ fmtUnit(pvPower) }}W</small>
</h3>
</div>
<div class="px-3" :class="panelClass" v-if="batteryConfigured">
<div class="mb-2 value">
<div class="d-block d-sm-none">
{{ $t("main.siteDetails.batteryShort") }}
<span class="text-muted"> / {{ batterySoC }} %</span>
</div>
<div class="d-none d-sm-block">
{{ $t("main.siteDetails.batteryLong") }}
<span class="text-muted"> / {{ batterySoC }}% </span>
<fa-icon class="text-primary" :icon="batteryIcon"></fa-icon>
</div>
</div>
<h3 class="value">
{{ fmt(batteryPower) }}
<small class="text-muted">{{ fmtUnit(batteryPower) }}W</small>
</h3>
</div>
</div>
</template>
<script>
import "../icons";
import formatter from "../mixins/formatter";
const limit = 20;
const icons = [
"battery-empty",
"battery-quarter",
"battery-half",
"battery-three-quarters",
"battery-full",
];
export default {
name: "SiteDetails",
props: {
gridConfigured: Boolean,
gridPower: Number,
pvConfigured: Boolean,
pvPower: Number,
batteryConfigured: Boolean,
batteryPower: Number,
batterySoC: Number,
},
data: function () {
return {
iconIdx: 0,
};
},
mixins: [formatter],
computed: {
numberOfPanels: function () {
let count = 0;
if (this.gridConfigured) count++;
if (this.pvConfigured) count++;
if (this.batteryConfigured) count++;
return count;
},
panelClass: function () {
if (this.numberOfPanels == 3) {
return "col-4 col-md-4 col-lg-3";
}
if (this.numberOfPanels == 2) {
return "col-6 col-md-4 col-lg-6";
}
return "col-12";
},
batteryIcon: function () {
if (Math.abs(this.batteryPower) < limit) {
if (this.batterySoC < 30) return icons[0];
if (this.batterySoC < 50) return icons[1];
if (this.batterySoC < 70) return icons[2];
if (this.batterySoC < 90) return icons[3];
return icons[4];
}
return icons[this.iconIdx];
},
},
mounted: function () {
window.setInterval(() => {
if (this.batteryPower > limit) {
if (--this.iconIdx < 0) {
this.iconIdx = icons.length - 1;
}
} else if (this.batteryPower < limit) {
if (++this.iconIdx >= icons.length) {
this.iconIdx = 0;
}
}
}, 1000);
},
};
</script>

View file

@ -29,12 +29,16 @@ export default {
dismissAll: "Meldungen entfernen",
},
main: {
siteDetails: {
import: "Bezug",
export: "Einspeisung",
production: "Erzeugung",
batteryShort: "Akku",
batteryLong: "Batterie",
energyflow: {
noEnergy: "Kein Energiefluss",
houseConsumption: "Verbrauch",
pvProduction: "Erzeugung",
battery: "Batterie",
batteryCharge: "Batterie laden",
batteryDischarge: "Batterie entladen",
gridImport: "Netzbezug",
selfConsumption: "Eigenverbrauch",
pvExport: "Einspeisung",
},
mode: {
title: "Modus",

View file

@ -29,12 +29,16 @@ export default {
dismissAll: "Dismiss all",
},
main: {
siteDetails: {
import: "Import",
export: "Export",
production: "Production",
batteryShort: "Batt.",
batteryLong: "Battery",
energyflow: {
noEnergy: "No Energyflow",
houseConsumption: "Consumption",
pvProduction: "Production",
battery: "Battery",
batteryCharge: "Battery charge",
batteryDischarge: "Battery discharge",
gridImport: "Grid import",
selfConsumption: "Self consumption",
pvExport: "Grid feed-in",
},
mode: {
title: "Mode",

View file

@ -29,12 +29,16 @@ export default {
dismissAll: "Rimuovi tutte",
},
main: {
siteDetails: {
import: "Import",
export: "Export",
production: "Produzione",
batteryShort: "Batt.",
batteryLong: "Batteria",
energyflow: {
noEnergy: "No Energyflow",
houseConsumption: "Consumption",
pvProduction: "Produzione",
battery: "Batteria",
batteryCharge: "Battery charge",
batteryDischarge: "Battery discharge",
gridImport: "Grid import",
selfConsumption: "Self consumption",
pvExport: "Grid feed-in",
},
mode: {
title: "Modalità",

View file

@ -2,18 +2,15 @@ import Vue from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faArrowDown } from "@fortawesome/free-solid-svg-icons/faArrowDown";
import { faArrowUp } from "@fortawesome/free-solid-svg-icons/faArrowUp";
import { faBatteryEmpty } from "@fortawesome/free-solid-svg-icons/faBatteryEmpty";
import { faBatteryFull } from "@fortawesome/free-solid-svg-icons/faBatteryFull";
import { faBatteryHalf } from "@fortawesome/free-solid-svg-icons/faBatteryHalf";
import { faBatteryQuarter } from "@fortawesome/free-solid-svg-icons/faBatteryQuarter";
import { faBatteryThreeQuarters } from "@fortawesome/free-solid-svg-icons/faBatteryThreeQuarters";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
import { faChevronUp } from "@fortawesome/free-solid-svg-icons/faChevronUp";
import { faAngleUp } from "@fortawesome/free-solid-svg-icons/faAngleUp";
import { faAngleDown } from "@fortawesome/free-solid-svg-icons/faAngleDown";
import { faClock } from "@fortawesome/free-solid-svg-icons/faClock";
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons/faExclamationTriangle";
import { faLeaf } from "@fortawesome/free-solid-svg-icons/faLeaf";
import { faSun } from "@fortawesome/free-solid-svg-icons/faSun";
import { faTemperatureHigh } from "@fortawesome/free-solid-svg-icons/faTemperatureHigh";
import { faTemperatureLow } from "@fortawesome/free-solid-svg-icons/faTemperatureLow";
@ -22,30 +19,37 @@ import { faHeart as farHeart } from "@fortawesome/free-regular-svg-icons/faHeart
import { faHeart as fasHeart } from "@fortawesome/free-solid-svg-icons/faHeart";
import { faGift } from "@fortawesome/free-solid-svg-icons/faGift";
import { faBox } from "@fortawesome/free-solid-svg-icons/faBox";
import { faHome } from "@fortawesome/free-solid-svg-icons/faHome";
import { faCar } from "@fortawesome/free-solid-svg-icons/faCar";
import { faSquare } from "@fortawesome/free-solid-svg-icons/faSquare";
import { faExclamationCircle } from "@fortawesome/free-solid-svg-icons/faExclamationCircle";
import { faAngleDoubleLeft } from "@fortawesome/free-solid-svg-icons/faAngleDoubleLeft";
import { faAngleDoubleRight } from "@fortawesome/free-solid-svg-icons/faAngleDoubleRight";
library.add(
faArrowDown,
faArrowUp,
faAngleDown,
faAngleUp,
faBatteryEmpty,
faBatteryFull,
faBatteryHalf,
faBatteryQuarter,
faBatteryThreeQuarters,
faChevronDown,
faChevronUp,
faBox,
faCar,
faAngleDoubleLeft,
faAngleDoubleRight,
faClock,
faExclamationCircle,
faExclamationTriangle,
faLeaf,
faGift,
faHome,
farHeart,
fasHeart,
faSquare,
faSun,
faTemperatureHigh,
faTemperatureLow,
faThermometerHalf,
farHeart,
fasHeart,
faGift,
faBox,
faExclamationCircle
faThermometerHalf
);
Vue.component("fa-icon", FontAwesomeIcon);

View file

@ -3,8 +3,8 @@
<div class="flex-grow-1 d-flex flex-column justify-content-between">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="https://github.com/andig/evcc">
<Logo class="logo"></Logo>
<a class="navbar-brand" href="/" @click.prevent="reload">
<Logo class="logo me-3"></Logo>
</a>
<div class="d-flex">
<div class="d-flex">
@ -94,6 +94,14 @@ export default {
}
};
},
reload() {
window.location.reload();
},
},
metaInfo() {
return {
title: this.title ? `evcc | ${this.title}` : "evcc",
};
},
computed: {
version: function () {
@ -106,6 +114,9 @@ export default {
uploadProgress: this.store.state.uploadProgress,
};
},
title: function () {
return this.store.state.siteTitle;
},
sponsor: function () {
return this.store.state.sponsor;
},
@ -127,4 +138,8 @@ export default {
.app {
min-height: 100vh;
}
.title {
position: relative;
top: 0.1rem;
}
</style>

1
dist/css/index.d2c9ca1b.css vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
.progress-bar-animated{animation-direction:reverse}.btn.caption{opacity:1}.btn.first{border-top-left-radius:.2rem;border-bottom-left-radius:.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:.25}.icon[data-v-99c2e68a]{color:#0fdd42}.icon[data-v-1eecc7b7]{color:#0fdd42;display:inline-block}.sponsor-button[data-v-1eecc7b7]{touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.support-button .solid[data-v-1eecc7b7]{display:none}.support-button:hover .solid[data-v-1eecc7b7]{display:inline-block}.support-button:hover .outline[data-v-1eecc7b7]{display:none}.logo[data-v-1d8baa41]{width:85px}.app[data-v-1d8baa41]{min-height:100vh}.btn[data-v-5d01faa2]{flex-basis:0;white-space:nowrap}.vehicle-soc[data-v-3405be3f]{--height:38px;--thumb-overlap:3px;--thumb-width:3px;--thumb-horizontal-padding:15px;--label-height:26px;position:relative;height:var(--height)}.progress[data-v-3405be3f]{height:100%;font-size:1rem}.progress-bar.bg-muted[data-v-3405be3f]{color:var(--white)}.bg-light[data-v-3405be3f]{color:var(--bs-gray-dark)}.target-label[data-v-3405be3f]{width:3em;margin-left:-1.5em;height:var(--label-height);position:absolute;top:calc(var(--label-height)*-1 + var(--thumb-overlap)*-1);text-align:center;color:var(--bs-gray-dark);font-size:1rem;opacity:1;transition:opacity .2s ease 1s}.target-slider[data-v-3405be3f]{-webkit-appearance:none;position:absolute;top:calc(var(--thumb-overlap)*-1);left:calc(var(--thumb-horizontal-padding)*-1);height:calc(100% + var(--thumb-overlap)*2);width:calc(100% + var(--thumb-horizontal-padding)*2);background:transparent}.target-slider[data-v-3405be3f]:focus{outline:none}.target-slider[data-v-3405be3f]::-webkit-slider-runnable-track{position:relative;background:transparent;border:none;height:100%;cursor:pointer}.target-slider[data-v-3405be3f]::-moz-range-track{background:transparent;border:none;height:100%;cursor:pointer}.target-slider[data-v-3405be3f]::-webkit-slider-thumb{-webkit-appearance:none;position:relative;top:calc(var(--label-height)*-1);height:100%;width:var(--thumb-width);padding:var(--label-height) var(--thumb-horizontal-padding) 0;box-sizing:content-box;background-clip:content-box;background-color:var(--bs-gray-dark);cursor:-webkit-grab;cursor:grab;border:none;opacity:1;-webkit-transition:opacity .2s ease 1s;transition:opacity .2s ease 1s}.target-slider[data-v-3405be3f]::-moz-range-thumb{position:relative;top:calc(var(--label-height)*-1);height:100%;width:var(--thumb-width);padding:0 var(--thumb-horizontal-padding) 0;box-sizing:content-box;background-clip:content-box;background-color:var(--bs-gray-dark);cursor:grab;border:none;opacity:1;-moz-transition:opacity .2s ease 1s;transition:opacity .2s ease 1s}.target--max .target-label[data-v-3405be3f],.target--max .target-slider[data-v-3405be3f]::-webkit-slider-thumb{opacity:0}.target--max .target-label[data-v-3405be3f],.target--max .target-slider[data-v-3405be3f]::-moz-range-thumb{opacity:0}.target:hover .target-label[data-v-3405be3f],.target:hover .target-slider[data-v-3405be3f]::-webkit-slider-thumb{opacity:1;transition-delay:0s}.target:hover .target-label[data-v-3405be3f],.target:hover .target-slider[data-v-3405be3f]::-moz-range-thumb{opacity:1;transition-delay:0s}

4
dist/index.html vendored
View file

@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="EV Charge Controller"><meta name="author" content="andig"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black"><link rel="apple-touch-icon" sizes="180x180" href="ico/apple-touch-icon.png?[[.Version]]"><link rel="icon" type="image/png" sizes="32x32" href="ico/favicon-32x32.png?[[.Version]]"><link rel="icon" type="image/png" sizes="16x16" href="ico/favicon-16x16.png?[[.Version]]"><link rel="manifest" href="ico/site.webmanifest"><link rel="mask-icon" href="ico/safari-pinned-tab.svg?[[.Version]]" color="#18191a"><link rel="shortcut icon" href="ico/favicon.ico?[[.Version]]"><meta name="apple-mobile-web-app-title" content="evcc"><meta name="application-name" content="evcc"><meta name="msapplication-TileColor" content="#18191a"><meta name="msapplication-config" content="ico/browserconfig.xml"><meta name="theme-color" content="#18191a"><title>evcc</title><link href="css/chunk-vendors.882a611f.css" rel="preload" as="style"><link href="css/index.f1264e7c.css" rel="preload" as="style"><link href="js/chunk-vendors.8d9f525d.js" rel="preload" as="script"><link href="js/index.3e99d31f.js" rel="preload" as="script"><link href="css/chunk-vendors.882a611f.css" rel="stylesheet"><link href="css/index.f1264e7c.css" rel="stylesheet"></head><body><script>window.evcc = {
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="EV Charge Controller"><meta name="author" content="andig"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black"><link rel="apple-touch-icon" sizes="180x180" href="ico/apple-touch-icon.png?[[.Version]]"><link rel="icon" type="image/png" sizes="32x32" href="ico/favicon-32x32.png?[[.Version]]"><link rel="icon" type="image/png" sizes="16x16" href="ico/favicon-16x16.png?[[.Version]]"><link rel="manifest" href="ico/site.webmanifest"><link rel="mask-icon" href="ico/safari-pinned-tab.svg?[[.Version]]" color="#18191a"><link rel="shortcut icon" href="ico/favicon.ico?[[.Version]]"><meta name="apple-mobile-web-app-title" content="evcc"><meta name="application-name" content="evcc"><meta name="msapplication-TileColor" content="#18191a"><meta name="msapplication-config" content="ico/browserconfig.xml"><meta name="theme-color" content="#18191a"><title>evcc</title><link href="css/chunk-vendors.882a611f.css" rel="preload" as="style"><link href="css/index.d2c9ca1b.css" rel="preload" as="style"><link href="js/chunk-vendors.1a7ce110.js" rel="preload" as="script"><link href="js/index.6e9352a0.js" rel="preload" as="script"><link href="css/chunk-vendors.882a611f.css" rel="stylesheet"><link href="css/index.d2c9ca1b.css" rel="stylesheet"></head><body><script>window.evcc = {
version: "[[.Version]]",
configured: "[[.Configured]]",
};</script><div id="app"></div><script src="js/chunk-vendors.8d9f525d.js"></script><script src="js/index.3e99d31f.js"></script></body></html>
};</script><div id="app"></div><script src="js/chunk-vendors.1a7ce110.js"></script><script src="js/index.6e9352a0.js"></script></body></html>

File diff suppressed because one or more lines are too long

1
dist/js/chunk-vendors.1a7ce110.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
dist/js/index.6e9352a0.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/js/index.6e9352a0.js.map vendored Normal file

File diff suppressed because one or more lines are too long

0
docker/bin/docker-btmgmt Executable file → Normal file
View file

4
jest.config.js Normal file
View file

@ -0,0 +1,4 @@
module.exports = {
preset: "@vue/cli-plugin-unit-jest/presets/no-babel",
testMatch: ["**/*.spec.js"],
};

5948
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,45 +1,16 @@
{
"name": "evcc",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vue-cli-service build --watch --mode=development",
"build": "vue-cli-service build",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "prettier assets --write && eslint assets/**/*.js assets/**/*.vue --fix",
"storybook": "start-storybook -p 6006 -s assets,node_modules/bootstrap/dist",
"build-storybook": "build-storybook"
},
"author": "naltatis & andig",
"license": "MIT",
"devDependencies": {
"@storybook/addon-actions": "^6.2.9",
"@storybook/addon-essentials": "^6.2.9",
"@storybook/addon-links": "^6.2.9",
"@storybook/addon-postcss": "^2.0.0",
"@storybook/vue": "^6.2.9",
"@vue/cli-service": "^4.5.13",
"@vue/component-compiler-utils": "^3.2.0",
"@vue/eslint-config-standard": "^6.0.0",
"eslint": "^7.26.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-promise": "^5.1.0",
"eslint-plugin-standard": "^5.0.0",
"eslint-plugin-vue": "^7.9.0",
"prettier": "^2.3.0",
"vue-template-compiler": "^2.6.12"
"scripts": {
"build": "vue-cli-service build",
"test": "vue-cli-service test:unit",
"lint": "prettier assets --write && eslint assets/**/*.js assets/**/*.vue --fix",
"build-storybook": "build-storybook",
"dev": "vue-cli-service build --watch --mode=development",
"storybook": "start-storybook -p 6006 -s assets,node_modules/bootstrap/dist"
},
"engines": {
"npm": ">=6.0.0",
"node": ">=14.0.0"
},
"alias": {
"vue": "./node_modules/vue/dist/vue.js"
},
"repository": "github:andig/evcc",
"main": "index.js",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.35",
"@fortawesome/free-regular-svg-icons": "^5.15.3",
@ -51,8 +22,40 @@
"vue": "^2.6.12",
"vue-hot-reload-api": "^2.3.4",
"vue-i18n": "^8.24.4",
"vue-meta": "^2.4.0",
"vue-router": "^3.5.1"
},
"devDependencies": {
"@storybook/addon-actions": "^6.2.9",
"@storybook/addon-essentials": "^6.2.9",
"@storybook/addon-links": "^6.2.9",
"@storybook/addon-postcss": "^2.0.0",
"@storybook/vue": "^6.2.9",
"@vue/cli-plugin-unit-jest": "^4.5.13",
"@vue/cli-service": "^4.5.13",
"@vue/component-compiler-utils": "^3.2.0",
"@vue/eslint-config-standard": "^6.0.0",
"@vue/test-utils": "^1.0.3",
"eslint": "^7.26.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-promise": "^5.1.0",
"eslint-plugin-standard": "^5.0.0",
"eslint-plugin-vue": "^7.9.0",
"prettier": "^2.3.0",
"vue-template-compiler": "^2.6.12"
},
"alias": {
"vue": "./node_modules/vue/dist/vue.js"
},
"engines": {
"npm": ">=6.0.0",
"node": ">=14.0.0"
},
"license": "MIT",
"repository": "github:andig/evcc",
"resolutions": {
"node-forge": "0.10.0"
}