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 customization server.Customization 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) // UI customization, flags default to environment variables rootCmd.Flags().StringVar(&customization.Css, flagCustomCss, os.Getenv("EVCC_CUSTOM_CSS"), flagCustomCssDescription) rootCmd.Flags().StringVar(&customization.LogoLight, flagCustomLogoLight, os.Getenv("EVCC_CUSTOM_LOGO_LIGHT"), flagCustomLogoLightDescription) rootCmd.Flags().StringVar(&customization.LogoDark, flagCustomLogoDark, os.Getenv("EVCC_CUSTOM_LOGO_DARK"), flagCustomLogoDarkDescription) rootCmd.Flags().StringVar(&customization.Brand, flagCustomBrand, os.Getenv("EVCC_CUSTOM_BRAND"), flagCustomBrandDescription) rootCmd.Flags().StringVar(&customization.Website, flagCustomWebsite, os.Getenv("EVCC_CUSTOM_WEBSITE"), flagCustomWebsiteDescription) rootCmd.Flags().StringVar(&customization.Email, flagCustomEmail, os.Getenv("EVCC_CUSTOM_EMAIL"), flagCustomEmailDescription) rootCmd.Flags().StringVar(&customization.Phone, flagCustomPhone, os.Getenv("EVCC_CUSTOM_PHONE"), flagCustomPhoneDescription) rootCmd.Flags().StringVar(&customization.Theme, flagCustomTheme, os.Getenv("EVCC_CUSTOM_THEME"), flagCustomThemeDescription) } // 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/", conf.Ocpp.Port) if ocpp.ExternalUrl() != "" { log.INFO.Printf("OCPP external url: %s/", 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, customization) // 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: hems.Dimmed(hemsInstance), Curtailed: hemsInstance.CurtailedPercent(), MaxConsumptionPower: hemsInstance.MaxConsumptionPower(), MaxProductionPower: hemsInstance.MaxProductionPower(), }, }} }) } } // all device skis are registered, unknown ones may now be denied eebus.ConfigComplete() // 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()) 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 }() authObject := auth.New() // allow web access for vehicles configureAuth(httpd.Router(), server.EnsureAuthHandler(authObject), valueChan) 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)) }