544 lines
16 KiB
Go
544 lines
16 KiB
Go
package cmd
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
_ "net/http/pprof" // pprof handler
|
||
"os"
|
||
"os/signal"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
"github.com/evcc-io/evcc/api/globalconfig"
|
||
"github.com/evcc-io/evcc/charger/ocpp"
|
||
"github.com/evcc-io/evcc/core"
|
||
"github.com/evcc-io/evcc/core/keys"
|
||
"github.com/evcc-io/evcc/hems/hems"
|
||
"github.com/evcc-io/evcc/messenger"
|
||
"github.com/evcc-io/evcc/server"
|
||
"github.com/evcc-io/evcc/server/db"
|
||
"github.com/evcc-io/evcc/server/eebus"
|
||
"github.com/evcc-io/evcc/server/mcp"
|
||
"github.com/evcc-io/evcc/server/network"
|
||
"github.com/evcc-io/evcc/server/remote"
|
||
"github.com/evcc-io/evcc/server/updater"
|
||
"github.com/evcc-io/evcc/ui"
|
||
"github.com/evcc-io/evcc/util"
|
||
"github.com/evcc-io/evcc/util/auth"
|
||
"github.com/evcc-io/evcc/util/pipe"
|
||
"github.com/evcc-io/evcc/util/sponsor"
|
||
"github.com/evcc-io/evcc/util/telemetry"
|
||
_ "github.com/joho/godotenv/autoload"
|
||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||
"github.com/samber/lo"
|
||
"github.com/spf13/cast"
|
||
"github.com/spf13/cobra"
|
||
vpr "github.com/spf13/viper"
|
||
)
|
||
|
||
const (
|
||
rebootDelay = 15 * time.Minute // delayed reboot on error
|
||
serviceDB = "/var/lib/evcc/evcc.db"
|
||
userDB = "~/.evcc/evcc.db"
|
||
)
|
||
|
||
var (
|
||
log = util.NewLogger("main")
|
||
cfgFile string
|
||
cfgDatabase string
|
||
customCssFile string
|
||
ignoreEmpty = "" // ignore empty keys
|
||
ignoreLogs = []string{"log"} // ignore log messages, including warn/error
|
||
ignoreMqtt = []string{"log", "auth", "releaseNotes"} // excessive size may crash certain brokers
|
||
|
||
viper *vpr.Viper
|
||
|
||
runAsService bool
|
||
)
|
||
|
||
const rootName = "evcc"
|
||
|
||
// rootCmd represents the base command when called without any subcommands
|
||
var rootCmd = &cobra.Command{
|
||
Use: rootName,
|
||
Short: "evcc - open source solar charging",
|
||
Version: util.FormattedVersion(),
|
||
Run: runRoot,
|
||
// always allow Ctrl-C in child commands
|
||
PersistentPreRun: allowCtrlC,
|
||
PersistentPostRun: awaitShutdown,
|
||
}
|
||
|
||
func init() {
|
||
viper = vpr.NewWithOptions(vpr.ExperimentalBindStruct())
|
||
|
||
viper.SetEnvPrefix("evcc")
|
||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||
viper.AutomaticEnv() // read in environment variables that match
|
||
|
||
util.LogLevel("info", nil)
|
||
|
||
cobra.OnInitialize(initConfig)
|
||
|
||
withCustomTemplate(rootCmd)
|
||
|
||
// global options
|
||
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "Config file (default \"~/evcc.yaml\" or \"/etc/evcc.yaml\")")
|
||
rootCmd.PersistentFlags().StringVar(&cfgDatabase, "database", "", "Database location (default \"~/.evcc/evcc.db\")")
|
||
rootCmd.PersistentFlags().BoolP("help", "h", false, "Help")
|
||
rootCmd.PersistentFlags().Bool(flagHeaders, false, flagHeadersDescription)
|
||
rootCmd.PersistentFlags().Bool(flagIgnoreDatabase, false, flagIgnoreDatabaseDescription)
|
||
|
||
// config file options
|
||
rootCmd.PersistentFlags().StringP("log", "l", "info", "Log level (fatal, error, warn, info, debug, trace)")
|
||
bindP(rootCmd, "log")
|
||
|
||
rootCmd.Flags().Bool("metrics", false, "Expose metrics")
|
||
bind(rootCmd, "metrics")
|
||
|
||
rootCmd.Flags().Bool("profile", false, "Expose pprof profiles")
|
||
bind(rootCmd, "profile")
|
||
|
||
rootCmd.Flags().Bool(flagDisableAuth, false, flagDisableAuthDescription)
|
||
rootCmd.Flags().Bool(flagDemoMode, false, flagDemoModeDescription)
|
||
rootCmd.Flags().StringVar(&customCssFile, flagCustomCss, "", flagCustomCssDescription)
|
||
}
|
||
|
||
// initConfig reads in config file and ENV variables if set
|
||
func initConfig() {
|
||
if cfgFile != "" {
|
||
// Use config file from the flag
|
||
viper.SetConfigFile(cfgFile)
|
||
} else {
|
||
// Search for config in home directory if available
|
||
if home, err := os.UserHomeDir(); err == nil {
|
||
viper.AddConfigPath(home)
|
||
}
|
||
|
||
// Search config in home directory with name "mbmd" (without extension).
|
||
viper.AddConfigPath(".") // optionally look for config in the working directory
|
||
viper.AddConfigPath("/etc") // path to look for the config file in
|
||
|
||
viper.SetConfigName("evcc")
|
||
}
|
||
if cfgDatabase != "" {
|
||
viper.Set("Database.Dsn", cfgDatabase)
|
||
}
|
||
}
|
||
|
||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||
func Execute() {
|
||
if err := rootCmd.Execute(); err != nil {
|
||
fmt.Println(err)
|
||
os.Exit(1)
|
||
}
|
||
}
|
||
|
||
func withCustomTemplate(cmd *cobra.Command) {
|
||
cmd.Flags().String(flagTemplate, "", flagTemplateDescription)
|
||
cmd.Flags().String(flagTemplateType, "", flagTemplateTypeDescription)
|
||
}
|
||
|
||
func allowCtrlC(cmd *cobra.Command, args []string) {
|
||
if cmd.Name() == rootName {
|
||
return
|
||
}
|
||
|
||
go func() {
|
||
signalC := make(chan os.Signal, 1)
|
||
signal.Notify(signalC, os.Interrupt, syscall.SIGTERM)
|
||
|
||
<-signalC // wait for signal
|
||
os.Exit(1)
|
||
}()
|
||
}
|
||
|
||
func awaitShutdown(cmd *cobra.Command, args []string) {
|
||
if cmd.Name() == rootName {
|
||
return
|
||
}
|
||
|
||
// wait for shutdown
|
||
<-shutdownDoneC()
|
||
}
|
||
|
||
func runRoot(cmd *cobra.Command, args []string) {
|
||
runAsService = true
|
||
|
||
// print version
|
||
log.INFO.Printf("evcc %s", util.FormattedVersion())
|
||
|
||
// load config and re-configure logging after reading config file
|
||
var err error
|
||
if ok, _ := cmd.Flags().GetBool(flagDemoMode); ok {
|
||
log.INFO.Println("switching into demo mode as requested through the --demo flag")
|
||
if err := demoConfig(&conf); err != nil {
|
||
log.FATAL.Fatal(err)
|
||
}
|
||
} else {
|
||
if cfgErr := loadConfigFile(&conf, !cmd.Flag(flagIgnoreDatabase).Changed); cfgErr != nil {
|
||
// evcc.yaml found, might have errors
|
||
err = wrapErrorWithClass(ClassConfigFile, cfgErr)
|
||
}
|
||
}
|
||
|
||
// setup environment
|
||
if err == nil {
|
||
err = configureEnvironment(cmd, &conf)
|
||
}
|
||
|
||
// configure plugin external url
|
||
if err == nil {
|
||
// network configuration complete, start dependent services like HomeAssistant discovery
|
||
network.Start(conf.Network)
|
||
}
|
||
|
||
// start broadcasting values
|
||
tee := new(util.Tee)
|
||
valueChan := make(chan util.Param, 64)
|
||
go tee.Run(valueChan)
|
||
|
||
// start OCPP and EEBus servers (skipped in degraded mode where setup failed,
|
||
// so a misconfigured instance serves only the offline UI)
|
||
if err == nil {
|
||
cs, ocppErr := ocpp.Instance()
|
||
if ocppErr != nil {
|
||
log.ERROR.Println("ocpp:", ocppErr)
|
||
} else {
|
||
cs.SetUpdated(func() {
|
||
// republish when OCPP state updates
|
||
valueChan <- util.Param{Key: keys.Ocpp, Val: globalconfig.ConfigStatus{
|
||
Config: ocpp.CurrentConfig(),
|
||
Status: ocpp.GetStatus(),
|
||
}}
|
||
})
|
||
log.INFO.Printf("OCPP local url: ws://127.0.0.1:%d/<stationId>", conf.Ocpp.Port)
|
||
if ocpp.ExternalUrl() != "" {
|
||
log.INFO.Printf("OCPP external url: %s/<stationId>", ocpp.ExternalUrl())
|
||
}
|
||
}
|
||
// register the callback even with no rules so runtime additions are pushed
|
||
ocpp.SetForwarderUpdated(func() {
|
||
valueChan <- util.Param{Key: keys.OcppForwarder, Val: globalconfig.ConfigStatus{
|
||
Config: lo.Map(ocpp.ForwarderRules(), func(r ocpp.ForwarderRule, _ int) ocpp.ForwarderRule { return r.Redacted() }),
|
||
Status: ocpp.GetForwarderStatus(),
|
||
}}
|
||
})
|
||
if ocpp.ForwarderEnabled() {
|
||
log.INFO.Printf("OCPP forwarder: %d rule(s) active", len(ocpp.ForwarderRules()))
|
||
}
|
||
|
||
if _, eebusErr := eebus.Instance(); eebusErr != nil {
|
||
log.ERROR.Println("eebus:", eebusErr)
|
||
}
|
||
}
|
||
|
||
// value cache
|
||
cache := util.NewParamCache()
|
||
go cache.Run(pipe.NewDropper(ignoreLogs...).Pipe(tee.Attach()))
|
||
|
||
// create web server
|
||
socketHub := server.NewSocketHub()
|
||
httpd := server.NewHTTPd(fmt.Sprintf(":%d", conf.Network.Port), socketHub, customCssFile)
|
||
|
||
// start serving in background, watch for “routine‐only” errors
|
||
go func() {
|
||
if err := wrapFatalError(httpd.Server.ListenAndServe()); err != nil && err != http.ErrServerClosed {
|
||
log.FATAL.Println(err)
|
||
os.Exit(1)
|
||
}
|
||
}()
|
||
log.INFO.Printf("UI local url: http://127.0.0.1:%d", conf.Network.Port)
|
||
if conf.Network.ExternalUrl != "" {
|
||
log.INFO.Printf("UI external url: %s", conf.Network.ExternalURL())
|
||
}
|
||
|
||
// publish to UI
|
||
go socketHub.Run(pipe.NewDropper(ignoreEmpty).Pipe(tee.Attach()), cache)
|
||
|
||
// remote access tunnel
|
||
remoteAccess := remote.New(util.Getenv("EVCC_REMOTE_ACCESS", "api.evcc.cloud"), httpd.Router(), valueChan)
|
||
|
||
// signal ui listening
|
||
valueChan <- util.Param{Key: keys.StartupCompleted, Val: false}
|
||
valueChan <- util.Param{Key: keys.ApiReady, Val: false}
|
||
|
||
// metrics
|
||
if viper.GetBool("metrics") {
|
||
httpd.Router().Handle("/metrics", promhttp.Handler())
|
||
}
|
||
|
||
// pprof
|
||
if viper.GetBool("profile") {
|
||
httpd.Router().PathPrefix("/debug/").Handler(http.DefaultServeMux)
|
||
}
|
||
|
||
// capture log messages for UI
|
||
util.CaptureLogs(valueChan)
|
||
|
||
// setup telemetry
|
||
if err == nil {
|
||
telemetry.Create(conf.Plant, valueChan)
|
||
if conf.Telemetry {
|
||
err = telemetry.Enable(true)
|
||
}
|
||
}
|
||
|
||
// setup modbus proxy
|
||
if err == nil {
|
||
err = wrapErrorWithClass(ClassModbusProxy, configureModbusProxy(&conf.ModbusProxy))
|
||
}
|
||
|
||
// setup site and loadpoints
|
||
var site *core.Site
|
||
if err == nil {
|
||
site, err = configureSiteAndLoadpoints(&conf)
|
||
}
|
||
|
||
// setup influx
|
||
if err == nil {
|
||
influx, ierr := configureInflux(&conf.Influx)
|
||
if ierr != nil {
|
||
err = wrapErrorWithClass(ClassInflux, ierr)
|
||
}
|
||
|
||
if err == nil && influx != nil {
|
||
// eliminate duplicate values
|
||
dedupe := pipe.NewDeduplicator(30*time.Minute,
|
||
keys.VehicleSoc,
|
||
keys.VehicleRange,
|
||
keys.VehicleOdometer,
|
||
keys.TariffCo2,
|
||
keys.TariffCo2Home,
|
||
keys.TariffCo2Loadpoints,
|
||
keys.TariffFeedIn,
|
||
keys.TariffGrid,
|
||
keys.TariffPriceHome,
|
||
keys.TariffPriceLoadpoints,
|
||
keys.TariffSolar,
|
||
keys.ChargedEnergy,
|
||
keys.ChargeRemainingEnergy)
|
||
go influx.Run(site, dedupe.Pipe(
|
||
pipe.NewDropper(append(ignoreLogs, ignoreEmpty, keys.Forecast)...).Pipe(tee.Attach()),
|
||
))
|
||
}
|
||
}
|
||
|
||
// signal devices initialized
|
||
valueChan <- util.Param{Key: keys.StartupCompleted, Val: true}
|
||
// show onboarding UI
|
||
valueChan <- util.Param{Key: keys.SetupRequired, Val: site == nil || !site.IsConfigured()}
|
||
|
||
// setup mqtt publisher
|
||
if err == nil && conf.Mqtt.Broker != "" && conf.Mqtt.Topic != "" {
|
||
var mqtt *server.MQTT
|
||
mqtt, err = server.NewMQTT(strings.Trim(conf.Mqtt.Topic, "/"), site)
|
||
if err == nil {
|
||
go mqtt.Run(site, pipe.NewDropper(append(ignoreMqtt, ignoreEmpty)...).Pipe(tee.Attach()))
|
||
}
|
||
}
|
||
|
||
// announce on mDNS
|
||
if err == nil {
|
||
if err := configureMDNS(conf.Network); err != nil {
|
||
log.WARN.Println("mDNS:", err)
|
||
}
|
||
}
|
||
|
||
// start SHM server
|
||
if err == nil {
|
||
err = wrapErrorWithClass(ClassSHM, configureSHM(&conf.SHM, conf.Network.ExternalURL(), site, httpd))
|
||
}
|
||
|
||
// start HEMS server
|
||
var hemsInstance hems.API
|
||
if err == nil {
|
||
hemsInstance, err = configureHEMS(&conf.HEMS, site)
|
||
if err != nil {
|
||
err = wrapErrorWithClass(ClassHEMS, err)
|
||
} else if hemsInstance != nil {
|
||
// republish when HEMS state updates
|
||
hemsInstance.SetUpdated(func() {
|
||
valueChan <- util.Param{Key: keys.Hems, Val: globalconfig.ConfigStatus{
|
||
Config: struct {
|
||
Configured bool `json:"configured"`
|
||
}{hemsInstance != nil},
|
||
YamlSource: yamlSource.hems,
|
||
Status: struct {
|
||
Dimmed *bool `json:"dimmed,omitempty"`
|
||
Curtailed *int `json:"curtailed,omitempty"`
|
||
MaxConsumptionPower float64 `json:"maxConsumptionPower,omitempty"`
|
||
MaxProductionPower *float64 `json:"maxProductionPower,omitempty"`
|
||
}{
|
||
Dimmed: hemsInstance.Dimmed(),
|
||
Curtailed: hemsInstance.CurtailedPercent(),
|
||
MaxConsumptionPower: hemsInstance.MaxConsumptionPower(),
|
||
MaxProductionPower: hemsInstance.MaxProductionPower(),
|
||
},
|
||
}}
|
||
})
|
||
}
|
||
}
|
||
|
||
// setup MCP
|
||
if err == nil && isMcp() {
|
||
router := httpd.Router()
|
||
|
||
var handler http.Handler
|
||
if handler, err = mcp.NewHandler(router); err == nil {
|
||
router.PathPrefix("/mcp").Handler(handler)
|
||
}
|
||
}
|
||
if conf.Mcp {
|
||
log.WARN.Println("mcp: yaml config is deprecated")
|
||
}
|
||
|
||
// setup messaging
|
||
var pushChan chan messenger.Event
|
||
if err == nil {
|
||
pushChan, err = configureMessengers(&conf.Messaging, &conf.MessagingEvents, site.Vehicles(), valueChan, cache)
|
||
err = wrapErrorWithClass(ClassMessenger, err)
|
||
}
|
||
|
||
// publish initial settings
|
||
valueChan <- util.Param{Key: keys.DeviceColors, Val: ui.DeviceColorList()}
|
||
valueChan <- util.Param{Key: keys.EEBus, Val: globalconfig.ConfigStatus{
|
||
Config: conf.EEBus.Redacted(),
|
||
Status: eebus.GetStatus(),
|
||
YamlSource: yamlSource.eebus,
|
||
}}
|
||
valueChan <- util.Param{Key: keys.Shm, Val: conf.SHM}
|
||
valueChan <- util.Param{Key: keys.Influx, Val: conf.Influx}
|
||
valueChan <- util.Param{Key: keys.Interval, Val: conf.Interval}
|
||
valueChan <- util.Param{Key: keys.Messaging, Val: globalconfig.ConfigStatus{
|
||
YamlSource: yamlSource.messaging,
|
||
}}
|
||
valueChan <- util.Param{Key: keys.MessagingEvents, Val: conf.MessagingEvents}
|
||
valueChan <- util.Param{Key: keys.ModbusProxy, Val: conf.ModbusProxy}
|
||
valueChan <- util.Param{Key: keys.Mqtt, Val: conf.Mqtt}
|
||
valueChan <- util.Param{Key: keys.Network, Val: conf.Network}
|
||
valueChan <- util.Param{Key: keys.Ocpp, Val: globalconfig.ConfigStatus{
|
||
Config: ocpp.CurrentConfig(),
|
||
Status: ocpp.GetStatus(),
|
||
}}
|
||
valueChan <- util.Param{Key: keys.OcppForwarder, Val: globalconfig.ConfigStatus{
|
||
Config: lo.Map(ocpp.ForwarderRules(), func(r ocpp.ForwarderRule, _ int) ocpp.ForwarderRule { return r.Redacted() }),
|
||
Status: ocpp.GetForwarderStatus(),
|
||
}}
|
||
valueChan <- util.Param{Key: keys.Sponsor, Val: globalconfig.ConfigStatus{
|
||
Status: sponsor.RedactedStatus(),
|
||
YamlSource: yamlSource.sponsor,
|
||
}}
|
||
|
||
valueChan <- util.Param{Key: keys.Hems, Val: globalconfig.ConfigStatus{
|
||
Config: struct {
|
||
Configured bool `json:"configured"`
|
||
}{hemsInstance != nil},
|
||
YamlSource: yamlSource.hems,
|
||
}}
|
||
valueChan <- util.Param{Key: keys.Tariffs, Val: globalconfig.ConfigStatus{
|
||
YamlSource: yamlSource.tariffs,
|
||
}}
|
||
|
||
// publish remote access status
|
||
valueChan <- util.Param{Key: keys.Remote, Val: remoteAccess.ConfigStatus()}
|
||
|
||
// publish system infos
|
||
valueChan <- util.Param{Key: keys.Version, Val: util.FormattedVersion()}
|
||
valueChan <- util.Param{Key: keys.Config, Val: viper.ConfigFileUsed()}
|
||
valueChan <- util.Param{Key: keys.Database, Val: db.FilePath()}
|
||
valueChan <- util.Param{Key: keys.System, Val: util.System()}
|
||
valueChan <- util.Param{Key: keys.Timezone, Val: time.Now().Format("MST -07:00")}
|
||
valueChan <- util.Param{Key: keys.Experimental, Val: isExperimental()}
|
||
valueChan <- util.Param{Key: keys.Optimizer, Val: isOptimizer()}
|
||
valueChan <- util.Param{Key: keys.Mcp, Val: isMcp()}
|
||
|
||
// run shutdown functions on stop
|
||
var once sync.Once
|
||
stopC := make(chan struct{})
|
||
|
||
// catch signals
|
||
go func() {
|
||
signalC := make(chan os.Signal, 1)
|
||
signal.Notify(signalC, os.Interrupt, syscall.SIGTERM)
|
||
|
||
<-signalC // wait for signal
|
||
once.Do(func() { close(stopC) }) // signal loop to end
|
||
}()
|
||
|
||
// allow web access for vehicles
|
||
configureAuth(httpd.Router(), valueChan)
|
||
|
||
authObject := auth.New()
|
||
if ok, _ := cmd.Flags().GetBool(flagDisableAuth); ok {
|
||
log.WARN.Println("❗❗❗ Authentication is disabled. This is dangerous. Your data and credentials are not protected.")
|
||
authObject.SetAuthMode(auth.Disabled)
|
||
valueChan <- util.Param{Key: keys.AuthDisabled, Val: true}
|
||
}
|
||
|
||
if ok, _ := cmd.Flags().GetBool(flagDemoMode); ok {
|
||
log.WARN.Println("Authentication is locked in demo mode. Login-features are disabled.")
|
||
authObject.SetAuthMode(auth.Locked)
|
||
valueChan <- util.Param{Key: keys.DemoMode, Val: true}
|
||
}
|
||
|
||
httpd.RegisterSystemHandler(site, func(k string, v any) {
|
||
valueChan <- util.Param{Key: k, Val: v}
|
||
}, cache, authObject, func() {
|
||
log.INFO.Println("evcc was stopped by user. OS should restart the service. Or restart manually.")
|
||
err = errors.New("restart required") // https://gokrazy.org/development/process-interface/
|
||
once.Do(func() { close(stopC) }) // signal loop to end
|
||
}, viper.ConfigFileUsed(), remoteAccess)
|
||
|
||
// show and check version, reduce api load during development
|
||
if util.Version != util.DevVersion {
|
||
go updater.Run(log, httpd, valueChan)
|
||
}
|
||
|
||
// setup site
|
||
if err == nil {
|
||
// set channels
|
||
site.DumpConfig()
|
||
site.Prepare(valueChan, pushChan)
|
||
|
||
httpd.RegisterSiteHandlers(site)
|
||
|
||
go func() {
|
||
site.Run(stopC, conf.Interval)
|
||
}()
|
||
}
|
||
|
||
// signal HTTP API ready
|
||
valueChan <- util.Param{Key: keys.ApiReady, Val: true}
|
||
|
||
if err != nil {
|
||
if uw, ok := err.(interface{ Unwrap() []error }); ok {
|
||
valueChan <- util.Param{Key: keys.Fatal, Val: uw.Unwrap()}
|
||
} else {
|
||
valueChan <- util.Param{Key: keys.Fatal, Val: []error{wrapFatalError(err)}}
|
||
}
|
||
|
||
// TODO stop reboot loop if user updates config (or show countdown in UI)
|
||
log.FATAL.Println(err)
|
||
log.FATAL.Printf("will attempt restart in: %v", rebootDelay)
|
||
|
||
go func() {
|
||
<-time.After(rebootDelay)
|
||
once.Do(func() { close(stopC) }) // signal loop to end
|
||
}()
|
||
}
|
||
|
||
// wait for shutdown
|
||
<-stopC
|
||
|
||
select {
|
||
case <-shutdownDoneC(): // wait for shutdown
|
||
case <-time.After(conf.Interval):
|
||
}
|
||
|
||
// exit code 1 on error
|
||
os.Exit(cast.ToInt(err != nil))
|
||
}
|