Gracefully handle startup errors (#4383)
This commit is contained in:
parent
80ed0fedb0
commit
479a3edb44
27 changed files with 692 additions and 415 deletions
|
|
@ -145,6 +145,13 @@ a:hover {
|
|||
background-color: var(--evcc-accent3);
|
||||
border-color: var(--evcc-accent3);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
color: inherit !important;
|
||||
background-color: inherit !important;
|
||||
border-color: inherit !important;
|
||||
}
|
||||
|
||||
.btn-outline-primary,
|
||||
.btn-outline-primary:focus {
|
||||
color: var(--bs-primary);
|
||||
|
|
|
|||
|
|
@ -13,11 +13,9 @@ const api = axios.create({
|
|||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.config.url !== "health") {
|
||||
const url = error.config.baseURL + error.config.url;
|
||||
const message = `${error.message}: API request failed ${url}`;
|
||||
window.app.error({ message });
|
||||
}
|
||||
const url = error.config.baseURL + error.config.url;
|
||||
const message = `${error.message}: API request failed ${url}`;
|
||||
window.app.error({ message });
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import smoothscroll from "smoothscroll-polyfill";
|
|||
import "../css/app.css";
|
||||
import { createApp, h } from "vue";
|
||||
import { createMetaManager, plugin as metaPlugin } from "vue-meta";
|
||||
import api from "./api";
|
||||
import App from "./views/App.vue";
|
||||
import VueNumber from "vue-number-animation";
|
||||
import router from "./router";
|
||||
|
|
@ -73,10 +72,4 @@ app.use(featureflags);
|
|||
app.use(VueNumber);
|
||||
window.app = app.mount("#app");
|
||||
|
||||
window.setInterval(function () {
|
||||
if (!document.hidden) {
|
||||
api.get("health").then(window.app.setOnline).catch(window.app.setOffline);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
watchThemeChanges();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
<template>
|
||||
<div class="d-flex flex-column site">
|
||||
<OfflineIndicator v-if="offline" />
|
||||
|
||||
<div class="container px-4 top-area">
|
||||
<div class="d-flex justify-content-between align-items-center my-3">
|
||||
<h1 class="d-block my-0">
|
||||
|
|
@ -29,7 +27,6 @@
|
|||
<script>
|
||||
import "@h2d2/shopicons/es/regular/arrowup";
|
||||
import TopNavigation from "./TopNavigation.vue";
|
||||
import OfflineIndicator from "./OfflineIndicator.vue";
|
||||
import Notifications from "./Notifications.vue";
|
||||
import Energyflow from "./Energyflow/Energyflow.vue";
|
||||
import Loadpoints from "./Loadpoints.vue";
|
||||
|
|
@ -44,7 +41,6 @@ export default {
|
|||
Loadpoints,
|
||||
Energyflow,
|
||||
Footer,
|
||||
OfflineIndicator,
|
||||
Notifications,
|
||||
TopNavigation,
|
||||
Vehicles,
|
||||
|
|
|
|||
142
assets/js/components/StartupError.vue
Normal file
142
assets/js/components/StartupError.vue
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
<template>
|
||||
<div class="container px-4">
|
||||
<div class="d-flex justify-content-between align-items-center my-3">
|
||||
<h1 class="d-block mt-0 d-flex">
|
||||
{{ $t("startupError.title") }}
|
||||
<shopicon-regular-car1 size="m" class="ms-2 icon"></shopicon-regular-car1>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<code class="fs-6 mb-3">
|
||||
<div v-for="(error, index) in errors" :key="index">{{ error }}</div>
|
||||
</code>
|
||||
<i18n-t tag="p" keypath="startupError.description">
|
||||
<a href="https://github.com/evcc-io/evcc/discussions">
|
||||
{{ $t("startupError.discussions") }}
|
||||
</a>
|
||||
</i18n-t>
|
||||
<p>
|
||||
<em>{{ $t("startupError.hint") }}</em>
|
||||
</p>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<h5 class="mb-3">{{ $t("startupError.configuration") }}</h5>
|
||||
<div class="d-md-flex justify-content-between">
|
||||
<p class="me-md-4">
|
||||
<span class="d-block">
|
||||
{{ $t("startupError.configFile") }}
|
||||
<code>{{ file }}</code>
|
||||
</span>
|
||||
<i18n-t v-if="line" tag="span" keypath="startupError.lineError">
|
||||
<a :href="`#line${line}`" @click.prevent="scrollTo">{{
|
||||
$t("startupError.lineErrorLink", [line])
|
||||
}}</a>
|
||||
</i18n-t>
|
||||
{{ $t("startupError.fixAndRestart") }}
|
||||
</p>
|
||||
<p>
|
||||
<button
|
||||
class="btn btn-primary text-nowrap"
|
||||
type="button"
|
||||
:disabled="offline"
|
||||
@click="shutdown"
|
||||
>
|
||||
{{ $t("startupError.restartButton") }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<code v-if="config">
|
||||
<div class="my-2"></div>
|
||||
<div class="py-2 text-muted config">
|
||||
<div
|
||||
v-for="(configLine, lineNumber) in config.split('\n')"
|
||||
:id="`line${lineNumber + 1}`"
|
||||
:key="lineNumber"
|
||||
class="m-0 px-2"
|
||||
:class="{
|
||||
highlighted: line === lineNumber + 1,
|
||||
}"
|
||||
>
|
||||
{{ configLine }}
|
||||
</div>
|
||||
</div>
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/regular/car1";
|
||||
import api from "../api";
|
||||
import collector from "../mixins/collector";
|
||||
|
||||
export default {
|
||||
name: "StartupError",
|
||||
mixins: [collector],
|
||||
props: {
|
||||
fatal: Array,
|
||||
config: String,
|
||||
file: String,
|
||||
line: Number,
|
||||
offline: Boolean,
|
||||
},
|
||||
computed: {
|
||||
errors() {
|
||||
return this.fatal || [];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
shutdown() {
|
||||
api.post("shutdown");
|
||||
},
|
||||
scrollTo(e) {
|
||||
const id = e.currentTarget.getAttribute("href").substring(1);
|
||||
const el = document.getElementById(id);
|
||||
console.log({ id, el });
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.highlighted {
|
||||
position: relative;
|
||||
color: var(--bs-code-color) !important;
|
||||
}
|
||||
.highlighted:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.2em;
|
||||
border-top: 0.5em solid transparent;
|
||||
border-bottom: 0.5em solid transparent;
|
||||
border-left: 0.5em solid var(--bs-code-color);
|
||||
}
|
||||
.config {
|
||||
max-width: 100%;
|
||||
overflow-x: scroll;
|
||||
white-space: pre;
|
||||
}
|
||||
.config {
|
||||
border: 1px solid var(--bs-gray-400);
|
||||
}
|
||||
.icon {
|
||||
transform-origin: 60% 40%;
|
||||
animation: swinging 3.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes swinging {
|
||||
0% {
|
||||
transform: translateY(8%) rotate(170deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(8%) rotate(185deg);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(8%) rotate(170deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -168,6 +168,19 @@ export default {
|
|||
`,
|
||||
},
|
||||
},
|
||||
startupError: {
|
||||
title: "Fehler beim Starten",
|
||||
description:
|
||||
"Bitte überprüfe deine Konfigurationsdatei. Sollte dir die Fehlermeldung nicht weiterhelfen, suche in unseren {0} nach einer Lösung.",
|
||||
discussions: "GitHub Discussions",
|
||||
hint: "Hinweis: Ein weiterer Grund, warum du diese Meldung siehst, könnte ein fehlerhaftes Gerät (Wechselrichter, Zähler, ...) sein. Überprüfe deine Netzwerkverbindungen.",
|
||||
configuration: "Konfiguration",
|
||||
configFile: "Verwendete Konfigurationsdatei:",
|
||||
lineError: "In {0} wurde ein Fehler gefunden.",
|
||||
lineErrorLink: "Zeile {0}",
|
||||
fixAndRestart: "Behebe das Problem und starte den Server neu.",
|
||||
restartButton: "Server neu starten",
|
||||
},
|
||||
offline: {
|
||||
message: "Keine Verbindung zum Server.",
|
||||
reload: "Reload?",
|
||||
|
|
|
|||
|
|
@ -167,6 +167,19 @@ export default {
|
|||
`,
|
||||
},
|
||||
},
|
||||
startupError: {
|
||||
title: "Startup Error",
|
||||
description:
|
||||
"Please check your configuration file. If the error message does not help you, have a look at our {0}.",
|
||||
discussions: "GitHub Discussions",
|
||||
hint: "Note: Another reason why you see this message could be a faulty device (inverter, meter, ...). Check your network connections.",
|
||||
configuration: "Config",
|
||||
configFile: "Configuration file used:",
|
||||
lineError: "We found an error in {0}.",
|
||||
lineErrorLink: "line {0}",
|
||||
fixAndRestart: "Fix the problem and restart the server.",
|
||||
restartButton: "Restart server",
|
||||
},
|
||||
offline: {
|
||||
message: "No connection to server.",
|
||||
reload: "Reload?",
|
||||
|
|
|
|||
|
|
@ -1,96 +1,21 @@
|
|||
<template>
|
||||
<div>
|
||||
<Site
|
||||
v-if="configured"
|
||||
:notifications="notifications"
|
||||
:offline="offline"
|
||||
v-bind="state"
|
||||
></Site>
|
||||
<div v-else class="container">
|
||||
<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/evcc-io/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/evcc-io/evcc/issues">GitHub</a>
|
||||
erreichen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OfflineIndicator v-if="offline" />
|
||||
|
||||
<StartupError v-if="startupErrors" v-bind="state" :offline="offline" />
|
||||
<Site v-else :notifications="notifications" v-bind="state"></Site>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Site from "../components/Site.vue";
|
||||
import StartupError from "../components/StartupError.vue";
|
||||
import OfflineIndicator from "../components/OfflineIndicator.vue";
|
||||
import store from "../store";
|
||||
|
||||
export default {
|
||||
name: "Main",
|
||||
components: { Site },
|
||||
components: { Site, StartupError, OfflineIndicator },
|
||||
props: {
|
||||
notifications: Array,
|
||||
offline: Boolean,
|
||||
|
|
@ -99,16 +24,8 @@ export default {
|
|||
return store;
|
||||
},
|
||||
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;
|
||||
startupErrors: function () {
|
||||
return this.state.fatal?.length > 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ func runCharger(cmd *cobra.Command, args []string) {
|
|||
log.INFO.Printf("evcc %s", server.FormattedVersion())
|
||||
|
||||
// load config
|
||||
if err := loadConfigFile(cfgFile, &conf); err != nil {
|
||||
if err := loadConfigFile(&conf); err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -68,8 +68,12 @@ func runCharger(cmd *cobra.Command, args []string) {
|
|||
|
||||
chargers := cp.chargers
|
||||
if len(args) == 1 {
|
||||
arg := args[0]
|
||||
chargers = map[string]api.Charger{arg: cp.Charger(arg)}
|
||||
name := args[0]
|
||||
charger, err := cp.Charger(name)
|
||||
if err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
chargers = map[string]api.Charger{name: charger}
|
||||
}
|
||||
|
||||
current := int64(noCurrent)
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ func runChargerRamp(cmd *cobra.Command, args []string) {
|
|||
log.INFO.Printf("evcc %s", server.FormattedVersion())
|
||||
|
||||
// load config
|
||||
if err := loadConfigFile(cfgFile, &conf); err != nil {
|
||||
if err := loadConfigFile(&conf); err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -104,8 +104,12 @@ func runChargerRamp(cmd *cobra.Command, args []string) {
|
|||
|
||||
chargers := cp.chargers
|
||||
if len(args) == 1 {
|
||||
arg := args[0]
|
||||
chargers = map[string]api.Charger{arg: cp.Charger(arg)}
|
||||
name := args[0]
|
||||
charger, err := cp.Charger(name)
|
||||
if err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
chargers = map[string]api.Charger{name: charger}
|
||||
}
|
||||
|
||||
digits, err := strconv.Atoi(cmd.PersistentFlags().Lookup(flagDigits).Value.String())
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ func (cp *ConfigProvider) TrackVisitors() {
|
|||
}
|
||||
|
||||
// Meter provides meters by name
|
||||
func (cp *ConfigProvider) Meter(name string) api.Meter {
|
||||
func (cp *ConfigProvider) Meter(name string) (api.Meter, error) {
|
||||
if meter, ok := cp.meters[name]; ok {
|
||||
// track duplicate usage https://github.com/evcc-io/evcc/issues/1744
|
||||
if cp.visited != nil {
|
||||
|
|
@ -121,28 +121,25 @@ func (cp *ConfigProvider) Meter(name string) api.Meter {
|
|||
cp.visited[name] = true
|
||||
}
|
||||
|
||||
return meter
|
||||
return meter, nil
|
||||
}
|
||||
log.FATAL.Fatalf("invalid meter: %s", name)
|
||||
return nil
|
||||
return nil, fmt.Errorf("meter does not exist: %s", name)
|
||||
}
|
||||
|
||||
// Charger provides chargers by name
|
||||
func (cp *ConfigProvider) Charger(name string) api.Charger {
|
||||
func (cp *ConfigProvider) Charger(name string) (api.Charger, error) {
|
||||
if charger, ok := cp.chargers[name]; ok {
|
||||
return charger
|
||||
return charger, nil
|
||||
}
|
||||
log.FATAL.Fatalf("invalid charger: %s", name)
|
||||
return nil
|
||||
return nil, fmt.Errorf("charger does not exist: %s", name)
|
||||
}
|
||||
|
||||
// Vehicle provides vehicles by name
|
||||
func (cp *ConfigProvider) Vehicle(name string) api.Vehicle {
|
||||
func (cp *ConfigProvider) Vehicle(name string) (api.Vehicle, error) {
|
||||
if vehicle, ok := cp.vehicles[name]; ok {
|
||||
return vehicle
|
||||
return vehicle, nil
|
||||
}
|
||||
log.FATAL.Fatalf("invalid vehicle: %s", name)
|
||||
return nil
|
||||
return nil, fmt.Errorf("vehicle does not exist: %s", name)
|
||||
}
|
||||
|
||||
func (cp *ConfigProvider) configure(conf config) error {
|
||||
|
|
|
|||
23
cmd/dump.go
23
cmd/dump.go
|
|
@ -21,12 +21,19 @@ func init() {
|
|||
rootCmd.AddCommand(dumpCmd)
|
||||
}
|
||||
|
||||
func handle(device any, err error) any {
|
||||
if err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
return device
|
||||
}
|
||||
|
||||
func runDump(cmd *cobra.Command, args []string) {
|
||||
util.LogLevel(viper.GetString("log"), viper.GetStringMapString("levels"))
|
||||
log.INFO.Printf("evcc %s", server.FormattedVersion())
|
||||
|
||||
// load config
|
||||
if err := loadConfigFile(cfgFile, &conf); err != nil {
|
||||
if err := loadConfigFile(&conf); err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -46,29 +53,29 @@ func runDump(cmd *cobra.Command, args []string) {
|
|||
fmt.Println("")
|
||||
|
||||
if name := site.Meters.GridMeterRef; name != "" {
|
||||
d.DumpWithHeader(fmt.Sprintf("grid: %s", name), cp.Meter(name))
|
||||
d.DumpWithHeader(fmt.Sprintf("grid: %s", name), handle(cp.Meter(name)))
|
||||
}
|
||||
|
||||
if len(site.Meters.PVMetersRef) == 0 {
|
||||
if name := site.Meters.PVMeterRef; name != "" {
|
||||
d.DumpWithHeader(fmt.Sprintf("pv: %s", name), cp.Meter(name))
|
||||
d.DumpWithHeader(fmt.Sprintf("pv: %s", name), handle(cp.Meter(name)))
|
||||
}
|
||||
} else {
|
||||
for id, name := range site.Meters.PVMetersRef {
|
||||
if name != "" {
|
||||
d.DumpWithHeader(fmt.Sprintf("pv %d: %s", id, name), cp.Meter(name))
|
||||
d.DumpWithHeader(fmt.Sprintf("pv %d: %s", id, name), handle(cp.Meter(name)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(site.Meters.BatteryMetersRef) == 0 {
|
||||
if name := site.Meters.BatteryMeterRef; name != "" {
|
||||
d.DumpWithHeader(fmt.Sprintf("battery: %s", name), cp.Meter(name))
|
||||
d.DumpWithHeader(fmt.Sprintf("battery: %s", name), handle(cp.Meter(name)))
|
||||
}
|
||||
} else {
|
||||
for id, name := range site.Meters.BatteryMetersRef {
|
||||
if name != "" {
|
||||
d.DumpWithHeader(fmt.Sprintf("battery %d: %s", id, name), cp.Meter(name))
|
||||
d.DumpWithHeader(fmt.Sprintf("battery %d: %s", id, name), handle(cp.Meter(name)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -84,11 +91,11 @@ func runDump(cmd *cobra.Command, args []string) {
|
|||
fmt.Println("")
|
||||
|
||||
if name := lp.MeterRef; name != "" {
|
||||
d.DumpWithHeader(fmt.Sprintf("charge: %s", name), cp.Meter(name))
|
||||
d.DumpWithHeader(fmt.Sprintf("charge: %s", name), handle(cp.Meter(name)))
|
||||
}
|
||||
|
||||
if name := lp.ChargerRef; name != "" {
|
||||
d.DumpWithHeader(fmt.Sprintf("charger: %s", name), cp.Charger(name))
|
||||
d.DumpWithHeader(fmt.Sprintf("charger: %s", name), handle(cp.Charger(name)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
cmd/meter.go
10
cmd/meter.go
|
|
@ -29,7 +29,7 @@ func runMeter(cmd *cobra.Command, args []string) {
|
|||
log.INFO.Printf("evcc %s", server.FormattedVersion())
|
||||
|
||||
// load config
|
||||
if err := loadConfigFile(cfgFile, &conf); err != nil {
|
||||
if err := loadConfigFile(&conf); err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -54,8 +54,12 @@ func runMeter(cmd *cobra.Command, args []string) {
|
|||
|
||||
meters := cp.meters
|
||||
if len(args) == 1 {
|
||||
arg := args[0]
|
||||
meters = map[string]api.Meter{arg: cp.Meter(arg)}
|
||||
name := args[0]
|
||||
meter, err := cp.Meter(name)
|
||||
if err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
meters = map[string]api.Meter{name: meter}
|
||||
}
|
||||
|
||||
d := dumper{len: len(meters)}
|
||||
|
|
|
|||
251
cmd/root.go
251
cmd/root.go
|
|
@ -1,24 +1,29 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
_ "net/http/pprof" // pprof handler
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/cmd/shutdown"
|
||||
"github.com/evcc-io/evcc/core"
|
||||
"github.com/evcc-io/evcc/push"
|
||||
"github.com/evcc-io/evcc/server"
|
||||
"github.com/evcc-io/evcc/server/updater"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/pipe"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/evcc-io/evcc/util/sponsor"
|
||||
"github.com/libp2p/zeroconf/v2"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -29,8 +34,8 @@ var (
|
|||
log = util.NewLogger("main")
|
||||
cfgFile string
|
||||
|
||||
ignoreErrors = []string{"warn", "error", "fatal"} // don't add to cache
|
||||
ignoreMqtt = []string{"auth", "releaseNotes"} // excessive size may crash certain brokers
|
||||
ignoreErrors = []string{"warn", "error"} // don't add to cache
|
||||
ignoreMqtt = []string{"auth", "releaseNotes"} // excessive size may crash certain brokers
|
||||
)
|
||||
|
||||
var conf = config{
|
||||
|
|
@ -58,7 +63,7 @@ func bind(cmd *cobra.Command, flag string) {
|
|||
func configureCommand(cmd *cobra.Command) {
|
||||
cmd.PersistentFlags().StringP(
|
||||
"log", "l",
|
||||
"error",
|
||||
"info",
|
||||
"Log level (fatal, error, warn, info, debug, trace)",
|
||||
)
|
||||
bind(cmd, "log")
|
||||
|
|
@ -115,19 +120,6 @@ func initConfig() {
|
|||
}
|
||||
|
||||
viper.AutomaticEnv() // read in environment variables that match
|
||||
|
||||
// If a config file is found, read it in
|
||||
if err := viper.ReadInConfig(); err == nil {
|
||||
// using config file
|
||||
cfgFile = viper.ConfigFileUsed()
|
||||
} else if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
// parsing failed - exit
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
} else {
|
||||
// not using config file
|
||||
cfgFile = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
|
|
@ -138,18 +130,84 @@ func Execute() {
|
|||
}
|
||||
}
|
||||
|
||||
var valueChan chan util.Param
|
||||
|
||||
func publish(key string, val any) {
|
||||
valueChan <- util.Param{Key: key, Val: val}
|
||||
}
|
||||
|
||||
func unwrap(err error) (res []string) {
|
||||
for err != nil {
|
||||
inner := errors.Unwrap(err)
|
||||
if inner == nil {
|
||||
res = append(res, err.Error())
|
||||
} else {
|
||||
cur := strings.TrimSuffix(err.Error(), ": "+inner.Error())
|
||||
cur = strings.TrimSuffix(cur, inner.Error())
|
||||
res = append(res, strings.TrimSpace(cur))
|
||||
}
|
||||
err = inner
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func redact(src string) string {
|
||||
secrets := []string{
|
||||
"url", "uri", "host", "broker", // infrastructure
|
||||
"user", "password", // users
|
||||
"token", "access", "refresh", "sponsortoken", // tokens
|
||||
"ain", "id", "secret", "serial", "deviceid", "machineid", // devices
|
||||
"vin"} // vehicles
|
||||
return regexp.
|
||||
MustCompile(fmt.Sprintf(`\b(%s)\b.*?:.*`, strings.Join(secrets, "|"))).
|
||||
ReplaceAllString(src, "$1: *****")
|
||||
}
|
||||
|
||||
func publishErrorInfo(cfgFile string, err error) {
|
||||
if cfgFile != "" {
|
||||
file, pathErr := filepath.Abs(cfgFile)
|
||||
if pathErr != nil {
|
||||
file = cfgFile
|
||||
}
|
||||
publish("file", file)
|
||||
|
||||
if src, fileErr := os.ReadFile(cfgFile); fileErr != nil {
|
||||
log.ERROR.Println("could not open config file:", fileErr)
|
||||
} else {
|
||||
publish("config", redact(string(src)))
|
||||
|
||||
// find line number
|
||||
if match := regexp.MustCompile(`yaml: line (\d+):`).FindStringSubmatch(err.Error()); len(match) == 2 {
|
||||
if line, err := strconv.Atoi(match[1]); err == nil {
|
||||
publish("line", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publish("fatal", unwrap(err))
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, args []string) {
|
||||
util.LogLevel(viper.GetString("log"), viper.GetStringMapString("levels"))
|
||||
log.INFO.Printf("evcc %s", server.FormattedVersion())
|
||||
|
||||
// load config and re-configure logging after reading config file
|
||||
if err := loadConfigFile(cfgFile, &conf); err != nil {
|
||||
log.ERROR.Println("missing evcc config - switching into demo mode")
|
||||
var err error
|
||||
if cfgErr := loadConfigFile(&conf); errors.As(cfgErr, &viper.ConfigFileNotFoundError{}) {
|
||||
log.INFO.Println("missing config file - switching into demo mode")
|
||||
demoConfig(&conf)
|
||||
} else {
|
||||
err = cfgErr
|
||||
}
|
||||
|
||||
util.LogLevel(viper.GetString("log"), viper.GetStringMapString("levels"))
|
||||
|
||||
// full http request log
|
||||
if cmd.PersistentFlags().Lookup(flagHeaders).Changed {
|
||||
request.LogHeaders = true
|
||||
}
|
||||
|
||||
// network config
|
||||
if viper.GetString("uri") != "" {
|
||||
log.WARN.Println("`uri` is deprecated and will be ignored. Use `network` instead.")
|
||||
|
|
@ -161,55 +219,16 @@ func run(cmd *cobra.Command, args []string) {
|
|||
|
||||
log.INFO.Printf("listening at :%d", conf.Network.Port)
|
||||
|
||||
// setup environment
|
||||
if err := configureEnvironment(conf); err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
|
||||
// full http request log
|
||||
if cmd.PersistentFlags().Lookup(flagHeaders).Changed {
|
||||
request.LogHeaders = true
|
||||
}
|
||||
|
||||
// setup loadpoints
|
||||
cp.TrackVisitors() // track duplicate usage
|
||||
|
||||
site, err := configureSiteAndLoadpoints(conf)
|
||||
if err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
|
||||
// start broadcasting values
|
||||
tee := &util.Tee{}
|
||||
tee := new(util.Tee)
|
||||
|
||||
// value cache
|
||||
cache := util.NewCache()
|
||||
go cache.Run(pipe.NewDropper(ignoreErrors...).Pipe(tee.Attach()))
|
||||
|
||||
// setup database
|
||||
if conf.Influx.URL != "" {
|
||||
configureDatabase(conf.Influx, site.LoadPoints(), tee.Attach())
|
||||
}
|
||||
|
||||
// setup mqtt publisher
|
||||
if conf.Mqtt.Broker != "" {
|
||||
publisher := server.NewMQTT(conf.Mqtt.RootTopic())
|
||||
go publisher.Run(site, pipe.NewDropper(ignoreMqtt...).Pipe(tee.Attach()))
|
||||
}
|
||||
|
||||
// create webserver
|
||||
// create web server
|
||||
socketHub := server.NewSocketHub()
|
||||
httpd := server.NewHTTPd(fmt.Sprintf(":%d", conf.Network.Port), site, socketHub, cache)
|
||||
|
||||
// announce webserver on mDNS
|
||||
if strings.HasSuffix(conf.Network.Host, ".local") {
|
||||
host := strings.TrimSuffix(conf.Network.Host, ".local")
|
||||
if zc, err := zeroconf.RegisterProxy("EV Charge Controller", "_http._tcp", "local.", conf.Network.Port, host, nil, []string{}, nil); err == nil {
|
||||
shutdown.Register(zc.Shutdown)
|
||||
} else {
|
||||
log.ERROR.Printf("mDNS announcement: %s", err)
|
||||
}
|
||||
}
|
||||
httpd := server.NewHTTPd(fmt.Sprintf(":%d", conf.Network.Port), socketHub)
|
||||
|
||||
// metrics
|
||||
if viper.GetBool("metrics") {
|
||||
|
|
@ -221,48 +240,108 @@ func run(cmd *cobra.Command, args []string) {
|
|||
httpd.Router().PathPrefix("/debug/").Handler(http.DefaultServeMux)
|
||||
}
|
||||
|
||||
// start HEMS server
|
||||
if conf.HEMS.Type != "" {
|
||||
hems := configureHEMS(conf.HEMS, site, httpd)
|
||||
go hems.Run()
|
||||
}
|
||||
|
||||
// publish to UI
|
||||
go socketHub.Run(tee.Attach(), cache)
|
||||
|
||||
// setup values channel
|
||||
valueChan := make(chan util.Param)
|
||||
valueChan = make(chan util.Param)
|
||||
go tee.Run(valueChan)
|
||||
|
||||
// expose sponsor to UI
|
||||
if sponsor.Subject != "" {
|
||||
valueChan <- util.Param{Key: "sponsor", Val: sponsor.Subject}
|
||||
// setup environment
|
||||
if err == nil {
|
||||
err = configureEnvironment(conf)
|
||||
}
|
||||
|
||||
// allow web access for vehicles
|
||||
cp.webControl(conf.Network, httpd.Router(), valueChan)
|
||||
// setup site and loadpoints
|
||||
var site *core.Site
|
||||
if err == nil {
|
||||
cp.TrackVisitors() // track duplicate usage
|
||||
site, err = configureSiteAndLoadpoints(conf)
|
||||
}
|
||||
|
||||
// version check
|
||||
go updater.Run(log, httpd, tee, valueChan)
|
||||
// setup database
|
||||
if err == nil && conf.Influx.URL != "" {
|
||||
configureDatabase(conf.Influx, site.LoadPoints(), tee.Attach())
|
||||
}
|
||||
|
||||
// capture log messages for UI
|
||||
util.CaptureLogs(valueChan)
|
||||
// setup mqtt publisher
|
||||
if err == nil && conf.Mqtt.Broker != "" {
|
||||
publisher := server.NewMQTT(conf.Mqtt.RootTopic())
|
||||
go publisher.Run(site, pipe.NewDropper(ignoreMqtt...).Pipe(tee.Attach()))
|
||||
}
|
||||
|
||||
// announce on mDNS
|
||||
if err == nil && strings.HasSuffix(conf.Network.Host, ".local") {
|
||||
err = configureMDNS(conf.Network)
|
||||
}
|
||||
|
||||
// start HEMS server
|
||||
if err == nil && conf.HEMS.Type != "" {
|
||||
err = configureHEMS(conf.HEMS, site, httpd)
|
||||
}
|
||||
|
||||
// setup messaging
|
||||
pushChan := configureMessengers(conf.Messaging, cache)
|
||||
|
||||
// set channels
|
||||
site.DumpConfig()
|
||||
site.Prepare(valueChan, pushChan)
|
||||
var pushChan chan push.Event
|
||||
if err == nil {
|
||||
pushChan, err = configureMessengers(conf.Messaging, cache)
|
||||
}
|
||||
|
||||
stopC := make(chan struct{})
|
||||
go shutdown.Run(stopC)
|
||||
|
||||
siteC := make(chan struct{})
|
||||
go func() {
|
||||
site.Run(stopC, conf.Interval)
|
||||
close(siteC)
|
||||
}()
|
||||
|
||||
// show main ui
|
||||
if err == nil {
|
||||
httpd.RegisterSiteHandlers(site, cache)
|
||||
|
||||
// set channels
|
||||
site.DumpConfig()
|
||||
site.Prepare(valueChan, pushChan)
|
||||
|
||||
// version check
|
||||
go updater.Run(log, httpd, tee, valueChan)
|
||||
|
||||
// capture log messages for UI
|
||||
util.CaptureLogs(valueChan)
|
||||
|
||||
// expose sponsor to UI
|
||||
if sponsor.Subject != "" {
|
||||
publish("sponsor", sponsor.Subject)
|
||||
}
|
||||
|
||||
// allow web access for vehicles
|
||||
cp.webControl(conf.Network, httpd.Router(), valueChan)
|
||||
|
||||
go func() {
|
||||
site.Run(stopC, conf.Interval)
|
||||
close(siteC)
|
||||
}()
|
||||
} else {
|
||||
var once sync.Once
|
||||
httpd.RegisterShutdownHandler(func() {
|
||||
once.Do(func() {
|
||||
log.FATAL.Println("evcc was stopped. OS should restart the service. Or restart manually.")
|
||||
close(siteC)
|
||||
})
|
||||
})
|
||||
|
||||
// delayed reboot on error
|
||||
const rebootDelay = 5 * time.Minute
|
||||
|
||||
log.FATAL.Println(err)
|
||||
log.FATAL.Printf("will attempt restart in: %v", rebootDelay)
|
||||
|
||||
publishErrorInfo(cfgFile, err)
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-time.After(rebootDelay):
|
||||
case <-siteC:
|
||||
}
|
||||
os.Exit(1)
|
||||
}()
|
||||
}
|
||||
|
||||
// uds health check listener
|
||||
go server.HealthListener(site, siteC)
|
||||
|
|
|
|||
39
cmd/root_test.go
Normal file
39
cmd/root_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnwrap(t *testing.T) {
|
||||
err := fmt.Errorf("foo: %w", fmt.Errorf("bar %w", errors.New("baz")))
|
||||
|
||||
res := unwrap(err)
|
||||
if exp := []string{"foo", "bar", "baz"}; !reflect.DeepEqual(res, exp) {
|
||||
t.Errorf("expected %v, got %v", exp, res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedact(t *testing.T) {
|
||||
secret := `
|
||||
# sponsor token is a public token
|
||||
sponsortoken: geheim
|
||||
user: geheim
|
||||
password: geheim
|
||||
secret: geheim
|
||||
token:
|
||||
access: geheim
|
||||
refresh: geheim
|
||||
host: geheim
|
||||
url: geheim
|
||||
secret: geheim # comment
|
||||
secret : geheim
|
||||
`
|
||||
|
||||
if res := redact(secret); strings.Contains(res, "geheim") || !strings.Contains(res, "public") {
|
||||
t.Errorf("secret exposed: %v", res)
|
||||
}
|
||||
}
|
||||
74
cmd/setup.go
74
cmd/setup.go
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
paho "github.com/eclipse/paho.mqtt.golang"
|
||||
|
|
@ -22,6 +23,7 @@ import (
|
|||
"github.com/evcc-io/evcc/util/pipe"
|
||||
"github.com/evcc-io/evcc/util/sponsor"
|
||||
"github.com/evcc-io/evcc/util/telemetry"
|
||||
"github.com/libp2p/zeroconf/v2"
|
||||
"github.com/samber/lo"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/text/currency"
|
||||
|
|
@ -33,14 +35,19 @@ func init() {
|
|||
|
||||
var cp = new(ConfigProvider)
|
||||
|
||||
func loadConfigFile(cfgFile string, conf *config) (err error) {
|
||||
if cfgFile != "" {
|
||||
log.INFO.Println("using config file", cfgFile)
|
||||
if err := viper.UnmarshalExact(&conf); err != nil {
|
||||
log.FATAL.Fatalf("failed parsing config file %s: %v", cfgFile, err)
|
||||
func loadConfigFile(conf *config) error {
|
||||
err := viper.ReadInConfig()
|
||||
|
||||
if cfgFile = viper.ConfigFileUsed(); cfgFile == "" {
|
||||
return err
|
||||
}
|
||||
|
||||
log.INFO.Println("using config file:", cfgFile)
|
||||
|
||||
if err == nil {
|
||||
if err = viper.UnmarshalExact(&conf); err != nil {
|
||||
err = fmt.Errorf("failed parsing config file: %w", err)
|
||||
}
|
||||
} else {
|
||||
err = errors.New("missing evcc config")
|
||||
}
|
||||
|
||||
return err
|
||||
|
|
@ -123,47 +130,64 @@ func configureJavascript(conf map[string]interface{}) error {
|
|||
}
|
||||
|
||||
// setup HEMS
|
||||
func configureHEMS(conf typedConfig, site *core.Site, httpd *server.HTTPd) hems.HEMS {
|
||||
func configureHEMS(conf typedConfig, site *core.Site, httpd *server.HTTPd) error {
|
||||
hems, err := hems.NewFromConfig(conf.Type, conf.Other, site, httpd)
|
||||
if err != nil {
|
||||
log.FATAL.Fatalf("failed configuring hems: %v", err)
|
||||
return fmt.Errorf("failed configuring hems: %w", err)
|
||||
}
|
||||
return hems
|
||||
|
||||
go hems.Run()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setup MDNS
|
||||
func configureMDNS(conf networkConfig) error {
|
||||
host := strings.TrimSuffix(conf.Host, ".local")
|
||||
|
||||
zc, err := zeroconf.RegisterProxy("EV Charge Controller", "_http._tcp", "local.", conf.Port, host, nil, []string{}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mDNS announcement: %w", err)
|
||||
}
|
||||
|
||||
shutdown.Register(zc.Shutdown)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setup EEBus
|
||||
func configureEEBus(conf map[string]interface{}) error {
|
||||
var err error
|
||||
if server.EEBusInstance, err = server.NewEEBus(conf); err == nil {
|
||||
go server.EEBusInstance.Run()
|
||||
shutdown.Register(server.EEBusInstance.Shutdown)
|
||||
} else {
|
||||
err = fmt.Errorf("eebus: %w", err)
|
||||
if server.EEBusInstance, err = server.NewEEBus(conf); err != nil {
|
||||
return fmt.Errorf("failed configuring eebus: %w", err)
|
||||
}
|
||||
|
||||
return err
|
||||
go server.EEBusInstance.Run()
|
||||
shutdown.Register(server.EEBusInstance.Shutdown)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setup messaging
|
||||
func configureMessengers(conf messagingConfig, cache *util.Cache) chan push.Event {
|
||||
notificationChan := make(chan push.Event, 1)
|
||||
notificationHub, err := push.NewHub(conf.Events, cache)
|
||||
func configureMessengers(conf messagingConfig, cache *util.Cache) (chan push.Event, error) {
|
||||
messageChan := make(chan push.Event, 1)
|
||||
|
||||
messageHub, err := push.NewHub(conf.Events, cache)
|
||||
if err != nil {
|
||||
log.FATAL.Fatalf("failed configuring push services: %v", err)
|
||||
return messageChan, fmt.Errorf("failed configuring push services: %w", err)
|
||||
}
|
||||
|
||||
for _, service := range conf.Services {
|
||||
impl, err := push.NewMessengerFromConfig(service.Type, service.Other)
|
||||
if err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
log.FATAL.Fatalf("failed configuring messenger %s: %v", service.Type, err)
|
||||
return messageChan, fmt.Errorf("failed configuring push service %s: %w", service.Type, err)
|
||||
}
|
||||
notificationHub.Add(impl)
|
||||
messageHub.Add(impl)
|
||||
}
|
||||
|
||||
go notificationHub.Run(notificationChan)
|
||||
go messageHub.Run(messageChan)
|
||||
|
||||
return notificationChan
|
||||
return messageChan, nil
|
||||
}
|
||||
|
||||
func configureTariffs(conf tariffConfig) (tariff.Tariffs, error) {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ func runToken(cmd *cobra.Command, args []string) {
|
|||
log.INFO.Printf("evcc %s", server.FormattedVersion())
|
||||
|
||||
// load config
|
||||
if err := loadConfigFile(cfgFile, &conf); err != nil {
|
||||
if err := loadConfigFile(&conf); err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ func runVehicle(cmd *cobra.Command, args []string) {
|
|||
log.INFO.Printf("evcc %s", server.FormattedVersion())
|
||||
|
||||
// load config
|
||||
if err := loadConfigFile(cfgFile, &conf); err != nil {
|
||||
if err := loadConfigFile(&conf); err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
|
||||
|
|
@ -57,8 +57,12 @@ func runVehicle(cmd *cobra.Command, args []string) {
|
|||
|
||||
vehicles := cp.vehicles
|
||||
if len(args) == 1 {
|
||||
arg := args[0]
|
||||
vehicles = map[string]api.Vehicle{arg: cp.Vehicle(arg)}
|
||||
name := args[0]
|
||||
vehicle, err := cp.Vehicle(name)
|
||||
if err != nil {
|
||||
log.FATAL.Fatal(err)
|
||||
}
|
||||
vehicles = map[string]api.Vehicle{name: vehicle}
|
||||
}
|
||||
|
||||
d := dumper{len: len(vehicles)}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import "github.com/evcc-io/evcc/api"
|
|||
|
||||
// configProvider gives access to configuration repository
|
||||
type configProvider interface {
|
||||
Meter(string) api.Meter
|
||||
Charger(string) api.Charger
|
||||
Vehicle(string) api.Vehicle
|
||||
Meter(string) (api.Meter, error)
|
||||
Charger(string) (api.Charger, error)
|
||||
Vehicle(string) (api.Vehicle, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,12 +208,18 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin
|
|||
lp.collectDefaults()
|
||||
|
||||
if lp.MeterRef != "" {
|
||||
lp.chargeMeter = cp.Meter(lp.MeterRef)
|
||||
var err error
|
||||
if lp.chargeMeter, err = cp.Meter(lp.MeterRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// default vehicle
|
||||
if lp.VehicleRef != "" {
|
||||
lp.defaultVehicle = cp.Vehicle(lp.VehicleRef)
|
||||
var err error
|
||||
if lp.defaultVehicle, err = cp.Vehicle(lp.VehicleRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// TODO deprecated
|
||||
|
|
@ -224,7 +230,10 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin
|
|||
if lp.ChargerRef == "" {
|
||||
return nil, errors.New("missing charger")
|
||||
}
|
||||
lp.charger = cp.Charger(lp.ChargerRef)
|
||||
var err error
|
||||
if lp.charger, err = cp.Charger(lp.ChargerRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lp.configureChargerType(lp.charger)
|
||||
|
||||
// setup fixed phases:
|
||||
|
|
|
|||
25
core/site.go
25
core/site.go
|
|
@ -95,12 +95,18 @@ func NewSiteFromConfig(
|
|||
}
|
||||
|
||||
if site.Meters.GridMeterRef != "" {
|
||||
site.gridMeter = cp.Meter(site.Meters.GridMeterRef)
|
||||
var err error
|
||||
if site.gridMeter, err = cp.Meter(site.Meters.GridMeterRef); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// multiple pv
|
||||
for _, ref := range site.Meters.PVMetersRef {
|
||||
pv := cp.Meter(ref)
|
||||
pv, err := cp.Meter(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
site.pvMeters = append(site.pvMeters, pv)
|
||||
}
|
||||
|
||||
|
|
@ -109,13 +115,19 @@ func NewSiteFromConfig(
|
|||
if len(site.pvMeters) > 0 {
|
||||
return nil, errors.New("cannot have pv and pvs both")
|
||||
}
|
||||
pv := cp.Meter(site.Meters.PVMeterRef)
|
||||
pv, err := cp.Meter(site.Meters.PVMeterRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
site.pvMeters = append(site.pvMeters, pv)
|
||||
}
|
||||
|
||||
// multiple batteries
|
||||
for _, ref := range site.Meters.BatteryMetersRef {
|
||||
battery := cp.Meter(ref)
|
||||
battery, err := cp.Meter(ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
site.batteryMeters = append(site.batteryMeters, battery)
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +136,10 @@ func NewSiteFromConfig(
|
|||
if len(site.batteryMeters) > 0 {
|
||||
return nil, errors.New("cannot have battery and batteries both")
|
||||
}
|
||||
battery := cp.Meter(site.Meters.BatteryMeterRef)
|
||||
battery, err := cp.Meter(site.Meters.BatteryMeterRef)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
site.batteryMeters = append(site.batteryMeters, battery)
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
79
dist/assets/index.69a8c963.js
vendored
Normal file
79
dist/assets/index.69a8c963.js
vendored
Normal file
File diff suppressed because one or more lines are too long
97
dist/assets/index.fd078ce0.js
vendored
97
dist/assets/index.fd078ce0.js
vendored
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
|
|
@ -20,8 +20,8 @@
|
|||
<meta name="theme-color" content="#020318" />
|
||||
|
||||
<title>evcc</title>
|
||||
<script type="module" crossorigin src="./assets/index.fd078ce0.js"></script>
|
||||
<link rel="stylesheet" href="./assets/index.9f475f7d.css">
|
||||
<script type="module" crossorigin src="./assets/index.69a8c963.js"></script>
|
||||
<link rel="stylesheet" href="./assets/index.682a2576.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
125
server/http.go
125
server/http.go
|
|
@ -43,15 +43,7 @@ type HTTPd struct {
|
|||
}
|
||||
|
||||
// NewHTTPd creates HTTP server with configured routes for loadpoint
|
||||
func NewHTTPd(addr string, site site.API, hub *SocketHub, cache *util.Cache) *HTTPd {
|
||||
routes := map[string]route{
|
||||
"health": {[]string{"GET"}, "/health", healthHandler(site)},
|
||||
"state": {[]string{"GET"}, "/state", stateHandler(cache)},
|
||||
"buffersoc": {[]string{"POST", "OPTIONS"}, "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoC, site.GetBufferSoC)},
|
||||
"prioritysoc": {[]string{"POST", "OPTIONS"}, "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoC, site.GetPrioritySoC)},
|
||||
"residualpower": {[]string{"POST", "OPTIONS"}, "/residualpower/{value:[-0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
|
||||
}
|
||||
|
||||
func NewHTTPd(addr string, hub *SocketHub) *HTTPd {
|
||||
router := mux.NewRouter().StrictSlash(true)
|
||||
|
||||
// websocket
|
||||
|
|
@ -61,48 +53,11 @@ func NewHTTPd(addr string, site site.API, hub *SocketHub, cache *util.Cache) *HT
|
|||
static := router.PathPrefix("/").Subrouter()
|
||||
static.Use(handlers.CompressHandler)
|
||||
|
||||
static.HandleFunc("/", indexHandler(site))
|
||||
static.HandleFunc("/", indexHandler())
|
||||
for _, dir := range []string{"assets", "meta"} {
|
||||
static.PathPrefix("/" + dir).Handler(http.FileServer(http.FS(Assets)))
|
||||
}
|
||||
|
||||
// api
|
||||
api := router.PathPrefix("/api").Subrouter()
|
||||
api.Use(jsonHandler)
|
||||
api.Use(handlers.CompressHandler)
|
||||
api.Use(handlers.CORS(
|
||||
handlers.AllowedHeaders([]string{"Content-Type"}),
|
||||
))
|
||||
|
||||
// site api
|
||||
for _, r := range routes {
|
||||
api.Methods(r.Methods...).Path(r.Pattern).Handler(r.HandlerFunc)
|
||||
}
|
||||
|
||||
// loadpoint api
|
||||
for id, lp := range site.LoadPoints() {
|
||||
lpAPI := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id)).Subrouter()
|
||||
|
||||
routes := map[string]route{
|
||||
"mode": {[]string{"POST", "OPTIONS"}, "/mode/{value:[a-z]+}", chargeModeHandler(lp)},
|
||||
"targetsoc": {[]string{"POST", "OPTIONS"}, "/targetsoc/{value:[0-9]+}", intHandler(pass(lp.SetTargetSoC), lp.GetTargetSoC)},
|
||||
"minsoc": {[]string{"POST", "OPTIONS"}, "/minsoc/{value:[0-9]+}", intHandler(pass(lp.SetMinSoC), lp.GetMinSoC)},
|
||||
"mincurrent": {[]string{"POST", "OPTIONS"}, "/mincurrent/{value:[0-9]+}", floatHandler(pass(lp.SetMinCurrent), lp.GetMinCurrent)},
|
||||
"maxcurrent": {[]string{"POST", "OPTIONS"}, "/maxcurrent/{value:[0-9]+}", floatHandler(pass(lp.SetMaxCurrent), lp.GetMaxCurrent)},
|
||||
"phases": {[]string{"POST", "OPTIONS"}, "/phases/{value:[0-9]+}", phasesHandler(lp)},
|
||||
"targetcharge": {[]string{"POST", "OPTIONS"}, "/targetcharge/{soc:[0-9]+}/{time:[0-9TZ:.-]+}", targetChargeHandler(lp)},
|
||||
"targetcharge2": {[]string{"DELETE", "OPTIONS"}, "/targetcharge", targetChargeRemoveHandler(lp)},
|
||||
"vehicle": {[]string{"POST", "OPTIONS"}, "/vehicle/{vehicle:[0-9]+}", vehicleHandler(site, lp)},
|
||||
"vehicle2": {[]string{"DELETE", "OPTIONS"}, "/vehicle", vehicleRemoveHandler(lp)},
|
||||
"vehicleDetect": {[]string{"PATCH", "OPTIONS"}, "/vehicle", vehicleDetectHandler(lp)},
|
||||
"remotedemand": {[]string{"POST", "OPTIONS"}, "/remotedemand/{demand:[a-z]+}/{source::[0-9a-zA-Z_-]+}", remoteDemandHandler(lp)},
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
lpAPI.Methods(r.Methods...).Path(r.Pattern).Handler(r.HandlerFunc)
|
||||
}
|
||||
}
|
||||
|
||||
srv := &HTTPd{
|
||||
Server: &http.Server{
|
||||
Addr: addr,
|
||||
|
|
@ -122,3 +77,79 @@ func NewHTTPd(addr string, site site.API, hub *SocketHub, cache *util.Cache) *HT
|
|||
func (s *HTTPd) Router() *mux.Router {
|
||||
return s.Handler.(*mux.Router)
|
||||
}
|
||||
|
||||
// RegisterSiteHandlers connects the http handlers to the site
|
||||
func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
|
||||
router := s.Server.Handler.(*mux.Router)
|
||||
|
||||
// api
|
||||
api := router.PathPrefix("/api").Subrouter()
|
||||
api.Use(jsonHandler)
|
||||
api.Use(handlers.CompressHandler)
|
||||
api.Use(handlers.CORS(
|
||||
handlers.AllowedHeaders([]string{"Content-Type"}),
|
||||
))
|
||||
|
||||
// site api
|
||||
routes := map[string]route{
|
||||
"health": {[]string{"GET"}, "/health", healthHandler(site)},
|
||||
"state": {[]string{"GET"}, "/state", stateHandler(cache)},
|
||||
"buffersoc": {[]string{"POST", "OPTIONS"}, "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoC, site.GetBufferSoC)},
|
||||
"prioritysoc": {[]string{"POST", "OPTIONS"}, "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoC, site.GetPrioritySoC)},
|
||||
"residualpower": {[]string{"POST", "OPTIONS"}, "/residualpower/{value:[-0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
api.Methods(r.Methods...).Path(r.Pattern).Handler(r.HandlerFunc)
|
||||
}
|
||||
|
||||
// loadpoint api
|
||||
for id, lp := range site.LoadPoints() {
|
||||
loadpoint := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id)).Subrouter()
|
||||
|
||||
routes := map[string]route{
|
||||
"mode": {[]string{"POST", "OPTIONS"}, "/mode/{value:[a-z]+}", chargeModeHandler(lp)},
|
||||
"targetsoc": {[]string{"POST", "OPTIONS"}, "/targetsoc/{value:[0-9]+}", intHandler(pass(lp.SetTargetSoC), lp.GetTargetSoC)},
|
||||
"minsoc": {[]string{"POST", "OPTIONS"}, "/minsoc/{value:[0-9]+}", intHandler(pass(lp.SetMinSoC), lp.GetMinSoC)},
|
||||
"mincurrent": {[]string{"POST", "OPTIONS"}, "/mincurrent/{value:[0-9]+}", floatHandler(pass(lp.SetMinCurrent), lp.GetMinCurrent)},
|
||||
"maxcurrent": {[]string{"POST", "OPTIONS"}, "/maxcurrent/{value:[0-9]+}", floatHandler(pass(lp.SetMaxCurrent), lp.GetMaxCurrent)},
|
||||
"phases": {[]string{"POST", "OPTIONS"}, "/phases/{value:[0-9]+}", phasesHandler(lp)},
|
||||
"targetcharge": {[]string{"POST", "OPTIONS"}, "/targetcharge/{soc:[0-9]+}/{time:[0-9TZ:.-]+}", targetChargeHandler(lp)},
|
||||
"targetcharge2": {[]string{"DELETE", "OPTIONS"}, "/targetcharge", targetChargeRemoveHandler(lp)},
|
||||
"vehicle": {[]string{"POST", "OPTIONS"}, "/vehicle/{vehicle:[0-9]+}", vehicleHandler(site, lp)},
|
||||
"vehicle2": {[]string{"DELETE", "OPTIONS"}, "/vehicle", vehicleRemoveHandler(lp)},
|
||||
"vehicleDetect": {[]string{"PATCH", "OPTIONS"}, "/vehicle", vehicleDetectHandler(lp)},
|
||||
"remotedemand": {[]string{"POST", "OPTIONS"}, "/remotedemand/{demand:[a-z]+}/{source::[0-9a-zA-Z_-]+}", remoteDemandHandler(lp)},
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
loadpoint.Methods(r.Methods...).Path(r.Pattern).Handler(r.HandlerFunc)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// RegisterShutdownHandler connects the http handlers to the site
|
||||
func (s *HTTPd) RegisterShutdownHandler(callback func()) {
|
||||
router := s.Server.Handler.(*mux.Router)
|
||||
|
||||
// api
|
||||
api := router.PathPrefix("/api").Subrouter()
|
||||
api.Use(jsonHandler)
|
||||
api.Use(handlers.CompressHandler)
|
||||
api.Use(handlers.CORS(
|
||||
handlers.AllowedHeaders([]string{"Content-Type"}),
|
||||
))
|
||||
|
||||
// site api
|
||||
routes := map[string]route{
|
||||
"shutdown": {[]string{"POST", "OPTIONS"}, "/shutdown", func(w http.ResponseWriter, r *http.Request) {
|
||||
callback()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}},
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
api.Methods(r.Methods...).Path(r.Pattern).Handler(r.HandlerFunc)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import (
|
|||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func indexHandler(site site.API) http.HandlerFunc {
|
||||
func indexHandler() http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
|
||||
|
||||
|
|
@ -34,9 +34,8 @@ func indexHandler(site site.API) http.HandlerFunc {
|
|||
}
|
||||
|
||||
if err := t.Execute(w, map[string]interface{}{
|
||||
"Version": Version,
|
||||
"Commit": Commit,
|
||||
"Configured": len(site.LoadPoints()),
|
||||
"Version": Version,
|
||||
"Commit": Commit,
|
||||
}); err != nil {
|
||||
log.ERROR.Println("httpd: failed to render main page:", err.Error())
|
||||
}
|
||||
|
|
@ -70,7 +69,7 @@ func jsonError(w http.ResponseWriter, status int, err error) {
|
|||
// healthHandler returns current charge mode
|
||||
func healthHandler(site site.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !site.Healthy() {
|
||||
if site == nil || !site.Healthy() {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue