Refactor UI into single file components (#491)

Authored-by: @naltatis
This commit is contained in:
Michael Geers 2020-12-02 08:29:54 +01:00 • committed by GitHub
parent 839f6aca33
commit 3d3d8b42e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 13755 additions and 18355 deletions

13
.eslintrc.js Normal file
View file

@ -0,0 +1,13 @@
module.exports = {
env: {
browser: true,
node: true,
es6: true,
},
extends: [
"eslint:recommended",
"plugin:vue/essential",
"plugin:prettier/recommended",
"prettier",
],
};

3
.prettierrc.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
printWidth: 100,
};

View file

@ -21,6 +21,7 @@ install:
lint:
golangci-lint run
npm run lint
test:
@echo "Running testsuite"

View file

@ -1,26 +1,29 @@
/* reverse loading animation */
.progress-bar-animated {
animation-direction: reverse;
animation-direction: reverse;
}
.btn.caption {
opacity: 1;
opacity: 1;
}
.btn.first {
border-top-left-radius: .2rem;
border-bottom-left-radius: .2rem;
border-top-left-radius: 0.2rem;
border-bottom-left-radius: 0.2rem;
}
code {
font-size: 87.5%;
color: #e83e8c;
word-wrap: break-word;
font-size: 87.5%;
color: #e83e8c;
word-wrap: break-word;
}
.value, input[type=radio], label.btn {
white-space: nowrap !important;
.value,
input[type="radio"],
label.btn {
white-space: nowrap !important;
}
.text-muted a, .text-muted a:hover {
color: #6c757d!important;
text-decoration: none;
.text-muted a,
.text-muted a:hover {
color: #6c757d !important;
text-decoration: none;
}
.bg-muted {
opacity: 0.25;
}
opacity: 0.25;
}

View file

@ -1,58 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 576 512"
version="1.1"
id="svg12"
sodipodi:docname="leaf.svg"
inkscape:version="1.0beta2 (2b71d25, 2019-12-03)">
<metadata
id="metadata18">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs16" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
inkscape:document-rotation="0"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1280"
inkscape:window-height="755"
id="namedview14"
showgrid="false"
inkscape:zoom="0.92768596"
inkscape:cx="353.312"
inkscape:cy="200.09826"
inkscape:window-x="0"
inkscape:window-y="23"
inkscape:window-maximized="0"
inkscape:current-layer="svg12" />
<path
style="stroke-width:1.1;stroke-miterlimit:4;stroke-dasharray:none"
d="M546.2 9.7c-5.6-12.5-21.6-13-28.3-1.2C486.9 62.4 431.4 96 368 96h-80C182 96 96 182 96 288c0 7 .8 13.7 1.5 20.5C161.3 262.8 253.4 224 384 224c8.8 0 16 7.2 16 16s-7.2 16-16 16C132.6 256 26 410.1 2.4 468c-6.6 16.3 1.2 34.9 17.5 41.6 16.4 6.8 35-1.1 41.8-17.3 1.5-3.6 20.9-47.9 71.9-90.6 32.4 43.9 94 85.8 174.9 77.2C465.5 467.5 576 326.7 576 154.3c0-50.2-10.8-102.2-29.8-144.6z"
id="path10" />
<path
inkscape:connector-curvature="0"
id="path20"
d="m 21.21449,509.39751 c -5.971286,-2.52059 -9.269565,-4.81794 -12.9733928,-9.03637 -6.1766191,-7.03478 -8.8842152,-17.3518 -6.971629,-26.56467 1.258445,-6.0619 11.8107088,-27.39624 19.8175918,-40.06678 22.713423,-35.94297 56.773767,-71.44959 94.11207,-98.10823 68.76834,-49.09895 154.92997,-76.24193 249.47048,-78.5892 11.23532,-0.27896 21.89988,-0.8765 23.69902,-1.32786 4.02501,-1.0098 9.1941,-5.41782 10.76663,-9.18141 2.92101,-6.99096 0.29781,-15.90466 -5.87906,-19.97721 l -3.71288,-2.44799 -20.91224,0.12879 c -95.91234,0.59069 -191.38366,28.82528 -262.4752,77.62416 -4.25115,2.91809 -7.89194,5.14304 -8.090638,4.94433 -1.034492,-1.03449 -1.681776,-19.91176 -1.047227,-30.54108 4.946935,-82.86599 63.514115,-153.47981 144.215265,-173.87878 19.41459,-4.90746 20.47839,-4.979088 83.88455,-5.648034 49.15213,-0.518563 60.64802,-0.871339 68.23769,-2.09402 19.39657,-3.124747 35.56867,-8.269165 52.1562,-16.591125 29.26858,-14.684035 50.80653,-34.823142 71.56328,-66.915381 3.64477,-5.6352221 5.2829,-7.343539 8.63712,-9.0071805 5.61954,-2.78721054 10.73552,-2.07507421 15.40609,2.1445063 3.91499,3.5369576 8.97418,14.4546312 15.06776,32.5161072 20.64772,61.200058 24.79308,128.916947 11.83935,193.402597 -25.37559,126.32372 -112.033,220.27326 -224.43817,243.32443 -17.6222,3.61382 -29.64707,4.91447 -50.19768,5.42955 -21.4123,0.53668 -32.33232,-0.45528 -49.11375,-4.46143 -40.56718,-9.6844 -78.7741,-33.83895 -104.95018,-66.34986 l -5.80657,-7.21182 -7.6437,6.87084 c -26.141169,23.49803 -48.851538,52.36799 -61.996828,78.81184 -2.979862,5.99446 -6.952342,12.64221 -8.827734,14.77277 -7.841316,8.90821 -23.148129,12.54014 -33.836218,8.02851 z"
style="fill:#007bff;stroke:#007bff;stroke-width:1.1;fill-opacity:1;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none" />
</svg>
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" viewBox="0 0 576 512" version="1.1" id="svg12"
sodipodi:docname="leaf.svg" inkscape:version="1.0beta2 (2b71d25, 2019-12-03)">
<metadata id="metadata18">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs id="defs16" />
<sodipodi:namedview pagecolor="#ffffff" bordercolor="#666666" inkscape:document-rotation="0" borderopacity="1"
objecttolerance="10" gridtolerance="10" guidetolerance="10" inkscape:pageopacity="0" inkscape:pageshadow="2"
inkscape:window-width="1280" inkscape:window-height="755" id="namedview14" showgrid="false"
inkscape:zoom="0.92768596" inkscape:cx="353.312" inkscape:cy="200.09826" inkscape:window-x="0"
inkscape:window-y="23" inkscape:window-maximized="0" inkscape:current-layer="svg12" />
<path style="stroke-width:1.1;stroke-miterlimit:4;stroke-dasharray:none"
d="M546.2 9.7c-5.6-12.5-21.6-13-28.3-1.2C486.9 62.4 431.4 96 368 96h-80C182 96 96 182 96 288c0 7 .8 13.7 1.5 20.5C161.3 262.8 253.4 224 384 224c8.8 0 16 7.2 16 16s-7.2 16-16 16C132.6 256 26 410.1 2.4 468c-6.6 16.3 1.2 34.9 17.5 41.6 16.4 6.8 35-1.1 41.8-17.3 1.5-3.6 20.9-47.9 71.9-90.6 32.4 43.9 94 85.8 174.9 77.2C465.5 467.5 576 326.7 576 154.3c0-50.2-10.8-102.2-29.8-144.6z"
id="path10" />
<path inkscape:connector-curvature="0" id="path20"
d="m 21.21449,509.39751 c -5.971286,-2.52059 -9.269565,-4.81794 -12.9733928,-9.03637 -6.1766191,-7.03478 -8.8842152,-17.3518 -6.971629,-26.56467 1.258445,-6.0619 11.8107088,-27.39624 19.8175918,-40.06678 22.713423,-35.94297 56.773767,-71.44959 94.11207,-98.10823 68.76834,-49.09895 154.92997,-76.24193 249.47048,-78.5892 11.23532,-0.27896 21.89988,-0.8765 23.69902,-1.32786 4.02501,-1.0098 9.1941,-5.41782 10.76663,-9.18141 2.92101,-6.99096 0.29781,-15.90466 -5.87906,-19.97721 l -3.71288,-2.44799 -20.91224,0.12879 c -95.91234,0.59069 -191.38366,28.82528 -262.4752,77.62416 -4.25115,2.91809 -7.89194,5.14304 -8.090638,4.94433 -1.034492,-1.03449 -1.681776,-19.91176 -1.047227,-30.54108 4.946935,-82.86599 63.514115,-153.47981 144.215265,-173.87878 19.41459,-4.90746 20.47839,-4.979088 83.88455,-5.648034 49.15213,-0.518563 60.64802,-0.871339 68.23769,-2.09402 19.39657,-3.124747 35.56867,-8.269165 52.1562,-16.591125 29.26858,-14.684035 50.80653,-34.823142 71.56328,-66.915381 3.64477,-5.6352221 5.2829,-7.343539 8.63712,-9.0071805 5.61954,-2.78721054 10.73552,-2.07507421 15.40609,2.1445063 3.91499,3.5369576 8.97418,14.4546312 15.06776,32.5161072 20.64772,61.200058 24.79308,128.916947 11.83935,193.402597 -25.37559,126.32372 -112.033,220.27326 -224.43817,243.32443 -17.6222,3.61382 -29.64707,4.91447 -50.19768,5.42955 -21.4123,0.53668 -32.33232,-0.45528 -49.11375,-4.46143 -40.56718,-9.6844 -78.7741,-33.83895 -104.95018,-66.34986 l -5.80657,-7.21182 -7.6437,6.87084 c -26.141169,23.49803 -48.851538,52.36799 -61.996828,78.81184 -2.979862,5.99446 -6.952342,12.64221 -8.827734,14.77277 -7.841316,8.90821 -23.148129,12.54014 -33.836218,8.02851 z"
style="fill:#007bff;stroke:#007bff;stroke-width:1.1;fill-opacity:1;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none" />
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Before After
Before After

View file

@ -1,15 +1,12 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="576.000000pt" height="576.000000pt" viewBox="0 0 576.000000 576.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,576.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M5254 5426 c-30 -14 -94 -84 -94 -105 0 -6 -4 -11 -10 -11 -5 0 -10
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="576.000000pt" height="576.000000pt"
viewBox="0 0 576.000000 576.000000" preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,576.000000) scale(0.100000,-0.100000)" fill="#000000" stroke="none">
<path d="M5254 5426 c-30 -14 -94 -84 -94 -105 0 -6 -4 -11 -10 -11 -5 0 -10
-4 -10 -10 0 -9 -64 -106 -80 -120 -3 -3 -21 -25 -40 -50 -147 -191 -344 -351
-572 -466 -70 -35 -236 -103 -268 -109 -8 -2 -35 -9 -60 -16 -53 -15 -54 -15
-100 -23 -19 -3 -46 -8 -60 -11 -55 -12 -115 -16 -330 -20 -124 -2 -227 -5
@ -45,6 +42,6 @@ fill="#000000" stroke="none">
11 51 3 14 8 36 10 50 2 14 6 36 9 50 6 34 16 97 21 140 3 19 7 55 10 79 16
121 19 198 19 446 -1 238 -4 308 -20 430 -2 17 -5 49 -8 72 -2 23 -7 55 -10
70 -3 15 -8 48 -12 73 -3 24 -7 48 -10 51 -2 4 -6 24 -10 45 -19 130 -124 484
-181 614 -57 130 -81 162 -130 181 -34 13 -82 11 -119 -5z"/>
</g>
</svg>
-181 614 -57 130 -81 162 -130 181 -34 13 -82 11 -119 -5z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before After
Before After

