evcc-io/cmd/setup.go
2023-09-03 21:10:47 +02:00

716 lines
17 KiB
Go

package cmd
import (
"cmp"
"context"
"errors"
"fmt"
"net"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"time"
paho "github.com/eclipse/paho.mqtt.golang"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/charger"
"github.com/evcc-io/evcc/charger/eebus"
"github.com/evcc-io/evcc/cmd/shutdown"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/hems"
"github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/provider/golang"
"github.com/evcc-io/evcc/provider/javascript"
"github.com/evcc-io/evcc/provider/mqtt"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/server"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/server/oauth2redirect"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/locale"
"github.com/evcc-io/evcc/util/machine"
"github.com/evcc-io/evcc/util/modbus"
"github.com/evcc-io/evcc/util/pipe"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/sponsor"
"github.com/evcc-io/evcc/util/templates"
"github.com/evcc-io/evcc/vehicle"
"github.com/evcc-io/evcc/vehicle/wrapper"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/libp2p/zeroconf/v2"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/sync/errgroup"
"golang.org/x/text/currency"
)
var conf = globalConfig{
Interval: 10 * time.Second,
Log: "info",
Network: networkConfig{
Schema: "http",
Host: "evcc.local",
Port: 7070,
},
Mqtt: mqttConfig{
Topic: "evcc",
},
Database: dbConfig{
Type: "sqlite",
Dsn: "~/.evcc/evcc.db",
},
}
type globalConfig struct {
URI interface{} // TODO deprecated
Network networkConfig
Log string
SponsorToken string
Plant string // telemetry plant id
Telemetry bool
Metrics bool
Profile bool
Levels map[string]string
Interval time.Duration
Database dbConfig
Mqtt mqttConfig
ModbusProxy []proxyConfig
Javascript []javascriptConfig
Go []goConfig
Influx server.InfluxConfig
EEBus map[string]interface{}
HEMS config.Typed
Messaging messagingConfig
Meters []config.Named
Chargers []config.Named
Vehicles []config.Named
Tariffs tariffConfig
Site map[string]interface{}
Loadpoints []map[string]interface{}
}
type mqttConfig struct {
mqtt.Config `mapstructure:",squash"`
Topic string
}
type javascriptConfig struct {
VM string
Script string
}
type goConfig struct {
VM string
Script string
}
type proxyConfig struct {
Port int
ReadOnly bool
modbus.Settings `mapstructure:",squash"`
}
type dbConfig struct {
Type string
Dsn string
}
type messagingConfig struct {
Events map[string]push.EventTemplateConfig
Services []config.Typed
}
type tariffConfig struct {
Currency string
Grid config.Typed
FeedIn config.Typed
Co2 config.Typed
Planner config.Typed
}
type networkConfig struct {
Schema string
Host string
Port int
}
func (c networkConfig) HostPort() string {
if c.Schema == "http" && c.Port == 80 || c.Schema == "https" && c.Port == 443 {
return c.Host
}
return net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
}
func (c networkConfig) URI() string {
return fmt.Sprintf("%s://%s", c.Schema, c.HostPort())
}
func loadConfigFile(conf *globalConfig) 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)
}
}
// parse log levels after reading config
if err == nil {
parseLogLevels()
}
return err
}
func configureMeters(static []config.Named) error {
for i, cc := range static {
if cc.Name == "" {
return fmt.Errorf("cannot create meter %d: missing name", i+1)
}
instance, err := meter.NewFromConfig(cc.Type, cc.Other)
if err != nil {
return fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)
}
if err := config.Meters().Add(config.NewStaticDevice(cc, instance)); err != nil {
return err
}
}
// append devices from database
configurable, err := config.ConfigurationsByClass(templates.Meter)
if err != nil {
return err
}
for _, conf := range configurable {
cc := conf.Named()
instance, err := meter.NewFromConfig(cc.Type, cc.Other)
if err != nil {
return fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)
}
if err := config.Meters().Add(config.NewConfigurableDevice(conf, instance)); err != nil {
return err
}
}
return nil
}
func configureChargers(static []config.Named) error {
g, _ := errgroup.WithContext(context.Background())
for i, cc := range static {
if cc.Name == "" {
return fmt.Errorf("cannot create charger %d: missing name", i+1)
}
cc := cc
g.Go(func() error {
instance, err := charger.NewFromConfig(cc.Type, cc.Other)
if err != nil {
return fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)
}
return config.Chargers().Add(config.NewStaticDevice(cc, instance))
})
}
// append devices from database
configurable, err := config.ConfigurationsByClass(templates.Charger)
if err != nil {
return err
}
for _, conf := range configurable {
conf := conf
g.Go(func() error {
cc := conf.Named()
instance, err := charger.NewFromConfig(cc.Type, cc.Other)
if err != nil {
return fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)
}
return config.Chargers().Add(config.NewConfigurableDevice(conf, instance))
})
}
return g.Wait()
}
func vehicleInstance(cc config.Named) (api.Vehicle, error) {
instance, err := vehicle.NewFromConfig(cc.Type, cc.Other)
if err != nil {
var ce *util.ConfigError
if errors.As(err, &ce) {
return nil, fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err)
}
// wrap non-config vehicle errors to prevent fatals
log.ERROR.Printf("creating vehicle %s failed: %v", cc.Name, err)
instance = wrapper.New(cc.Name, cc.Other, err)
}
// ensure vehicle config has title
if instance.Title() == "" {
//lint:ignore SA1019 as Title is safe on ascii
instance.SetTitle(strings.Title(cc.Name))
}
return instance, nil
}
func configureVehicles(static []config.Named) error {
var mu sync.Mutex
g, _ := errgroup.WithContext(context.Background())
// stable-sort vehicles by name
devs1 := make([]config.Device[api.Vehicle], 0, len(static))
for i, cc := range static {
if cc.Name == "" {
return fmt.Errorf("cannot create vehicle %d: missing name", i+1)
}
cc := cc
g.Go(func() error {
instance, err := vehicleInstance(cc)
if err != nil {
return fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err)
}
mu.Lock()
defer mu.Unlock()
devs1 = append(devs1, config.NewStaticDevice(cc, instance))
return nil
})
}
// append devices from database
configurable, err := config.ConfigurationsByClass(templates.Vehicle)
if err != nil {
return err
}
// stable-sort vehicles by id
devs2 := make([]config.ConfigurableDevice[api.Vehicle], 0, len(configurable))
for _, conf := range configurable {
conf := conf
g.Go(func() error {
cc := conf.Named()
instance, err := vehicleInstance(cc)
if err != nil {
return fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err)
}
mu.Lock()
defer mu.Unlock()
devs2 = append(devs2, config.NewConfigurableDevice(conf, instance))
return nil
})
}
if err := g.Wait(); err != nil {
return err
}
slices.SortFunc(devs1, func(i, j config.Device[api.Vehicle]) int {
return cmp.Compare(strings.ToLower(i.Config().Name), strings.ToLower(j.Config().Name))
})
for _, dev := range devs1 {
if err := config.Vehicles().Add(dev); err != nil {
return err
}
}
slices.SortFunc(devs2, func(i, j config.ConfigurableDevice[api.Vehicle]) int {
return cmp.Compare(i.ID(), j.ID())
})
for _, dev := range devs2 {
if err := config.Vehicles().Add(dev); err != nil {
return err
}
}
return nil
}
func configureEnvironment(cmd *cobra.Command, conf globalConfig) (err error) {
// full http request log
if cmd.Flags().Lookup(flagHeaders).Changed {
request.LogHeaders = true
}
// setup machine id
if conf.Plant != "" {
err = machine.CustomID(conf.Plant)
}
// setup sponsorship (allow env override)
if err == nil && conf.SponsorToken != "" {
err = sponsor.ConfigureSponsorship(conf.SponsorToken)
}
// setup translations
if err == nil {
err = locale.Init()
}
// setup persistence
if err == nil && conf.Database.Dsn != "" {
err = configureDatabase(conf.Database)
}
// setup mqtt client listener
if err == nil && conf.Mqtt.Broker != "" {
err = configureMQTT(conf.Mqtt)
}
// setup javascript VMs
if err == nil {
err = configureJavascript(conf.Javascript)
}
// setup go VMs
if err == nil {
err = configureGo(conf.Go)
}
// setup EEBus server
if err == nil && conf.EEBus != nil {
err = configureEEBus(conf.EEBus)
}
// setup config database
if err == nil {
err = config.Init(db.Instance)
}
return
}
// configureDatabase configures session database
func configureDatabase(conf dbConfig) error {
if err := db.NewInstance(conf.Type, conf.Dsn); err != nil {
return err
}
if err := settings.Init(); err != nil {
return err
}
persistSettings := func() {
if err := settings.Persist(); err != nil {
log.ERROR.Println("cannot save settings:", err)
}
}
// persist unsaved settings on shutdown
shutdown.Register(persistSettings)
// persist unsaved settings every 30 minutes
go func() {
for range time.Tick(30 * time.Minute) {
persistSettings()
}
}()
return nil
}
// configureInflux configures influx database
func configureInflux(conf server.InfluxConfig, site site.API, in <-chan util.Param) {
influx := server.NewInfluxClient(
conf.URL,
conf.Token,
conf.Org,
conf.User,
conf.Password,
conf.Database,
)
// eliminate duplicate values
dedupe := pipe.NewDeduplicator(30*time.Minute, "vehicleCapacity", "vehicleSoc", "vehicleRange", "vehicleOdometer", "chargedEnergy", "chargeRemainingEnergy")
in = dedupe.Pipe(in)
go influx.Run(site, in)
}
// setup mqtt
func configureMQTT(conf mqttConfig) error {
log := util.NewLogger("mqtt")
instance, err := mqtt.RegisteredClient(log, conf.Broker, conf.User, conf.Password, conf.ClientID, 1, conf.Insecure, func(options *paho.ClientOptions) {
topic := fmt.Sprintf("%s/status", strings.Trim(conf.Topic, "/"))
options.SetWill(topic, "offline", 1, true)
oc := options.OnConnect
options.SetOnConnectHandler(func(client paho.Client) {
oc(client) // original handler
_ = client.Publish(topic, 1, true, "online") // alive - not logged
})
})
if err != nil {
return fmt.Errorf("failed configuring mqtt: %w", err)
}
mqtt.Instance = instance
return nil
}
// setup javascript
func configureJavascript(conf []javascriptConfig) error {
for _, cc := range conf {
if _, err := javascript.RegisteredVM(cc.VM, cc.Script); err != nil {
return fmt.Errorf("failed configuring javascript: %w", err)
}
}
return nil
}
// setup go
func configureGo(conf []goConfig) error {
for _, cc := range conf {
if _, err := golang.RegisteredVM(cc.VM, cc.Script); err != nil {
return fmt.Errorf("failed configuring go: %w", err)
}
}
return nil
}
// setup HEMS
func configureHEMS(conf config.Typed, site *core.Site, httpd *server.HTTPd) error {
hems, err := hems.NewFromConfig(conf.Type, conf.Other, site, httpd)
if err != nil {
return fmt.Errorf("failed configuring hems: %w", err)
}
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 eebus.Instance, err = eebus.NewServer(conf); err != nil {
return fmt.Errorf("failed configuring eebus: %w", err)
}
eebus.Instance.Run()
shutdown.Register(eebus.Instance.Shutdown)
return nil
}
// setup messaging
func configureMessengers(conf messagingConfig, valueChan chan util.Param, cache *util.Cache) (chan push.Event, error) {
messageChan := make(chan push.Event, 1)
messageHub, err := push.NewHub(conf.Events, cache)
if err != nil {
return messageChan, fmt.Errorf("failed configuring push services: %w", err)
}
for _, service := range conf.Services {
impl, err := push.NewFromConfig(service.Type, service.Other)
if err != nil {
return messageChan, fmt.Errorf("failed configuring push service %s: %w", service.Type, err)
}
messageHub.Add(impl)
}
go messageHub.Run(messageChan, valueChan)
return messageChan, nil
}
func configureTariffs(conf tariffConfig) (tariff.Tariffs, error) {
var grid, feedin, co2, planner api.Tariff
var currencyCode currency.Unit = currency.EUR
var err error
if conf.Currency != "" {
currencyCode = currency.MustParseISO(conf.Currency)
}
if conf.Grid.Type != "" {
grid, err = tariff.NewFromConfig(conf.Grid.Type, conf.Grid.Other)
if err != nil {
grid = nil
log.ERROR.Printf("failed configuring grid tariff: %v", err)
}
}
if conf.FeedIn.Type != "" {
feedin, err = tariff.NewFromConfig(conf.FeedIn.Type, conf.FeedIn.Other)
if err != nil {
feedin = nil
log.ERROR.Printf("failed configuring feed-in tariff: %v", err)
}
}
if conf.Co2.Type != "" {
co2, err = tariff.NewFromConfig(conf.Co2.Type, conf.Co2.Other)
if err != nil {
co2 = nil
log.ERROR.Printf("failed configuring co2 tariff: %v", err)
}
}
if conf.Planner.Type != "" {
planner, err = tariff.NewFromConfig(conf.Planner.Type, conf.Planner.Other)
if err != nil {
planner = nil
log.ERROR.Printf("failed configuring planner tariff: %v", err)
} else if planner.Type() == api.TariffTypeCo2 {
log.WARN.Printf("tariff configuration changed, use co2 instead of planner for co2 tariff")
}
}
tariffs := tariff.NewTariffs(currencyCode, grid, feedin, co2, planner)
return *tariffs, nil
}
func configureDevices(conf globalConfig) error {
if err := configureMeters(conf.Meters); err != nil {
return err
}
if err := configureChargers(conf.Chargers); err != nil {
return err
}
return configureVehicles(conf.Vehicles)
}
func configureSiteAndLoadpoints(conf globalConfig) (*core.Site, error) {
if err := configureDevices(conf); err != nil {
return nil, err
}
loadpoints, err := configureLoadpoints(conf)
if err != nil {
return nil, fmt.Errorf("failed configuring loadpoints: %w", err)
}
tariffs, err := configureTariffs(conf.Tariffs)
if err != nil {
return nil, err
}
return configureSite(conf.Site, loadpoints, config.Instances(config.Vehicles().Devices()), tariffs)
}
func configureSite(conf map[string]interface{}, loadpoints []*core.Loadpoint, vehicles []api.Vehicle, tariffs tariff.Tariffs) (*core.Site, error) {
site, err := core.NewSiteFromConfig(log, conf, loadpoints, vehicles, tariffs)
if err != nil {
return nil, fmt.Errorf("failed configuring site: %w", err)
}
return site, nil
}
func configureLoadpoints(conf globalConfig) (loadpoints []*core.Loadpoint, err error) {
lpInterfaces, ok := viper.AllSettings()["loadpoints"].([]interface{})
if !ok || len(lpInterfaces) == 0 {
return nil, errors.New("missing loadpoints")
}
for id, lpcI := range lpInterfaces {
var lpc map[string]interface{}
if err := util.DecodeOther(lpcI, &lpc); err != nil {
return nil, fmt.Errorf("failed decoding loadpoint configuration: %w", err)
}
log := util.NewLogger("lp-" + strconv.Itoa(id+1))
lp, err := core.NewLoadpointFromConfig(log, lpc)
if err != nil {
return nil, fmt.Errorf("failed configuring loadpoint: %w", err)
}
loadpoints = append(loadpoints, lp)
}
return loadpoints, nil
}
// configureAuth handles routing for devices. For now only api.AuthProvider related routes
func configureAuth(conf networkConfig, vehicles []api.Vehicle, router *mux.Router, paramC chan<- util.Param) {
auth := router.PathPrefix("/oauth").Subrouter()
auth.Use(handlers.CompressHandler)
auth.Use(handlers.CORS(
handlers.AllowedHeaders([]string{"Content-Type"}),
))
// wire the handler
oauth2redirect.SetupRouter(auth)
// initialize
authCollection := util.NewAuthCollection(paramC)
baseURI := conf.URI()
baseAuthURI := fmt.Sprintf("%s/oauth", baseURI)
var id int
for _, v := range vehicles {
if provider, ok := v.(api.AuthProvider); ok {
id += 1
basePath := fmt.Sprintf("vehicles/%d", id)
callbackURI := fmt.Sprintf("%s/%s/callback", baseAuthURI, basePath)
// register vehicle
ap := authCollection.Register(fmt.Sprintf("oauth/%s", basePath), v.Title())
provider.SetCallbackParams(baseURI, callbackURI, ap.Handler())
auth.
Methods(http.MethodPost).
Path(fmt.Sprintf("/%s/login", basePath)).
HandlerFunc(provider.LoginHandler())
auth.
Methods(http.MethodPost).
Path(fmt.Sprintf("/%s/logout", basePath)).
HandlerFunc(provider.LogoutHandler())
log.INFO.Printf("ensure the oauth client redirect/callback is configured for %s: %s", v.Title(), callbackURI)
}
}
authCollection.Publish()
}