Gracefully handle startup errors (#4383)

This commit is contained in:
andig 2022-09-22 17:18:36 +02:00 • committed by GitHub
parent 80ed0fedb0
commit 479a3edb44
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 692 additions and 415 deletions

View file

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

View file

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

View file

@ -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();

View file

@ -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,

View 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 }}&nbsp;
</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>

View file

@ -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?",

View file

@ -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?",

View file

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

View file

@ -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)

View file

@ -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())

View file

@ -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 {

View file

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

View file

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

View file

@ -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
View 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)
}
}

View file

@ -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) {

View file

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

View file

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

View file

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

View file

@ -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:

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View file

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

View file

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

View file

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