View file

@ -1,8 +1,8 @@
{
"start_url": "../#",
"name": "EV Charge Controller",
"short_name": "EV Charge Controller",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
"name": "EV Charge Controller",
"short_name": "EV Charge Controller",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View file

@ -1,507 +1,55 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<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">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<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">
<link rel="icon" type="image/png" sizes="32x32" href="ico/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="ico/favicon-16x16.png">
<link rel="manifest" href="ico/site.webmanifest">
<link rel="mask-icon" href="ico/safari-pinned-tab.svg" color="#5bbad5">
<link rel="shortcut icon" href="ico/favicon.ico">
<meta name="msapplication-TileColor" content="#2b5797">
<meta name="msapplication-config" content="ico/browserconfig.xml">
<meta name="theme-color" content="#ffffff">
<link rel="apple-touch-icon" sizes="180x180" href="ico/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="ico/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="ico/favicon-16x16.png" />
<link rel="manifest" href="ico/site.webmanifest" />
<link rel="mask-icon" href="ico/safari-pinned-tab.svg" color="#5bbad5" />
<link rel="shortcut icon" href="ico/favicon.ico" />
<meta name="msapplication-TileColor" content="#2b5797" />
<meta name="msapplication-config" content="ico/browserconfig.xml" />
<meta name="theme-color" content="#ffffff" />
<title>evcc</title>
<title>evcc</title>
<!-- CSS -->
<link href="../node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
</head>
<body>
<!-- CSS -->
<link href="../node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="./css/app.css" rel="stylesheet" />
</head>
<body>
<script>
window.evcc = {
version: "[[.Version]]",
configured: "[[.Configured]]",
};
</script>
<div id="app">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="https://github.com/andig/evcc"><fa-icon icon="leaf" class="text-primary mr-2"></fa-icon>evcc</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNavAltMarkup"
aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavAltMarkup">
<div class="navbar-nav">
<router-link class="nav-item nav-link pb-1" to="/">Laden</router-link>
<router-link class="nav-item nav-link pb-1" to="/config">Konfiguration</router-link>
<a class="nav-item nav-link pb-1" href="https://github.com/andig/evcc/issues" target="_blank">Support</a>
</div>
</div>
</nav>
<div id="app"></div>
<div id="toasts"></div>
<version installed="[[.Version]]" id="version-bar"></version>
<message-toasts></message-toasts>
<router-view></router-view>
</div>
<script type="text/x-template" id="version-template">
<div class="collapse" ref="bar">
<div class="row p-3 bg-warning">
<div class="col-12">
Neue Version verfügbar!
Installiert: {{installed}}.
Verfügbar: {{state.availableVersion}}.
<b class="px-3" data-toggle="collapse" data-target="#release-notes" v-if="state.releaseNotes">
<a href="#" class="text-body">
Release notes
<fa-icon icon="chevron-up" v-if="notesShown"></fa-icon>
<fa-icon icon="chevron-down" v-else></fa-icon>
</a>
</b>
<b class="px-3">
<a v-bind:href="'https://github.com/andig/evcc/releases/tag/'+state.availableVersion" class="text-body">
Download <fa-icon icon="chevron-down"></fa-icon>
</a>
</b>
<button type="button" class="close float-right" style="margin-top: -2px" aria-label="Close" data-toggle="collapse" data-target="#version-bar">
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
<div class="row p-3 bg-light collapse" id="release-notes" ref="notes">
<div class="col-12" v-html="state.releaseNotes"></div>
</div>
</div>
</script>
<div id="toasts">
<div aria-atomic="true" style="position: absolute; top: 4rem; right: 0.5rem;">
<!-- Position it -->
<message-toast v-for="item in items" v-bind:item="item" :id="'message-id-'+item.id" :key="item.id">
</message-toast>
</div>
</div>
<script type="text/x-template" id="message-template">
<div class="toast" data-delay="10000" v-bind:data-autohide="true">
<div class="toast-header">
<strong class="mr-auto" v-if="item.type != 'warn'"><fa-icon class="text-danger" icon="exclamation-triangle"></fa-icon> Error</strong>
<strong class="mr-auto" v-if="item.type == 'warn'"><fa-icon class="text-warning" icon="exclamation-triangle"></fa-icon> Warning</strong>
<small v-if="item.status">HTTP {{item.status}}</small>
<button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="toast-body">{{item.message}}</div>
</div>
</script>
<script type="text/x-template" id="main-template">
<div class="container">
<site v-bind:state="state" v-if="configured('[[.Configured]]')"></site>
<div v-else>
<div class="row py-5">
<div class="col12">
<p class="h1 pt-5 pb-2 border-bottom">Willkommen bei evcc</p>
<p class="lead pt-2"><b>evcc</b> ist dient zur flexiblen Ladesteuerung von Elektrofahrzeugen.</p>
<p class="pt-2"> Es sieht aus, als wäre Dein <b>evcc</b> noch nicht konfiguriert. Um <b>evcc</b> zu
konfigurieren sind die folgenden Schritte notwendig:
<ol class="pt-2">
<li>Erzeugen einer Konfigurationsdatei mit Namen <code>evcc.yaml</code>. Die
Standardkonfiguration <code>evcc.dist.yaml</code> kann dafür als Vorlage dienen (<a
href="https://github.com/andig/evcc/blob/master/evcc.dist.yaml">Download</a>).</li>
<li>Konfiguration der Wallbox als <code>chargers</code>.</li>
<li>Konfiguration des EVU Zählers und evtl. weiterer Zähler unter <code>meters</code>.</li>
<li>Konfiguration des Netzanschlusses unter <code>site</code>. In einer Site wird der Netzanschluss mit
dem konfigurierten EVU Zähler (<code>meter</code>) verbunden.</li>
<li>Konfiguration eines Ladepunktes unter <code>loadpoints</code>. In einem Ladepunkt wird die
konfigurierte Wallbox (<code>charger</code>) mit dem Ladepunkt verbunden.</li>
<li>Start von <b>evcc</b> mit der neu erstellten Konfiguration: <code>evcc -c evcc.yaml</code></li>
</ol>
</p>
<p>Minimale Beispielkonfiguration für <b>evcc</b>:</p>
<p>
<code><pre class="mx-3">
uri: localhost:7070 # Adresse für UI
interval: 10s # Regelintervall
meters:
- name: evu-zähler
type: ... # Detailkonfiguration des EVU Zählers
- name: ladezähler
type: ... # Detailkonfiguration des Ladezählers (optional)
chargers:
- name: wallbox
type: ... # Detailkonfiguration der Wallbox
site:
title: Home
meters:
grid: evu-zähler # EVU Zähler
loadpoints:
- title: Ladepunkt # ui display name
charger: wallbox # charger
meters:
charge: ladezähler # Ladezählers (optional)
</pre></code>
</p>
<p>Viel Spass mit <b>evcc</b>! Bei Problemen kannst Du uns auf <a href="https://github.com/andig/evcc/issues">GitHub</a> erreichen.</p>
</div>
</div>
</div>
</div>
</script>
<script type="text/x-template" id="site-template">
<div>
<div class="row">
<div class="d-none d-md-flex col-12 col-md-4 mt-md-4 align-items-end">
<p class="h1">{{state.title||"Home"}}</p>
</div>
<div class="col-12 col-md-8 mt-md-4" v-if="multi">
<site-details v-bind:state="state"></site-details>
</div>
</div>
<div class="row d-none d-md-flex border-bottom"></div>
<div class="row" v-if="!multi">
<div class="d-none d-md-block col-md-4"></div>
<div class="col-12 col-md-8">
<site-details v-bind:state="state"></site-details>
</div>
</div>
<loadpoint v-for="(loadpoint,id) in state.loadpoints"
v-bind:id="id" :key="id" :state="loadpoint" :pv="state.gridConfigured" :multi="multi">
</loadpoint>
</div>
</script>
<script type="text/x-template" id="site-details-template">
<div class="row">
<div class="col-6 col-md-3 mt-3" v-if="state.gridConfigured">
<div class="mb-2 value" v-if="state.gridPower > 0">
Bezug <fa-icon icon="arrow-down" class="text-primary"></fa-icon>
</div>
<div class="mb-2 value" v-else>
Einspeisung <fa-icon icon="arrow-up" class="text-primary"></fa-icon>
</div>
<h2 class="value">
{{fmt(state.gridPower)}} <small class="text-muted">{{fmtUnit(state.gridPower)}}W</small>
</h2>
</div>
<div class="col-6 col-md-3 mt-3" v-if="state.pvConfigured">
<div class="mb-2 value">
Erzeugung <fa-icon icon="sun" v-bind:class="{'text-primary':state.pvPower<0,'text-muted':state.pvPower>=0}"></fa-icon>
</div>
<h2 class="value">
{{fmt(state.pvPower)}} <small class="text-muted">{{fmtUnit(state.pvPower)}}W</small>
</h2>
</div>
<div class="d-md-block col-6 col-md-3 mt-3" v-if="state.batteryConfigured">
<div class="mb-2 value">Batterie
<fa-icon class="text-primary" icon="arrow-down" v-if="state.batteryPower < 0"></fa-icon>
<fa-icon class="text-primary" icon="arrow-up" v-if="state.batteryPower > 0"></fa-icon>
</div>
<h2 class="value">
{{fmt(state.batteryPower)}} <small class="text-muted">{{fmtUnit(state.batteryPower)}}W</small>
</h2>
</div>
<div class="col-6 col-md-3 mt-3" v-if="state.batteryConfigured">
<div class="mb-2 value">SoC
<fa-icon class="text-primary" icon="battery-three-quarters" v-bind:class="{'text-primary':state.batteryPower>0,'text-muted':state.batteryPower<0}"></fa-icon>
</div>
<h2 class="value">
{{fmt(state.batterySoC)}} <small class="text-muted">%</small>
</h2>
</div>
</div>
</script>
<script type="text/x-template" id="loadpoint-template">
<div>
<div class="row" v-if="multi">
<div class="col-12 col-md-4 d-md-flex mt-3 mt-md-5 align-items-end">
<span class="h1 align-bottom">{{state.title||"Ladepunkt"}}</span>
</div>
<div class="col-12 col-md-8 d-none d-md-block mt-3 mt-md-5">
<loadpoint-details v-bind:state="state"></loadpoint-details>
</div>
<div class="col-12 d-md-none">
<div class="row mt-3 pb-3 bg-light">
<div class="col-12 mt-3">
<mode class="w-100" v-bind:mode="state.mode" :pv="pv" v-on:updated="targetMode"></mode>
</div>
<div class="col-12 mt-3" v-if="hasTargetSoC">
<soc class="w-100" v-bind:soc="state.targetSoC" :levels="state.socLevels" v-on:updated="targetSoC"></soc>
</div>
</div>
</div>
</div>
<div class="row d-none d-md-flex mt-5 py-3 pb-4 text-center bg-light" v-if="!multi">
<div class="mt-3" v-bind:class="{'col-md-6':hasTargetSoC,'col-md-12':!hasTargetSoC}">
<mode v-bind:mode="state.mode" :pv="pv" :caption="true" v-on:updated="targetMode"></mode>
</div>
<div class="col-md-6 mt-3" v-if="hasTargetSoC">
<soc v-bind:soc="state.targetSoC" :levels="state.socLevels" :caption="true" v-on:updated="targetSoC"></soc>
</div>
</div>
<div class="row d-md-none mt-2 pb-3 bg-light" v-if="!multi">
<div class="col-12 mt-3">
<mode class="w-100" v-bind:mode="state.mode" :pv="pv" v-on:updated="targetMode"></mode>
</div>
<div class="col-12 mt-3" v-if="hasTargetSoC">
<soc class="w-100" v-bind:soc="state.targetSoC" :levels="state.socLevels" v-on:updated="targetSoC"></soc>
</div>
</div>
<div class="row" v-if="!multi">
<div class="col-12 col-md-4 d-none d-md-flex mt-3 mt-md-5">
<span class="h1 ">{{state.title||"Ladepunkt"}}</span>
</div>
<div class="col-12 col-md-8 d-flex d-md-flex mt-3 mt-md-5 pt-3"
v-if="state.remoteDisabled">
<h5 class="w-100">
<span class="badge badge-warning w-100" v-if="state.remoteDisabled == 'soft'">
{{state.remoteDisabledSource}}: Adaptives PV-Laden deaktiviert
</span>
<span class="badge badge-danger w-100" v-if="state.remoteDisabled == 'hard'">
{{state.remoteDisabledSource}}: Deaktiviert
</span>
</h5>
</div>
</div>
<div class="row border-bottom d-none d-md-block"></div>
<div class="row">
<div class="col-12 col-md-4 mt-3 mb-3 mb-md-0">
<vehicle v-bind:state="state"></vehicle>
</div>
<div class="col-12 col-md-4 d-none d-md-block mt-3" v-if="multi">
<div class="mb-2">Modus</div>
<mode class="btn-group-sm" v-bind:mode="state.mode" :pv="pv" v-on:updated="targetMode"></mode>
</div>
<div class="col-12 col-md-4 d-none d-md-block mt-3" v-if="multi && hasTargetSoC">
<div class="mb-2">Ladeziel</div>
<soc class="btn-group-sm" v-bind:soc="state.targetSoC" :levels="state.socLevels" v-on:updated="targetSoC"></soc>
</div>
<div class="col-md-8 d-none d-md-block" v-if="!multi">
<loadpoint-details v-bind:state="state"></loadpoint-details>
</div>
<div class="col-12 d-md-none">
<loadpoint-details v-bind:state="state"></loadpoint-details>
</div>
</div>
</div>
</script>
<script type="text/x-template" id="loadpoint-details-template">
<div class="row">
<div class="col-6 col-md-3 mt-3">
<div class="mb-2 value">Leistung
<fa-icon class="text-primary ml-1" icon="temperature-low" v-if="state.climater=='heating'"></fa-icon>
<fa-icon class="text-primary ml-1" icon="temperature-high" v-else-if="state.climater=='cooling'"></fa-icon>
<fa-icon class="text-primary ml-1" icon="thermometer-half" v-else-if="state.climater=='on'"></fa-icon>
</div>
<h2 class="value">
{{fmt(state.chargePower)}} <small class="text-muted">{{fmtUnit(state.chargePower)}}W</small>
</h2>
</div>
<div class="col-6 col-md-3 mt-3">
<div class="mb-2 value">Geladen</div>
<h2 class="value">
{{fmt(state.chargedEnergy)}} <small class="text-muted">{{fmtUnit(state.chargedEnergy)}}Wh</small>
</h2>
</div>
<div class="col-6 col-md-3 mt-3">
<div class="mb-2 value">Dauer</div>
<h2 class="value">
{{fmtShortDuration(state.chargeDuration)}} <small
class="text-muted">{{fmtShortDurationUnit(state.chargeDuration)}}</small>
</h2>
</div>
<div class="col-6 col-md-3 mt-3" v-if="state.soc">
<div class="mb-2 value">Restzeit</div>
<h2 class="value">
{{fmtShortDuration(state.chargeEstimate)}} <small
class="text-muted">{{fmtShortDurationUnit(state.chargeEstimate)}}</small>
</h2>
</div>
</div>
</script>
<script type="text/x-template" id="mode-template">
<div class="btn-group btn-group-toggle bg-white shadow-none">
<label class="btn btn-outline-primary value disabled caption font-weight-bold" v-if="caption">
Modus
</label>
<label class="btn btn-outline-primary" v-bind:class="{active:mode=='off',first:!caption}">
<input type="radio" value="off" v-on:click="targetMode('off')">Stop
</label>
<label class="btn btn-outline-primary" v-bind:class="{active:mode=='now'}">
<input type="radio" value="now" v-on:click="targetMode('now')">Sofort
</label>
<label class="btn btn-outline-primary" v-bind:class="{active:mode=='minpv'}" v-if="pv">
<input type="radio" value="minpv" v-on:click="targetMode('minpv')">
<span class="d-inline d-lg-none">Min</span>
<span class="d-none d-lg-inline">Min + PV</span>
</label>
<label class="btn btn-outline-primary" v-bind:class="{active:mode=='pv'}" v-if="pv">
<input type="radio" value="pv" v-on:click="targetMode('pv')">
<span class="d-inline d-md-none">PV</span>
<span class="d-none d-md-inline">Nur PV</span>
</label>
</div>
</script>
<script type="text/x-template" id="soc-template">
<div class="btn-group btn-group-toggle bg-white shadow-none">
<label class="btn btn-outline-primary disabled caption font-weight-bold" v-if="caption">
Ladeziel
</label>
<label class="btn btn-outline-primary" v-for="(level,id) in levelsOrDefault" v-bind:level="level" :id="id"
:class="{active:soc==level,first:!caption&&id==0}">
<input type="radio" v-bind:value="level" v-on:click="targetSoC(level)">{{level}}%
</label>
</div>
</script>
<script type="text/x-template" id="vehicle-template">
<div>
<div class="mb-2">{{state.socTitle||"Fahrzeug"}}</div>
<div class="progress" style="height: 24px; font-size:100%; margin-top:16px">
<div class="progress-bar" role="progressbar" v-bind:class="{
'progress-bar-striped': state.charging,
'progress-bar-animated': state.charging,
'bg-light': !state.connected,
'text-secondary': !state.connected,
'bg-warning': state.connected && minSoCActive,
}" v-bind:style="{width: socChargeDisplayWidth+'%'}">{{socChargeDisplayValue}}
</div>
<div class="progress-bar" role="progressbar" v-bind:class="{
'progress-bar-striped': state.charging,
'progress-bar-animated': state.charging,
'bg-warning': true,
'bg-muted': true,
}" v-bind:style="{width: minSoCRemainingDisplayWidth+'%'}"
v-if="minSoCActive && (socChargeDisplayWidth < 100)">
</div>
</div>
</div>
</script>
<script type="text/x-template" id="config-template">
<div class="container">
<!-- <h1 class="display-4 pt-3 mx-auto text-center">Konfiguration</h1>
<p class="lead mx-auto text-center">Details der Fahrzeug-, Wallbox- und Zählerkonfiguration.</p> -->
<div class="row mt-4 border-bottom">
<div class="col-12">
<p class="h1">{{state.title||"Home"}}</p>
</div>
</div>
<div class="row h5">
<div class="col-md-4"></div>
<div class="col-6 col-md-2 py-3">
Netzzähler:
<span class="text-primary" v-if="state.gridConfigured">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
<div class="col-6 col-md-2 py-3">
PV Zähler:
<span class="text-primary" v-if="state.pvConfigured">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
<div class="col-6 col-md-2 py-3">
Batteriezähler:
<span class="text-primary" v-if="state.batteryConfigured">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
</div>
<div v-for="(loadpoint,id) in state.loadpoints" v-bind:loadpoint="loadpoint" :id="'loadpoint-'+id">
<div class="row mt-4 border-bottom">
<div class="col-12">
<p class="h1">{{loadpoint.title||"Ladepunkt"}}</p>
</div>
</div>
<div class="row h5">
<div class="col-md-4"></div>
<div class="col-6 col-md-2 py-3">
Ladezähler:
<span class="text-primary" v-if="loadpoint.chargeConfigured">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
<div class="col-6 col-md-2 py-3">
Phasen:
<span class="text-primary">{{loadpoint.phases}}p</span>
</div>
<div class="col-6 col-md-2 py-3">
Min. Strom:
<span class="text-primary">{{loadpoint.minCurrent}}A</span>
</div>
<div class="col-6 col-md-2 py-3">
Max. Strom:
<span class="text-primary">{{loadpoint.maxCurrent}}A</span>
</div>
</div>
<div class="row h5">
<div class="col-md-4"></div>
<div class="col-md-8 h2">
<div class="row py-3 h2 border-bottom">
<div class="col-12">
Fahrzeug
</div>
<footer class="pt-4 pb-2 pt-md-5 pb-md-4 mt-3 mt-md-5 border-top">
<div class="container">
<div class="row">
<div class="col-6">
<small class="text-muted">
<a href="https://github.com/andig/evcc/releases/tag/[[.Version]]">[[.Version]]</a>
(<a href="https://github.com/andig/evcc/commit/[[.Commit]]">[[.Commit]]</a>)
</small>
</div>
<div class="row h5">
<div class="col-6 py-3">
Modell:
<span class="text-primary">{{loadpoint.socTitle||"—"}}</span>
</div>
<div class="col-6 py-3">
Kapazität:
<span class="text-primary">{{loadpoint.socCapacity}}kWh</span>
</div>
<div class="col-6 text-right">
<small class="text-muted">&copy; 2020</small>
</div>
</div>
</div>
</div>
</div>
</script>
<footer class="pt-4 pb-2 pt-md-5 pb-md-4 mt-3 mt-md-5 border-top">
<div class="container">
<div class="row">
<div class="col-6">
<small class="text-muted">
<a href="https://github.com/andig/evcc/releases/tag/[[.Version]]">[[.Version]]</a>
(<a href="https://github.com/andig/evcc/commit/[[.Commit]]">[[.Commit]]</a>)
</small>
</div>
<div class="col-6 text-right">
<small class="text-muted">&copy; 2020</small>
</div>
</div>
</div>
</footer>
<script src="js/app.js"></script>
</body>
</footer>
<script src="js/app.js"></script>
</body>
</html>

View file

@ -1,227 +1,54 @@
import $ from "jquery";
import "popper.js"
import "bootstrap"
import Vue from "vue"
import VueRouter from "vue-router"
import axios from "axios"
import { library } from '@fortawesome/fontawesome-svg-core'
import { faSun, faArrowUp, faArrowDown, faTemperatureLow, faTemperatureHigh, faThermometerHalf, faLeaf, faChevronUp, faChevronDown, faExclamationTriangle }from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import "popper.js";
import "bootstrap";
import Vue from "vue";
import axios from "axios";
import { library } from "@fortawesome/fontawesome-svg-core";
import { faSun } from "@fortawesome/free-solid-svg-icons/faSun";
import { faArrowUp } from "@fortawesome/free-solid-svg-icons/faArrowUp";
import { faArrowDown } from "@fortawesome/free-solid-svg-icons/faArrowDown";
import { faTemperatureLow } from "@fortawesome/free-solid-svg-icons/faTemperatureLow";
import { faTemperatureHigh } from "@fortawesome/free-solid-svg-icons/faTemperatureHigh";
import { faThermometerHalf } from "@fortawesome/free-solid-svg-icons/faThermometerHalf";
import { faLeaf } from "@fortawesome/free-solid-svg-icons/faLeaf";
import { faChevronUp } from "@fortawesome/free-solid-svg-icons/faChevronUp";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
import { faExclamationTriangle } from "@fortawesome/free-solid-svg-icons/faExclamationTriangle";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import App from "./views/App";
import Toasts from "./components/Toasts";
import router from "./router";
import store from "./store";
library.add(faSun, faArrowUp, faArrowDown, faTemperatureLow, faTemperatureHigh, faThermometerHalf, faLeaf, faChevronUp, faChevronDown, faExclamationTriangle)
library.add(
faSun,
faArrowUp,
faArrowDown,
faTemperatureLow,
faTemperatureHigh,
faThermometerHalf,
faLeaf,
faChevronUp,
faChevronDown,
faExclamationTriangle
);
Vue.component('fa-icon', FontAwesomeIcon)
Vue.use(VueRouter)
Vue.component("font-awesome-icon", FontAwesomeIcon);
const loc = window.location;
axios.defaults.baseURL = loc.protocol + "//" + loc.hostname + (loc.port ? ":" + loc.port : "") + loc.pathname + "api";
axios.defaults.headers.post['Content-Type'] = 'application/json';
axios.defaults.baseURL =
loc.protocol + "//" + loc.hostname + (loc.port ? ":" + loc.port : "") + loc.pathname + "api";
axios.defaults.headers.post["Content-Type"] = "application/json";
//
// Mixins
//
let formatter = {
data: function () {
return {
fmtLimit: 100,
fmtDigits: 1,
}
},
methods: {
round: function(num, precision) {
var base = 10 ** precision;
return (Math.round(num * base) / base).toFixed(precision);
},
fmt: function (val) {
if (val === undefined || val === null) {
return 0;
}
val = Math.abs(val);
return val >= this.fmtLimit ? this.round(val / 1e3, this.fmtDigits) : this.round(val, 0);
},
fmtUnit: function (val) {
return Math.abs(val) >= this.fmtLimit ? "k" : "";
},
fmtDuration: function (d) {
if (d <= 0 || d == null) {
return '—';
}
var seconds = "0" + (d % 60);
var minutes = "0" + (Math.floor(d / 60) % 60);
var hours = "" + Math.floor(d / 3600);
if (hours.length < 2) {
hours = "0" + hours;
}
return hours + ":" + minutes.substr(-2) + ":" + seconds.substr(-2);
},
fmtShortDuration: function (d) {
if (d <= 0 || d == null) {
return '—';
}
var minutes = (Math.floor(d / 60) % 60);
var hours = Math.floor(d / 3600);
var tm;
if (hours >= 1) {
minutes = "0" + minutes;
tm = hours + ":" + minutes.substr(-2);
} else {
var seconds = "0" + (d % 60);
tm = minutes + ":" + seconds.substr(-2);
}
return tm;
},
fmtShortDurationUnit: function (d) {
if (d <= 0 || d == null) {
return '';
}
var hours = Math.floor(d / 3600);
if (hours >= 1) {
return "h";
}
return "m";
},
}
}
//
// State
//
function setProperty(obj, props, value) {
const prop = props.shift()
if (!obj[prop]) {
Vue.set(obj, prop, {})
}
if (!props.length) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
obj[prop] = { ...obj[prop], ...value }
} else {
obj[prop] = value
}
return
}
setProperty(obj[prop], props, value)
}
let store = {
state: {
loadpoints: [], // ensure array type
},
update: function(msg) {
Object.keys(msg).forEach(function (k) {
if (typeof toasts[k] === "function") {
toasts[k]({message: msg[k]})
} else {
setProperty(store.state, k.split('.'), msg[k])
}
});
}
};
//
// Heartbeat
//
window.setInterval(function() {
axios.get("health").catch(function(res) {
res.message = "Server unavailable";
toasts.error(res)
});
}, 5000);
//
// App & Routing
//
const main = Vue.component('Main', {
template: '#main-template',
data: function() {
return {
state: store.state // global state
}
},
methods: {
configured: function (val) {
// for development purposes
if (val == '[[.Configured]]') {
return true;
}
if (!isNaN(parseInt(val)) && parseInt(val) > 0) {
return true;
}
return false;
}
}
});
const config = Vue.component("config", {
template: "#config-template",
data: function() {
return {
state: store.state // global state
};
},
});
const routes = [
{ path: "/", component: main },
{ path: "/config", component: config },
];
const router = new VueRouter({
routes, // short for `routes: routes`
linkExactActiveClass: "active", // Bootstrap <nav>
});
const app = new Vue({
el: "#app",
router: router,
data: {
compact: false,
},
methods: {
connect: function() {
const protocol = loc.protocol == "https:" ? "wss:" : "ws:";
const uri = protocol + "//" + loc.hostname + (loc.port ? ":" + loc.port : "") + loc.pathname + "ws";
const ws = new WebSocket(uri), self = this;
ws.onerror = function(evt) {
ws.close();
};
ws.onclose = function(evt) {
window.setTimeout(self.connect, 1000);
};
ws.onmessage = function(evt) {
try {
var msg = JSON.parse(evt.data);
store.update(msg);
}
catch (e) {
toasts.error(e, evt.data)
}
};
},
},
created: function () {
const urlParams = new URLSearchParams(window.location.search);
this.compact = urlParams.get("compact");
this.connect(); // websocket listener
},
});
//
// Components
//
const toasts = new Vue({
window.toasts = new Vue({
el: "#toasts",
render: function (h) {
return h(Toasts, { props: { items: this.items, count: this.count } });
},
data: {
items: {},
count: 0,
},
name: "ToastsRoot",
methods: {
raise: function (msg) {
let found = false;
@ -238,7 +65,7 @@ const toasts = new Vue({
},
error: function (msg) {
msg.type = "error";
this.raise(msg)
this.raise(msg);
},
warn: function (msg) {
msg.type = "warn";
@ -247,183 +74,19 @@ const toasts = new Vue({
remove: function (msg) {
Vue.delete(this.items, msg.id);
},
}
});
Vue.component('message-toast', {
template: '#message-template',
props: ['item'],
mounted: function () {
const id = "#message-id-" + this.item.id;
$(id).toast('show');
$(id).on('hidden.bs.toast', function () {
toasts.remove(this.item);
}.bind(this))
},
});
Vue.component('version', {
template: '#version-template',
props: ['installed'],
data: function () {
return {
state: store.state,
notesShown: false,
};
},
mounted: function () {
$(this.$refs.notes)
.on('show.bs.collapse', function () { this.notesShown = true; }.bind(this))
.on('hide.bs.collapse', function () { this.notesShown = false; }.bind(this));
},
watch: {
"state.availableVersion": function () {
if (this.installed != "[[.Version]]" && // go template parsed?
this.installed != "0.0.1-alpha" && // make used?
this.state.availableVersion != this.installed) {
$(this.$refs.bar).collapse("show");
}
}
}
new Vue({
el: "#app",
router,
data: { store },
render: (h) => h(App),
});
Vue.component('site', {
template: '#site-template',
props: ['state'],
mixins: [formatter],
computed: {
multi: function() {
return this.state.loadpoints.length > 1 || app.compact;
},
}
});
Vue.component('site-details', {
template: '#site-details-template',
props: ['state'],
mixins: [formatter]
});
Vue.component("loadpoint", {
template: "#loadpoint-template",
props: ["state", "id", "pv", "multi"],
mixins: [formatter],
data: function() {
return {
tickerHandle: null,
};
},
computed: {
hasTargetSoC: function () {
return this.state.socLevels != null && this.state.socLevels.length > 0;
},
},
watch: {
"state.chargeDuration": function() {
window.clearInterval(this.tickerHandle);
// only ticker if actually charging
if (this.state.charging && this.state.chargeDuration >= 0) {
this.tickerHandle = window.setInterval(function() {
this.state.chargeDuration += 1;
}.bind(this), 1000);
}
},
},
methods: {
api: function (func) {
return "loadpoints/" + this.id + "/" + func;
},
targetMode: function (mode) {
axios.post(this.api("mode") + "/" + mode).then(function (response) {
this.state.mode = response.data.mode;
}.bind(this)).catch(toasts.error);
},
targetSoC: function (soc) {
axios.post(this.api("targetsoc") + "/" + soc).then(function (response) {
this.state.targetSoC = response.data.targetSoC;
}.bind(this)).catch(toasts.error);
},
},
destroyed: function() {
window.clearInterval(this.tickerHandle);
}
});
Vue.component("loadpoint-details", {
template: "#loadpoint-details-template",
props: ["state"],
mixins: [formatter],
computed: {
minSoCActive: function () {
return this.state.connected && (
this.state.minSoC > 0 && this.state.socCharge < this.state.minSoC
)
}
}
});
Vue.component("vehicle", {
template: "#vehicle-template",
props: ["state"],
computed: {
socChargeDisplayWidth: function () {
if (this.state.soc && this.state.socCharge >= 0) {
return this.state.socCharge;
}
return 100;
},
socChargeDisplayValue: function () {
// no soc or no soc value
if (!this.state.soc || this.state.socCharge < 0) {
let chargeStatus = "getrennt";
if (this.state.charging) {
chargeStatus = "laden";
} else if (this.state.connected) {
chargeStatus = "verbunden";
}
return chargeStatus;
}
// percent value if enough space
let socCharge = this.state.socCharge;
if (socCharge >= 10) {
socCharge += "%";
}
return socCharge;
},
minSoCActive: function () {
return this.state.minSoC > 0 && this.state.socCharge < this.state.minSoC;
},
minSoCRemainingDisplayWidth: function () {
return this.state.minSoC - this.state.socCharge;
}
}
});
Vue.component("mode", {
template: "#mode-template",
props: ["mode", "pv", "caption"],
methods: {
targetMode: function (mode) {
this.$emit("updated", mode)
}
},
});
Vue.component("soc", {
template: "#soc-template",
props: ["soc", "caption", "levels"],
computed: {
levelsOrDefault: function() {
if (this.levels == null || this.levels.length == 0) {
return []; // disabled, or use 30, 50, 80, 100
}
return this.levels;
}
},
methods: {
targetSoC: function (mode) {
this.$emit("updated", mode)
}
},
});
window.setInterval(function () {
axios.get("health").catch(function (res) {
res.message = "Server unavailable";
window.toasts.error(res);
});
}, 5000);

View file

@ -0,0 +1,200 @@
<template>
<div>
<div class="row" v-if="multi">
<div class="col-12 col-md-4 d-md-flex mt-3 mt-md-5 align-items-end">
<span class="h1 align-bottom">{{ state.title || "Ladepunkt" }}</span>
</div>
<div class="col-12 col-md-8 d-none d-md-block mt-3 mt-md-5">
<LoadpointDetails v-bind:state="state"></LoadpointDetails>
</div>
<div class="col-12 d-md-none">
<div class="row mt-3 pb-3 bg-light">
<div class="col-12 mt-3">
<Mode
class="w-100"
v-bind:mode="state.mode"
:pv="pv"
v-on:updated="targetMode"
></Mode>
</div>
<div class="col-12 mt-3" v-if="hasTargetSoC">
<Soc
class="w-100"
v-bind:soc="state.targetSoC"
:levels="state.socLevels"
v-on:updated="targetSoC"
></Soc>
</div>
</div>
</div>
</div>
<div class="row d-none d-md-flex mt-5 py-3 pb-4 text-center bg-light" v-if="!multi">
<div
class="mt-3"
v-bind:class="{ 'col-md-6': hasTargetSoC, 'col-md-12': !hasTargetSoC }"
>
<Mode
v-bind:mode="state.mode"
:pv="pv"
:caption="true"
v-on:updated="targetMode"
></Mode>
</div>
<div class="col-md-6 mt-3" v-if="hasTargetSoC">
<Soc
v-bind:soc="state.targetSoC"
:levels="state.socLevels"
:caption="true"
v-on:updated="targetSoC"
></Soc>
</div>
</div>
<div class="row d-md-none mt-2 pb-3 bg-light" v-if="!multi">
<div class="col-12 mt-3">
<Mode
class="w-100"
v-bind:mode="state.mode"
:pv="pv"
v-on:updated="targetMode"
></Mode>
</div>
<div class="col-12 mt-3" v-if="hasTargetSoC">
<Soc
class="w-100"
v-bind:soc="state.targetSoC"
:levels="state.socLevels"
v-on:updated="targetSoC"
></Soc>
</div>
</div>
<div class="row" v-if="!multi">
<div class="col-12 col-md-4 d-none d-md-flex mt-3 mt-md-5">
<span class="h1">{{ state.title || "Ladepunkt" }}</span>
</div>
<div
class="col-12 col-md-8 d-flex d-md-flex mt-3 mt-md-5 pt-3"
v-if="state.remoteDisabled"
>
<h5 class="w-100">
<span class="badge badge-warning w-100" v-if="state.remoteDisabled == 'soft'">
{{ state.remoteDisabledSource }}: Adaptives PV-Laden deaktiviert
</span>
<span class="badge badge-danger w-100" v-if="state.remoteDisabled == 'hard'">
{{ state.remoteDisabledSource }}: Deaktiviert
</span>
</h5>
</div>
</div>
<div class="row border-bottom d-none d-md-block"></div>
<div class="row">
<div class="col-12 col-md-4 mt-3 mb-3 mb-md-0">
<Vehicle v-bind:state="state"></Vehicle>
</div>
<div class="col-12 col-md-4 d-none d-md-block mt-3" v-if="multi">
<div class="mb-2">Modus</div>
<Mode
class="btn-group-sm"
v-bind:mode="state.mode"
:pv="pv"
v-on:updated="targetMode"
></Mode>
</div>
<div class="col-12 col-md-4 d-none d-md-block mt-3" v-if="multi && hasTargetSoC">
<div class="mb-2">Ladeziel</div>
<Soc
class="btn-group-sm"
v-bind:soc="state.targetSoC"
:levels="state.socLevels"
v-on:updated="targetSoC"
></Soc>
</div>
<div class="col-md-8 d-none d-md-block" v-if="!multi">
<LoadpointDetails v-bind:state="state"></LoadpointDetails>
</div>
<div class="col-12 d-md-none">
<LoadpointDetails v-bind:state="state"></LoadpointDetails>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
import Soc from "./Soc";
import Mode from "./Mode";
import Vehicle from "./Vehicle";
import LoadpointDetails from "./LoadpointDetails";
import formatter from "../mixins/formatter";
export default {
name: "Loadpoint",
props: ["state", "id", "pv", "multi"],
components: { LoadpointDetails, Soc, Mode, Vehicle },
mixins: [formatter],
data: function () {
return {
tickerHandle: null,
};
},
computed: {
hasTargetSoC: function () {
return this.state.socLevels != null && this.state.socLevels.length > 0;
},
},
watch: {
"state.chargeDuration": function () {
window.clearInterval(this.tickerHandle);
// only ticker if actually charging
if (this.state.charging && this.state.chargeDuration >= 0) {
this.tickerHandle = window.setInterval(
function () {
// eslint-disable-next-line vue/no-mutating-props
this.state.chargeDuration += 1;
}.bind(this),
1000
);
}
},
},
methods: {
api: function (func) {
return "loadpoints/" + this.id + "/" + func;
},
targetMode: function (mode) {
axios
.post(this.api("mode") + "/" + mode)
.then(
function (response) {
// eslint-disable-next-line vue/no-mutating-props
this.state.mode = response.data.mode;
}.bind(this)
)
.catch(window.toasts.error);
},
targetSoC: function (soc) {
axios
.post(this.api("targetsoc") + "/" + soc)
.then(
function (response) {
// eslint-disable-next-line vue/no-mutating-props
this.state.targetSoC = response.data.targetSoC;
}.bind(this)
)
.catch(window.toasts.error);
},
},
destroyed: function () {
window.clearInterval(this.tickerHandle);
},
};
</script>

View file

@ -0,0 +1,70 @@
<template>
<div class="row">
<div class="col-6 col-md-3 mt-3">
<div class="mb-2 value">
Leistung
<font-awesome-icon
class="text-primary ml-1"
icon="temperature-low"
v-if="state.climater == 'heating'"
/>
<font-awesome-icon
class="text-primary ml-1"
icon="temperature-high"
v-if="state.climater == 'cooling'"
/>
<font-awesome-icon
class="text-primary ml-1"
icon="thermometer-half"
v-if="state.climater == 'on'"
/>
</div>
<h2 class="value">
{{ fmt(state.chargePower) }}
<small class="text-muted">{{ fmtUnit(state.chargePower) }}W</small>
</h2>
</div>
<div class="col-6 col-md-3 mt-3">
<div class="mb-2 value">Geladen</div>
<h2 class="value">
{{ fmt(state.chargedEnergy) }}
<small class="text-muted">{{ fmtUnit(state.chargedEnergy) }}Wh</small>
</h2>
</div>
<div class="col-6 col-md-3 mt-3">
<div class="mb-2 value">Dauer</div>
<h2 class="value">
{{ fmtShortDuration(state.chargeDuration) }}
<small class="text-muted">{{ fmtShortDurationUnit(state.chargeDuration) }}</small>
</h2>
</div>
<div class="col-6 col-md-3 mt-3" v-if="state.soc">
<div class="mb-2 value">Restzeit</div>
<h2 class="value">
{{ fmtShortDuration(state.chargeEstimate) }}
<small class="text-muted">{{ fmtShortDurationUnit(state.chargeEstimate) }}</small>
</h2>
</div>
</div>
</template>
<script>
import formatter from "../mixins/formatter";
export default {
name: "LoadpointDetails",
props: ["state"],
mixins: [formatter],
computed: {
minSoCActive: function () {
return (
this.state.connected &&
this.state.minSoC > 0 &&
this.state.socCharge < this.state.minSoC
);
},
},
};
</script>

View file

@ -0,0 +1,37 @@
<template>
<div class="toast" data-delay="10000" v-bind:data-autohide="true">
<div class="toast-header">
<strong class="mr-auto" v-if="item.type != 'warn'"
><font-awesome-icon class="text-danger" icon="exclamation-triangle" /> Error</strong
>
<strong class="mr-auto" v-if="item.type == 'warn'"
><font-awesome-icon class="text-warning" icon="exclamation-triangle" />
Warning</strong
>
<small v-if="item.status">HTTP {{ item.status }}</small>
<button type="button" class="ml-2 mb-1 close" data-dismiss="toast" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="toast-body">{{ item.message }}</div>
</div>
</template>
<script>
import $ from "jquery";
export default {
name: "MessageToast",
props: ["item"],
mounted: function () {
const id = "#message-id-" + this.item.id;
$(id).toast("show");
$(id).on(
"hidden.bs.toast",
function () {
window.toasts.remove(this.item);
}.bind(this)
);
},
};
</script>

View file

@ -0,0 +1,41 @@
<template>
<div class="btn-group btn-group-toggle bg-white shadow-none">
<label
class="btn btn-outline-primary value disabled caption font-weight-bold"
v-if="caption"
>
Modus
</label>
<label
class="btn btn-outline-primary"
v-bind:class="{ active: mode == 'off', first: !caption }"
>
<input type="radio" value="off" v-on:click="targetMode('off')" />Stop
</label>
<label class="btn btn-outline-primary" v-bind:class="{ active: mode == 'now' }">
<input type="radio" value="now" v-on:click="targetMode('now')" />Sofort
</label>
<label class="btn btn-outline-primary" v-bind:class="{ active: mode == 'minpv' }" v-if="pv">
<input type="radio" value="minpv" v-on:click="targetMode('minpv')" />
<span class="d-inline d-lg-none">Min</span>
<span class="d-none d-lg-inline">Min + PV</span>
</label>
<label class="btn btn-outline-primary" v-bind:class="{ active: mode == 'pv' }" v-if="pv">
<input type="radio" value="pv" v-on:click="targetMode('pv')" />
<span class="d-inline d-md-none">PV</span>
<span class="d-none d-md-inline">Nur PV</span>
</label>
</div>
</template>
<script>
export default {
name: "Mode",
props: ["mode", "pv", "caption"],
methods: {
targetMode: function (mode) {
this.$emit("updated", mode);
},
},
};
</script>

View file

@ -0,0 +1,50 @@
<template>
<div>
<div class="row">
<div class="d-none d-md-flex col-12 col-md-4 mt-md-4 align-items-end">
<p class="h1">{{ state.title || "Home" }}</p>
</div>
<div class="col-12 col-md-8 mt-md-4" v-if="multi">
<SiteDetails v-bind:state="state"></SiteDetails>
</div>
</div>
<div class="row d-none d-md-flex border-bottom"></div>
<div class="row" v-if="!multi">
<div class="d-none d-md-block col-md-4"></div>
<div class="col-12 col-md-8">
<SiteDetails v-bind:state="state"></SiteDetails>
</div>
</div>
<Loadpoint
v-for="(loadpoint, id) in state.loadpoints"
v-bind:id="id"
:key="id"
:state="loadpoint"
:pv="state.gridConfigured"
:multi="multi"
>
</Loadpoint>
</div>
</template>
<script>
import SiteDetails from "./SiteDetails";
import Loadpoint from "./Loadpoint";
import formatter from "../mixins/formatter";
export default {
name: "Site",
props: ["state"],
components: { SiteDetails, Loadpoint },
mixins: [formatter],
computed: {
multi: function () {
// TODO fix compact
return this.state.loadpoints.length > 1 /* || app.compact*/;
},
},
};
</script>

View file

@ -0,0 +1,79 @@
<template>
<div class="row">
<div class="col-6 col-md-3 mt-3" v-if="state.gridConfigured">
<div class="mb-2 value" v-if="state.gridPower > 0">
Bezug <font-awesome-icon icon="arrow-down" class="text-primary" />
</div>
<div class="mb-2 value" v-else>
Einspeisung <font-awesome-icon icon="arrow-up" class="text-primary" />
</div>
<h2 class="value">
{{ fmt(state.gridPower) }}
<small class="text-muted">{{ fmtUnit(state.gridPower) }}W</small>
</h2>
</div>
<div class="col-6 col-md-3 mt-3" v-if="state.pvConfigured">
<div class="mb-2 value">
Erzeugung
<font-awesome-icon
icon="sun"
v-bind:class="{
'text-primary': state.pvPower < 0,
'text-muted': state.pvPower >= 0,
}"
/>
</div>
<h2 class="value">
{{ fmt(state.pvPower) }}
<small class="text-muted">{{ fmtUnit(state.pvPower) }}W</small>
</h2>
</div>
<div
class="d-md-block col-6 col-md-3 mt-3"
v-bind:class="{ 'd-none': !state.batterySoC }"
v-if="state.batteryConfigured"
>
<div class="mb-2 value">
Batterie
<font-awesome-icon
class="text-primary"
icon="arrow-down"
v-if="state.batteryPower < 0"
/>
<font-awesome-icon
class="text-primary"
icon="arrow-up"
v-if="state.batteryPower > 0"
/>
</div>
<h2 class="value">
{{ fmt(state.batteryPower) }}
<small class="text-muted">{{ fmtUnit(state.batteryPower) }}W</small>
</h2>
</div>
<div class="col-6 col-md-3 mt-3" v-if="state.batterySoC">
<div class="mb-2 value">
SoC
<font-awesome-icon
class="text-primary"
icon="battery-three-quarters"
v-bind:class="{
'text-primary': state.batteryPower > 0,
'text-muted': state.batteryPower < 0,
}"
/>
</div>
<h2 class="value">{{ state.batterySoC }} <small class="text-muted">%</small></h2>
</div>
</div>
</template>
<script>
import formatter from "../mixins/formatter";
export default {
name: "SiteDetails",
props: ["state"],
mixins: [formatter],
};
</script>

View file

@ -0,0 +1,37 @@
<template>
<div class="btn-group btn-group-toggle bg-white shadow-none">
<label class="btn btn-outline-primary disabled caption font-weight-bold" v-if="caption">
Ladeziel
</label>
<label
class="btn btn-outline-primary"
v-for="(level, id) in levelsOrDefault"
:key="id"
v-bind:level="level"
:id="id"
:class="{ active: soc == level, first: !caption && id == 0 }"
>
<input type="radio" v-bind:value="level" v-on:click="targetSoC(level)" />{{ level }}%
</label>
</div>
</template>
<script>
export default {
name: "Soc",
props: ["soc", "caption", "levels"],
computed: {
levelsOrDefault: function () {
if (this.levels == null || this.levels.length == 0) {
return []; // disabled, or use 30, 50, 80, 100
}
return this.levels;
},
},
methods: {
targetSoC: function (mode) {
this.$emit("updated", mode);
},
},
};
</script>

View file

@ -0,0 +1,22 @@
<template>
<div aria-atomic="true" style="position: absolute; top: 4rem; right: 0.5rem">
<!-- Position it -->
<MessageToast
v-for="item in items"
v-bind:item="item"
:id="'message-id-' + item.id"
:key="item.id"
>
</MessageToast>
</div>
</template>
<script>
import MessageToast from "./MessageToast";
export default {
name: "Toasts",
components: { MessageToast },
props: ["items", "count"],
};
</script>

View file

@ -0,0 +1,73 @@
<template>
<div>
<div class="mb-2">{{ state.socTitle || "Fahrzeug" }}</div>
<div class="progress" style="height: 24px; font-size: 100%; margin-top: 16px">
<div
class="progress-bar"
role="progressbar"
v-bind:class="{
'progress-bar-striped': state.charging,
'progress-bar-animated': state.charging,
'bg-light': !state.connected,
'text-secondary': !state.connected,
'bg-warning': state.connected && minSoCActive,
}"
v-bind:style="{ width: socChargeDisplayWidth + '%' }"
>
{{ socChargeDisplayValue }}
</div>
<div
class="progress-bar"
role="progressbar"
v-bind:class="{
'progress-bar-striped': state.charging,
'progress-bar-animated': state.charging,
'bg-warning': true,
'bg-muted': true,
}"
v-bind:style="{ width: minSoCRemainingDisplayWidth + '%' }"
v-if="minSoCActive && socChargeDisplayWidth < 100"
></div>
</div>
</div>
</template>
<script>
export default {
name: "Vehicle",
props: ["state"],
computed: {
socChargeDisplayWidth: function () {
if (this.state.soc && this.state.socCharge >= 0) {
return this.state.socCharge;
}
return 100;
},
socChargeDisplayValue: function () {
// no soc or no soc value
if (!this.state.soc || this.state.socCharge < 0) {
let chargeStatus = "getrennt";
if (this.state.charging) {
chargeStatus = "laden";
} else if (this.state.connected) {
chargeStatus = "verbunden";
}
return chargeStatus;
}
// percent value if enough space
let socCharge = this.state.socCharge;
if (socCharge >= 10) {
socCharge += "%";
}
return socCharge;
},
minSoCActive: function () {
return this.state.minSoC > 0 && this.state.socCharge < this.state.minSoC;
},
minSoCRemainingDisplayWidth: function () {
return this.state.minSoC - this.state.socCharge;
},
},
};
</script>

View file

@ -0,0 +1,86 @@
<template>
<div class="collapse" ref="bar">
<div class="row p-3 bg-warning">
<div class="col-12">
Neue Version verfügbar! Installiert: {{ installed }}. Verfügbar:
{{ state.availableVersion }}.
<b
class="px-3"
data-toggle="collapse"
data-target="#release-notes"
v-if="state.releaseNotes"
>
<a href="#" class="text-body">
Release notes
<font-awesome-icon icon="chevron-up" v-if="notesShown" />
<font-awesome-icon icon="chevron-down" v-if="!notesShown" />
</a>
</b>
<b class="px-3">
<a
v-bind:href="
'https://github.com/andig/evcc/releases/tag/' + state.availableVersion
"
class="text-body"
>
Download <font-awesome-icon icon="chevron-down" />
</a>
</b>
<button
type="button"
class="close float-right"
style="margin-top: -2px"
aria-label="Close"
data-toggle="collapse"
data-target="#version-bar"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
<div class="row p-3 bg-light collapse" id="release-notes" ref="notes">
<div class="col-12" v-html="state.releaseNotes"></div>
</div>
</div>
</template>
<script>
import $ from "jquery";
export default {
name: "Version",
props: ["installed"],
data: function () {
return {
state: this.$root.$data.store.state,
notesShown: false,
};
},
mounted: function () {
$(this.$refs.notes)
.on(
"show.bs.collapse",
function () {
this.notesShown = true;
}.bind(this)
)
.on(
"hide.bs.collapse",
function () {
this.notesShown = false;
}.bind(this)
);
},
watch: {
"state.availableVersion": function () {
if (
this.installed != window.evcc.version && // go template parsed?
this.installed != "0.0.1-alpha" && // make used?
this.state.availableVersion != this.installed
) {
$(this.$refs.bar).collapse("show");
}
},
},
};
</script>

View file

@ -0,0 +1,62 @@
export default {
data: function () {
return {
fmtLimit: 100,
fmtDigits: 1,
};
},
methods: {
round: function (num, precision) {
var base = 10 ** precision;
return (Math.round(num * base) / base).toFixed(precision);
},
fmt: function (val) {
if (val === undefined || val === null) {
return 0;
}
val = Math.abs(val);
return val >= this.fmtLimit ? this.round(val / 1e3, this.fmtDigits) : this.round(val, 0);
},
fmtUnit: function (val) {
return Math.abs(val) >= this.fmtLimit ? "k" : "";
},
fmtDuration: function (d) {
if (d <= 0 || d == null) {
return "—";
}
var seconds = "0" + (d % 60);
var minutes = "0" + (Math.floor(d / 60) % 60);
var hours = "" + Math.floor(d / 3600);
if (hours.length < 2) {
hours = "0" + hours;
}
return hours + ":" + minutes.substr(-2) + ":" + seconds.substr(-2);
},
fmtShortDuration: function (d) {
if (d <= 0 || d == null) {
return "—";
}
var minutes = Math.floor(d / 60) % 60;
var hours = Math.floor(d / 3600);
var tm;
if (hours >= 1) {
minutes = "0" + minutes;
tm = hours + ":" + minutes.substr(-2);
} else {
var seconds = "0" + (d % 60);
tm = minutes + ":" + seconds.substr(-2);
}
return tm;
},
fmtShortDurationUnit: function (d) {
if (d <= 0 || d == null) {
return "";
}
var hours = Math.floor(d / 3600);
if (hours >= 1) {
return "h";
}
return "m";
},
},
};

16
assets/js/router.js Normal file
View file

@ -0,0 +1,16 @@
import VueRouter from "vue-router";
import Vue from "vue";
import Main from "./views/Main";
import Config from "./views/Config";
Vue.use(VueRouter);
const routes = [
{ path: "/", component: Main },
{ path: "/config", component: Config },
];
module.exports = new VueRouter({
routes,
linkExactActiveClass: "active", // Bootstrap <nav>
});

36
assets/js/store.js Normal file
View file

@ -0,0 +1,36 @@
import Vue from "vue";
function setProperty(obj, props, value) {
const prop = props.shift();
if (!obj[prop]) {
Vue.set(obj, prop, {});
}
if (!props.length) {
if (value && typeof value === "object" && !Array.isArray(value)) {
obj[prop] = { ...obj[prop], ...value };
} else {
obj[prop] = value;
}
return;
}
setProperty(obj[prop], props, value);
}
const store = {
state: {
loadpoints: [], // ensure array type
},
update: function (msg) {
Object.keys(msg).forEach(function (k) {
if (typeof window.toasts[k] === "function") {
window.toasts[k]({ message: msg[k] });
} else {
setProperty(store.state, k.split("."), msg[k]);
}
});
},
};
module.exports = store;

89
assets/js/views/App.vue Normal file
View file

@ -0,0 +1,89 @@
<template>
<div>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="https://github.com/andig/evcc"
><font-awesome-icon icon="leaf" class="text-primary mr-2" />evcc</a
>
<button
class="navbar-toggler"
type="button"
data-toggle="collapse"
data-target="#navbarNavAltMarkup"
aria-controls="navbarNavAltMarkup"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavAltMarkup">
<div class="navbar-nav">
<router-link class="nav-item nav-link pb-1" to="/">Laden</router-link>
<router-link class="nav-item nav-link pb-1" to="/config"
>Konfiguration</router-link
>
<a
class="nav-item nav-link pb-1"
href="https://github.com/andig/evcc/issues"
target="_blank"
>Support</a
>
</div>
</div>
</nav>
<Version :installed="installed" id="version-bar"></Version>
<router-view></router-view>
</div>
</template>
<script>
import Version from "../components/Version";
import store from "../store";
export default {
name: "App",
components: { Version },
data: function () {
return {
compact: false,
store: this.$root.$data.store,
installed: window.evcc.version,
};
},
methods: {
connect: function () {
const loc = window.location;
const protocol = loc.protocol == "https:" ? "wss:" : "ws:";
const uri =
protocol +
"//" +
loc.hostname +
(loc.port ? ":" + loc.port : "") +
loc.pathname +
"ws";
const ws = new WebSocket(uri),
self = this;
ws.onerror = function () {
ws.close();
};
ws.onclose = function () {
window.setTimeout(self.connect, 1000);
};
ws.onmessage = function (evt) {
try {
var msg = JSON.parse(evt.data);
store.update(msg);
} catch (e) {
window.toasts.error(e, evt.data);
}
};
},
},
created: function () {
const urlParams = new URLSearchParams(window.location.search);
this.compact = urlParams.get("compact");
this.connect(); // websocket listener
},
};
</script>

View file

@ -0,0 +1,95 @@
<template>
<div class="container">
<!-- <h1 class="display-4 pt-3 mx-auto text-center">Konfiguration</h1>
<p class="lead mx-auto text-center">Details der Fahrzeug-, Wallbox- und Zählerkonfiguration.</p> -->
<div class="row mt-4 border-bottom">
<div class="col-12">
<p class="h1">{{ state.title || "Home" }}</p>
</div>
</div>
<div class="row h5">
<div class="col-md-4"></div>
<div class="col-6 col-md-2 py-3">
Netzzähler:
<span class="text-primary" v-if="state.gridConfigured">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
<div class="col-6 col-md-2 py-3">
PV Zähler:
<span class="text-primary" v-if="state.pvConfigured">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
<div class="col-6 col-md-2 py-3">
Batteriezähler:
<span class="text-primary" v-if="state.batteryConfigured">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
</div>
<div
v-for="(loadpoint, id) in state.loadpoints"
v-bind:loadpoint="loadpoint"
:key="id"
:id="'loadpoint-' + id"
>
<div class="row mt-4 border-bottom">
<div class="col-12">
<p class="h1">{{ loadpoint.title || "Ladepunkt" }}</p>
</div>
</div>
<div class="row h5">
<div class="col-md-4"></div>
<div class="col-6 col-md-2 py-3">
Ladezähler:
<span class="text-primary" v-if="loadpoint.chargeConfigured">✓</span>
<span class="text-primary" v-else>&mdash;</span>
</div>
<div class="col-6 col-md-2 py-3">
Phasen:
<span class="text-primary">{{ loadpoint.phases }}p</span>
</div>
<div class="col-6 col-md-2 py-3">
Min. Strom:
<span class="text-primary">{{ loadpoint.minCurrent }}A</span>
</div>
<div class="col-6 col-md-2 py-3">
Max. Strom:
<span class="text-primary">{{ loadpoint.maxCurrent }}A</span>
</div>
</div>
<div class="row h5">
<div class="col-md-4"></div>
<div class="col-md-8 h2">
<div class="row py-3 h2 border-bottom">
<div class="col-12">Fahrzeug</div>
</div>
<div class="row h5">
<div class="col-6 py-3">
Modell:
<span class="text-primary">{{ loadpoint.socTitle || "—" }}</span>
</div>
<div class="col-6 py-3">
Kapazität:
<span class="text-primary">{{ loadpoint.socCapacity }}kWh</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "Config",
data: function () {
return {
state: this.$root.$data.store.state, // global state
};
},
};
</script>

107
assets/js/views/Main.vue Normal file
View file

@ -0,0 +1,107 @@
<template>
<div class="container">
<Site v-bind:state="state" v-if="configured"></Site>
<div v-else>
<div class="row py-5">
<div class="col12">
<p class="h1 pt-5 pb-2 border-bottom">Willkommen bei evcc</p>
<p class="lead pt-2">
<b>evcc</b> ist dient zur flexiblen Ladesteuerung von Elektrofahrzeugen.
</p>
<p class="pt-2">
Es sieht aus, als wäre Dein <b>evcc</b> noch nicht konfiguriert. Um
<b>evcc</b> zu konfigurieren sind die folgenden Schritte notwendig:
</p>
<ol class="pt-2">
<li>
Erzeugen einer Konfigurationsdatei mit Namen
<code>evcc.yaml</code>. Die Standardkonfiguration
<code>evcc.dist.yaml</code> kann dafür als Vorlage dienen (<a
href="https://github.com/andig/evcc/blob/master/evcc.dist.yaml"
>Download</a
>).
</li>
<li>Konfiguration der Wallbox als <code>chargers</code>.</li>
<li>
Konfiguration des EVU Zählers und evtl. weiterer Zähler unter
<code>meters</code>.
</li>
<li>
Konfiguration des Netzanschlusses unter
<code>site</code>. In einer Site wird der Netzanschluss mit dem
konfigurierten EVU Zähler (<code>meter</code>) verbunden.
</li>
<li>
Konfiguration eines Ladepunktes unter
<code>loadpoints</code>. In einem Ladepunkt wird die konfigurierte
Wallbox (<code>charger</code>) mit dem Ladepunkt verbunden.
</li>
<li>
Start von <b>evcc</b> mit der neu erstellten Konfiguration:
<code>evcc -c evcc.yaml</code>
</li>
</ol>
<p>Minimale Beispielkonfiguration für <b>evcc</b>:</p>
<p>
<code>
<pre class="mx-3">
uri: localhost:7070 # Adresse für UI
interval: 10s # Regelintervall
meters:
- name: evu-zähler
type: ... # Detailkonfiguration des EVU Zählers
- name: ladezähler
type: ... # Detailkonfiguration des Ladezählers (optional)
chargers:
- name: wallbox
type: ... # Detailkonfiguration der Wallbox
site:
title: Home
meters:
grid: evu-zähler # EVU Zähler
loadpoints:
- title: Ladepunkt # ui display name
charger: wallbox # charger
meters:
charge: ladezähler # Ladezählers (optional)
</pre
>
</code>
</p>
<p>
Viel Spass mit <b>evcc</b>! Bei Problemen kannst Du uns auf
<a href="https://github.com/andig/evcc/issues">GitHub</a>
erreichen.
</p>
</div>
</div>
</div>
</div>
</template>
<script>
import Site from "../components/Site";
export default {
name: "Main",
components: { Site },
data: function () {
return {
state: this.$root.$data.store.state, // global state
};
},
computed: {
configured: function () {
const val = window.evcc.configured;
// for development purposes
if (val == window.evcc.configured) {
return true;
}
if (!isNaN(parseInt(val)) && parseInt(val) > 0) {
return true;
}
return false;
},
},
};
</script>

3819
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,14 +6,29 @@
"dev": "rimraf dist; parcel watch assets/* --public-url ./dist/",
"build": "rimraf dist; parcel build assets/* --public-url ./dist/",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "prettier assets --write && eslint assets/**/*.js assets/**/*.vue --fix",
"fix-audit": "npm-force-resolutions"
},
"author": "",
"license": "MIT",
"devDependencies": {
"@vue/component-compiler-utils": "^3.2.0",
"@vue/eslint-config-standard": "^5.1.2",
"eslint": "^7.14.0",
"eslint-config-prettier": "^6.15.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-standard": "^4.1.0",
"eslint-plugin-vue": "^7.1.0",
"husky": "^4.3.0",
"lint-staged": "^10.5.1",
"npm-force-resolutions": "0.0.3",
"parcel-bundler": "^1.12.4",
"prettier": "^2.2.0",
"rimraf": "^3.0.2",
"npm-force-resolutions": "0.0.3"
"vue-template-compiler": "^2.6.12"
},
"engines": {
"npm": "^6.0.0",
@ -33,9 +48,23 @@
"jquery": "^3.5.1",
"popper.js": "^1.16.1",
"vue": "^2.6.12",
"vue-hot-reload-api": "^2.3.4",
"vue-router": "^3.4.7"
},
"resolutions": {
"node-forge": "0.10.0"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"assets/**/*.{js,vue,json}": [
"eslint --fix"
],
"assets/**/*.{html,js,vue,json,css}": [
"prettier --write"
]
}
}

File diff suppressed because it is too large Load diff