1528 lines
38 KiB
Go
1528 lines
38 KiB
Go
package cmd
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
paho "github.com/eclipse/paho.mqtt.golang"
|
|
"github.com/evcc-io/evcc/api"
|
|
"github.com/evcc-io/evcc/api/globalconfig"
|
|
"github.com/evcc-io/evcc/charger"
|
|
"github.com/evcc-io/evcc/charger/ocpp"
|
|
"github.com/evcc-io/evcc/cmd/shutdown"
|
|
"github.com/evcc-io/evcc/core"
|
|
"github.com/evcc-io/evcc/core/circuit"
|
|
"github.com/evcc-io/evcc/core/keys"
|
|
"github.com/evcc-io/evcc/core/loadpoint"
|
|
"github.com/evcc-io/evcc/core/metrics"
|
|
coresettings "github.com/evcc-io/evcc/core/settings"
|
|
"github.com/evcc-io/evcc/hems"
|
|
hemsapi "github.com/evcc-io/evcc/hems/hems"
|
|
"github.com/evcc-io/evcc/hems/shm"
|
|
"github.com/evcc-io/evcc/messenger"
|
|
"github.com/evcc-io/evcc/meter"
|
|
"github.com/evcc-io/evcc/plugin/golang"
|
|
"github.com/evcc-io/evcc/plugin/javascript"
|
|
"github.com/evcc-io/evcc/plugin/mqtt"
|
|
"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/eebus"
|
|
"github.com/evcc-io/evcc/server/modbus"
|
|
"github.com/evcc-io/evcc/server/providerauth"
|
|
"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/request"
|
|
_ "github.com/evcc-io/evcc/util/service"
|
|
"github.com/evcc-io/evcc/util/sponsor"
|
|
"github.com/evcc-io/evcc/util/templates"
|
|
"github.com/evcc-io/evcc/vehicle"
|
|
"github.com/gorilla/handlers"
|
|
"github.com/gorilla/mux"
|
|
"github.com/libp2p/zeroconf/v2"
|
|
"github.com/spf13/cast"
|
|
"github.com/spf13/cobra"
|
|
vpr "github.com/spf13/viper"
|
|
"golang.org/x/sync/errgroup"
|
|
"golang.org/x/text/currency"
|
|
)
|
|
|
|
var conf = globalconfig.All{
|
|
Interval: 30 * time.Second,
|
|
Log: "info",
|
|
Network: globalconfig.Network{
|
|
Host: "",
|
|
Port: 7070,
|
|
},
|
|
Ocpp: ocpp.Config{
|
|
Port: 8887,
|
|
},
|
|
Mqtt: globalconfig.Mqtt{
|
|
Topic: "evcc",
|
|
},
|
|
EEBus: eebus.Config{
|
|
Port: 4712,
|
|
},
|
|
Database: globalconfig.DB{
|
|
Type: "sqlite",
|
|
Dsn: "",
|
|
},
|
|
}
|
|
|
|
var yamlSource struct {
|
|
sponsor globalconfig.YamlSource
|
|
hems globalconfig.YamlSource
|
|
eebus globalconfig.YamlSource
|
|
tariffs globalconfig.YamlSource
|
|
messaging globalconfig.YamlSource
|
|
circuits globalconfig.YamlSource
|
|
}
|
|
|
|
var nameRE = regexp.MustCompile(`^[a-zA-Z0-9_.:-]+$`)
|
|
|
|
func nameValid(name string) error {
|
|
if !nameRE.MatchString(name) {
|
|
return fmt.Errorf("name must not contain special characters or spaces: %s", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadConfigFile(conf *globalconfig.All, checkDB bool) error {
|
|
if err := viper.ReadInConfig(); err != nil {
|
|
if _, ok := errors.AsType[vpr.ConfigFileNotFoundError](err); !ok {
|
|
return fmt.Errorf("failed reading config file: %w", err)
|
|
}
|
|
}
|
|
|
|
if cfgFile := viper.ConfigFileUsed(); cfgFile != "" {
|
|
log.INFO.Println("using config file:", cfgFile)
|
|
} else {
|
|
log.INFO.Println("config file not found, database-only mode")
|
|
}
|
|
|
|
if err := viper.UnmarshalExact(conf); err != nil {
|
|
return fmt.Errorf("failed parsing config file: %w", err)
|
|
}
|
|
|
|
// user did not specify a database path
|
|
if conf.Database.Dsn == "" && checkDB {
|
|
// check if service database exists
|
|
if _, err := os.Stat(serviceDB); err == nil {
|
|
// service database found, ask user what to do
|
|
sudo := ""
|
|
if !isWritable(serviceDB) {
|
|
sudo = "sudo "
|
|
}
|
|
log.FATAL.Fatal(`
|
|
Found systemd service database at "` + serviceDB + `", evcc has been invoked with no explicit database path.
|
|
Running the same config with multiple databases can lead to expiring vehicle tokens.
|
|
|
|
If you want to use the existing service database run the following command:
|
|
|
|
` + sudo + `evcc --database ` + serviceDB + `
|
|
|
|
If you want to create a new user-space database run the following command:
|
|
|
|
evcc --database ~/.evcc/evcc.db
|
|
|
|
If you know what you're doing, you can skip the database check with the --ignore-db flag.
|
|
`)
|
|
}
|
|
}
|
|
|
|
// parse log levels after reading config
|
|
parseLogLevels()
|
|
|
|
return nil
|
|
}
|
|
|
|
func isWritable(filePath string) bool {
|
|
file, err := os.OpenFile(filePath, os.O_WRONLY, 0o666)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
file.Close()
|
|
return true
|
|
}
|
|
|
|
func configureCircuits(conf *[]config.Named) error {
|
|
// yaml config from file
|
|
if len(*conf) != 0 {
|
|
yamlSource.circuits = globalconfig.YamlSourceFile
|
|
}
|
|
|
|
// yaml config from db (deprecated)
|
|
if settings.Exists(keys.Circuits) {
|
|
if yamlSource.circuits == globalconfig.YamlSourceFile {
|
|
// just warn, no error to not break previous behavior
|
|
log.WARN.Println("circuits configured via UI yaml; evcc.yaml config will be ignored")
|
|
}
|
|
*conf = []config.Named{}
|
|
if err := settings.Yaml(keys.Circuits, new([]map[string]any), &conf); err != nil {
|
|
return err
|
|
}
|
|
yamlSource.circuits = globalconfig.YamlSourceDb
|
|
}
|
|
|
|
// load configCircuits devices from database
|
|
configurable, err := config.ConfigurationsByClass(templates.Circuit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// device config from db
|
|
if yamlSource.circuits != globalconfig.YamlSourceNone && len(configurable) > 0 {
|
|
return errors.New("circuits are configured via UI; having an additional yaml config is not allowed")
|
|
}
|
|
|
|
if err := validateStaticCircuits(slices.Clone(*conf)); err != nil {
|
|
return err
|
|
}
|
|
if err := validateConfigurableCircuits(configurable); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, c := range configurable {
|
|
*conf = append(*conf, c.Named())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateCircuitConfigs validates circuit configurations with support for both static and configurable types
|
|
func validateCircuitConfigs[T any](children []T, getConfigAndLogger func(T) (config.Named, *util.Logger), getDevice func(T, api.Circuit) config.Device[api.Circuit]) error {
|
|
// TODO check for circular references
|
|
NEXT:
|
|
for i, child := range children {
|
|
cc, log := getConfigAndLogger(child)
|
|
|
|
if cc.Name == "" {
|
|
return fmt.Errorf("cannot create circuit: missing name")
|
|
}
|
|
|
|
if err := nameValid(cc.Name); err != nil {
|
|
return fmt.Errorf("cannot create circuit: duplicate name: %s", cc.Name)
|
|
}
|
|
|
|
if parent := cast.ToString(cc.Property("parent")); parent != "" {
|
|
if _, err := config.Circuits().ByName(parent); err != nil {
|
|
continue
|
|
}
|
|
}
|
|
|
|
typ, other, err := config.CustomDevice(cc.Type, cc.Other)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot decode custom circuit '%s': %w", cc.Name, err)
|
|
}
|
|
|
|
ctx := util.WithLogger(context.TODO(), log)
|
|
|
|
instance, err := circuit.NewFromConfig(ctx, typ, other)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot create circuit '%s': %w", cc.Name, err)
|
|
}
|
|
|
|
// ensure config has title
|
|
if instance.GetTitle() == "" {
|
|
//lint:ignore SA1019 as Title is safe on ascii
|
|
instance.SetTitle(strings.Title(cc.Name))
|
|
}
|
|
|
|
if err := config.Circuits().Add(getDevice(child, instance)); err != nil {
|
|
return err
|
|
}
|
|
|
|
children = slices.Delete(children, i, i+1)
|
|
goto NEXT
|
|
}
|
|
|
|
if len(children) > 0 {
|
|
cn, _ := getConfigAndLogger(children[0])
|
|
return fmt.Errorf("circuit is missing parent: %s", cn.Name)
|
|
}
|
|
|
|
var rootFound bool
|
|
for _, dev := range config.Circuits().Devices() {
|
|
c := dev.Instance()
|
|
|
|
if c.GetParent() == nil {
|
|
if rootFound {
|
|
return errors.New("cannot have multiple root circuits")
|
|
}
|
|
rootFound = true
|
|
}
|
|
}
|
|
|
|
if !rootFound && len(config.Circuits().Devices()) > 0 {
|
|
return errors.New("root circuit required")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateStaticCircuits(children []config.Named) error {
|
|
return validateCircuitConfigs(
|
|
children,
|
|
func(cc config.Named) (config.Named, *util.Logger) { return cc, util.NewLogger(cc.Name) },
|
|
func(cc config.Named, instance api.Circuit) config.Device[api.Circuit] {
|
|
return config.NewStaticDevice(cc, instance)
|
|
},
|
|
)
|
|
}
|
|
|
|
func validateConfigurableCircuits(children []config.Config) error {
|
|
return validateCircuitConfigs(
|
|
children,
|
|
func(cc config.Config) (config.Named, *util.Logger) { return cc.Named(), loggerForConfig(&cc) },
|
|
func(cc config.Config, instance api.Circuit) config.Device[api.Circuit] {
|
|
return config.NewConfigurableDevice(&cc, instance)
|
|
},
|
|
)
|
|
}
|
|
|
|
type newFromConfFunc[T any] func(context.Context, string, map[string]any) (T, error)
|
|
|
|
func staticInstance[T any](typ string, cc config.Named, newFromConf newFromConfFunc[T], h config.Handler[T]) error {
|
|
ctx, cancel := context.WithCancel(util.WithLogger(context.TODO(), util.NewLogger(cc.Name)))
|
|
|
|
instance, err := newFromConf(ctx, cc.Type, cc.Other)
|
|
if err != nil {
|
|
err = &DeviceError{cc.Name, fmt.Errorf("cannot create %s '%s': %w", typ, cc.Name, err)}
|
|
}
|
|
|
|
// ctx lives for the device lifetime- only release it on failure
|
|
defer func() {
|
|
if err != nil {
|
|
cancel()
|
|
}
|
|
}()
|
|
|
|
if e := h.Add(config.NewStaticDevice(cc, instance)); e != nil && err == nil {
|
|
err = &DeviceError{cc.Name, e}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// nameForConfig returns a sensible log name for a (custom) configurable device,
|
|
// qualifying the generic db:<id> with the configured title where available
|
|
func nameForConfig(conf *config.Config) string {
|
|
res := conf.Named().Name
|
|
if t := conf.Title; t != "" && t != res {
|
|
res += "-" + t
|
|
}
|
|
return res
|
|
}
|
|
|
|
// loggerForConfig creates a logger with sensible name for (custom) configurable device
|
|
func loggerForConfig(conf *config.Config) *util.Logger {
|
|
return util.NewLogger(nameForConfig(conf))
|
|
}
|
|
|
|
func configurableInstance[T any](typ string, conf *config.Config, newFromConf newFromConfFunc[T], h config.Handler[T]) error {
|
|
cc := conf.Named()
|
|
ctx, cancel := context.WithCancel(util.WithLogger(context.TODO(), loggerForConfig(conf)))
|
|
|
|
typ, other, err := config.CustomDevice(cc.Type, cc.Other)
|
|
if err != nil {
|
|
err = &DeviceError{cc.Name, fmt.Errorf("cannot decode custom %s '%s': %w", typ, cc.Name, err)}
|
|
}
|
|
|
|
// ctx lives for the device lifetime- only release it on failure
|
|
defer func() {
|
|
if err != nil {
|
|
cancel()
|
|
}
|
|
}()
|
|
|
|
var instance T
|
|
if err == nil && !conf.Disable {
|
|
instance, err = newFromConf(ctx, typ, other)
|
|
if err != nil {
|
|
err = &DeviceError{cc.Name, fmt.Errorf("cannot create %s '%s': %w", typ, cc.Name, err)}
|
|
}
|
|
}
|
|
|
|
if e := h.Add(config.NewConfigurableDevice(conf, instance)); e != nil && err == nil {
|
|
err = &DeviceError{cc.Name, e}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func configureMeters(static []config.Named, names ...string) error {
|
|
var eg errgroup.Group
|
|
|
|
for i, cc := range static {
|
|
if cc.Name == "" {
|
|
return fmt.Errorf("cannot create meter %d: missing name", i+1)
|
|
}
|
|
|
|
// configure all, if no name refs are given
|
|
if len(names) > 0 && !slices.Contains(names, cc.Name) {
|
|
continue
|
|
}
|
|
|
|
if err := nameValid(cc.Name); err != nil {
|
|
log.WARN.Printf("create meter %d: %v", i+1, err)
|
|
}
|
|
|
|
eg.Go(func() error {
|
|
return staticInstance("meter", cc, meter.NewFromConfig, config.Meters())
|
|
})
|
|
}
|
|
|
|
// append devices from database
|
|
configurable, err := config.ConfigurationsByClass(templates.Meter)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, conf := range configurable {
|
|
eg.Go(func() error {
|
|
cc := conf.Named()
|
|
|
|
// always skip unreferenced db devices
|
|
if !slices.Contains(names, cc.Name) {
|
|
return nil
|
|
}
|
|
|
|
return configurableInstance("meter", &conf, meter.NewFromConfig, config.Meters())
|
|
})
|
|
}
|
|
|
|
return eg.Wait()
|
|
}
|
|
|
|
func configureChargers(static []config.Named, names ...string) error {
|
|
var eg errgroup.Group
|
|
|
|
for i, cc := range static {
|
|
if cc.Name == "" {
|
|
return fmt.Errorf("cannot create charger %d: missing name", i+1)
|
|
}
|
|
|
|
// configure all, if no name refs are given
|
|
if len(names) > 0 && !slices.Contains(names, cc.Name) {
|
|
continue
|
|
}
|
|
|
|
if err := nameValid(cc.Name); err != nil {
|
|
log.WARN.Printf("create charger %d: %v", i+1, err)
|
|
}
|
|
|
|
eg.Go(func() error {
|
|
return staticInstance("charger", cc, charger.NewFromConfig, config.Chargers())
|
|
})
|
|
}
|
|
|
|
// append devices from database
|
|
configurable, err := config.ConfigurationsByClass(templates.Charger)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, conf := range configurable {
|
|
eg.Go(func() error {
|
|
cc := conf.Named()
|
|
|
|
// always skip unreferenced db devices
|
|
if !slices.Contains(names, cc.Name) {
|
|
return nil
|
|
}
|
|
|
|
return configurableInstance("charger", &conf, charger.NewFromConfig, config.Chargers())
|
|
})
|
|
}
|
|
|
|
return eg.Wait()
|
|
}
|
|
|
|
func vehicleInstance(cc config.Named) (api.Vehicle, error) {
|
|
ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name))
|
|
|
|
typ, other, err := config.CustomDevice(cc.Type, cc.Other)
|
|
|
|
var instance api.Vehicle
|
|
if err == nil {
|
|
instance, err = vehicle.NewFromConfig(ctx, typ, other)
|
|
}
|
|
|
|
if err != nil {
|
|
if _, ok := errors.AsType[*util.ConfigError](err); ok {
|
|
return nil, err
|
|
}
|
|
|
|
// wrap non-config vehicle errors to prevent fatals
|
|
log.ERROR.Printf("creating vehicle %s failed: %v", cc.Name, err)
|
|
instance = vehicle.NewWrapper(cc.Name, cc.Type, cc.Other, err)
|
|
}
|
|
|
|
// ensure vehicle config has title
|
|
if instance.GetTitle() == "" {
|
|
//lint:ignore SA1019 as Title is safe on ascii
|
|
instance.SetTitle(strings.Title(cc.Name))
|
|
}
|
|
|
|
return instance, nil
|
|
}
|
|
|
|
func configureVehicles(static []config.Named, names ...string) error {
|
|
var mu sync.Mutex
|
|
var eg errgroup.Group
|
|
|
|
// 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)
|
|
}
|
|
|
|
if len(names) > 0 && !slices.Contains(names, cc.Name) {
|
|
continue
|
|
}
|
|
|
|
if err := nameValid(cc.Name); err != nil {
|
|
return fmt.Errorf("cannot create vehicle %d: %w", i+1, err)
|
|
}
|
|
|
|
eg.Go(func() error {
|
|
instance, err := vehicleInstance(cc)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err)
|
|
}
|
|
|
|
if _, ok := instance.OnIdentified().GetMode(); ok {
|
|
log.WARN.Printf("vehicle '%s': default charge 'mode' is deprecated, please configure via UI (charging plan > arrival)", cc.Name)
|
|
}
|
|
|
|
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 {
|
|
eg.Go(func() error {
|
|
cc := conf.Named()
|
|
|
|
if len(names) > 0 && !slices.Contains(names, cc.Name) {
|
|
return nil
|
|
}
|
|
|
|
// migrate deprecated mode property to vehicle mode setting
|
|
if mode, ok := cc.Other["mode"].(string); ok && mode != "" {
|
|
key := fmt.Sprintf("vehicle.%s.%s", cc.Name, keys.Mode)
|
|
if _, err := settings.String(key); err != nil {
|
|
if _, err := api.ChargeModeString(mode); err == nil {
|
|
settings.SetString(key, mode)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 := eg.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 configureSponsorship(token string) (err error) {
|
|
if settings.Exists(keys.SponsorToken) {
|
|
if token, err = settings.String(keys.SponsorToken); err != nil {
|
|
return err
|
|
}
|
|
} else if token != "" {
|
|
yamlSource.sponsor = globalconfig.YamlSourceFile
|
|
}
|
|
|
|
return sponsor.ConfigureSponsorship(token)
|
|
}
|
|
|
|
func configureEnvironment(cmd *cobra.Command, conf *globalconfig.All) error {
|
|
// full http request log
|
|
if cmd.Flag(flagHeaders).Changed {
|
|
request.LogHeaders = true
|
|
}
|
|
|
|
// setup persistence
|
|
err := wrapErrorWithClass(ClassDatabase, configureDatabase(conf.Database))
|
|
|
|
// configure network
|
|
if err == nil {
|
|
err = networkSettings(&conf.Network)
|
|
}
|
|
|
|
// setup additional templates
|
|
if err == nil {
|
|
if cmd.Flags().Changed(flagTemplate) {
|
|
class, err := templates.ClassString(cmd.Flags().Lookup(flagTemplateType).Value.String())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := templates.Register(class, cmd.Flag(flagTemplate).Value.String()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// setup translations
|
|
if err == nil {
|
|
// TODO decide wrapping
|
|
err = locale.Init()
|
|
}
|
|
|
|
// setup machine id
|
|
if err == nil && conf.Plant != "" {
|
|
// TODO decide wrapping
|
|
err = machine.CustomID(conf.Plant)
|
|
}
|
|
|
|
// setup sponsorship (allow env override)
|
|
if err == nil {
|
|
err = wrapErrorWithClass(ClassSponsorship, configureSponsorship(conf.SponsorToken))
|
|
}
|
|
|
|
// setup mqtt client listener
|
|
if err == nil {
|
|
err = wrapErrorWithClass(ClassMqtt, configureMqtt(&conf.Mqtt))
|
|
}
|
|
|
|
// setup OCPP server
|
|
if err == nil {
|
|
configureOCPP(&conf.Ocpp, conf.Network.ExternalUrl)
|
|
}
|
|
|
|
// setup EEBus server
|
|
if err == nil {
|
|
err = wrapErrorWithClass(ClassEEBus, configureEEBus(&conf.EEBus))
|
|
}
|
|
|
|
// setup javascript VMs
|
|
if err == nil {
|
|
err = wrapErrorWithClass(ClassJavascript, configureJavascript(conf.Javascript))
|
|
}
|
|
|
|
// setup go VMs
|
|
if err == nil {
|
|
err = wrapErrorWithClass(ClassGo, configureGo(conf.Go))
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// configureDatabase configures session database
|
|
func configureDatabase(conf globalconfig.DB) error {
|
|
if conf.Dsn == "" {
|
|
conf.Dsn = userDB
|
|
}
|
|
|
|
if err := db.NewInstance(conf.Type, conf.Dsn); 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 minute
|
|
go func() {
|
|
for range time.Tick(time.Minute) {
|
|
persistSettings()
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// configureInflux configures influx database
|
|
func configureInflux(conf *globalconfig.Influx) (*server.Influx, error) {
|
|
// read settings
|
|
if settings.Exists(keys.Influx) {
|
|
if err := settings.Json(keys.Influx, &conf); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if conf.URL == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
influx := server.NewInfluxClient(
|
|
conf.URL,
|
|
conf.Token,
|
|
conf.Org,
|
|
conf.User,
|
|
conf.Password,
|
|
conf.Database,
|
|
conf.Insecure,
|
|
)
|
|
|
|
return influx, nil
|
|
}
|
|
|
|
// setup mqtt
|
|
func configureMqtt(conf *globalconfig.Mqtt) error {
|
|
// migrate settings
|
|
if settings.Exists(keys.Mqtt) {
|
|
if err := settings.Json(keys.Mqtt, &conf); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if conf.Broker == "" {
|
|
return nil
|
|
}
|
|
|
|
log := util.NewLogger("mqtt")
|
|
|
|
instance, err := mqtt.RegisteredClient(log, conf.Broker, conf.User, conf.Password, conf.ClientID, 1, conf.Insecure, conf.CaCert, conf.ClientCert, conf.ClientKey, func(options *paho.ClientOptions) {
|
|
if !runAsService || conf.Topic == "" {
|
|
return
|
|
}
|
|
|
|
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 SHM
|
|
func configureSHM(conf *shm.Config, externalUrl string, site *core.Site, httpd *server.HTTPd) error {
|
|
if settings.Exists(keys.Shm) {
|
|
if err := settings.Json(keys.Shm, &conf); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := shm.NewFromConfig(*conf, externalUrl, site, httpd.Addr, httpd.Router()); err != nil {
|
|
return fmt.Errorf("failed configuring shm: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// setup javascript
|
|
func configureJavascript(conf []globalconfig.Javascript) 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 []globalconfig.Go) 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 *globalconfig.Hems, site *core.Site) (hemsapi.API, error) {
|
|
if conf.Type != "" {
|
|
yamlSource.hems = globalconfig.YamlSourceFile
|
|
|
|
typ, other, err := config.CustomDevice(conf.Type, conf.Other)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot decode custom hems '%s': %w", conf.Type, err)
|
|
}
|
|
|
|
instance, err := hems.NewFromConfig(context.TODO(), typ, other, site)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed configuring hems: %w", err)
|
|
}
|
|
|
|
site.SetHEMS(instance)
|
|
|
|
go instance.Run()
|
|
|
|
return instance, nil
|
|
}
|
|
|
|
// migrate legacy keys.Hems setting (pre-UI) to a device-managed custom entry
|
|
if err := migrateLegacyHemsSetting(); err != nil {
|
|
return nil, fmt.Errorf("migrating legacy hems setting: %w", err)
|
|
}
|
|
|
|
return configureDbHems(site)
|
|
}
|
|
|
|
// migrateLegacyHemsSetting moves a keys.Hems YAML setting to a device-managed custom hems entry.
|
|
func migrateLegacyHemsSetting() error {
|
|
raw, err := settings.String(keys.Hems)
|
|
if err != nil || strings.TrimSpace(raw) == "" {
|
|
return nil
|
|
}
|
|
|
|
_, err = config.AddConfig(templates.Hems,
|
|
map[string]any{"yaml": raw},
|
|
config.WithProperties(config.Properties{Type: "custom"}),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.INFO.Println("migrated legacy hems yaml setting to device config")
|
|
return settings.Delete(keys.Hems)
|
|
}
|
|
|
|
func configureDbHems(site *core.Site) (hemsapi.API, error) {
|
|
dev, err := config.ConfigurationByClass(templates.Hems)
|
|
if err != nil || dev == nil {
|
|
return nil, err
|
|
}
|
|
|
|
cc := dev.Named()
|
|
|
|
var instance hemsapi.API
|
|
typ, other, err := config.CustomDevice(cc.Type, cc.Other)
|
|
if err != nil {
|
|
err = &DeviceError{cc.Name, fmt.Errorf("cannot decode custom %s '%s': %w", typ, cc.Name, err)}
|
|
} else {
|
|
instance, err = hems.NewFromConfig(context.TODO(), typ, other, site)
|
|
if err != nil {
|
|
err = &DeviceError{cc.Name, fmt.Errorf("cannot create %s '%s': %w", typ, cc.Name, err)}
|
|
}
|
|
}
|
|
|
|
// register device even on factory error so UI can edit/delete the broken config
|
|
if addErr := config.Hems().Add(config.NewConfigurableDevice(dev, instance)); addErr != nil && err == nil {
|
|
err = &DeviceError{cc.Name, addErr}
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
site.SetHEMS(instance)
|
|
|
|
go instance.Run()
|
|
return instance, nil
|
|
}
|
|
|
|
// networkSettings reads/migrates network settings
|
|
func networkSettings(conf *globalconfig.Network) error {
|
|
if settings.Exists(keys.Network) {
|
|
return settings.Json(keys.Network, &conf)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// setup MDNS
|
|
func configureMDNS(conf globalconfig.Network) error {
|
|
host := strings.TrimSuffix(conf.Host, ".local")
|
|
if host == "" {
|
|
host = "evcc"
|
|
}
|
|
|
|
internalURL := conf.InternalURL()
|
|
text := []string{"path=/", "internal_url=" + internalURL}
|
|
|
|
if externalURL := conf.ExternalURL(); externalURL != internalURL {
|
|
text = append(text, "external_url="+externalURL)
|
|
}
|
|
|
|
zc, err := zeroconf.RegisterProxy("evcc", "_http._tcp", "local.", conf.Port, host, nil, text, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
shutdown.Register(zc.Shutdown)
|
|
|
|
return nil
|
|
}
|
|
|
|
// setup OCPP
|
|
func configureOCPP(cfg *ocpp.Config, externalUrl string) {
|
|
if settings.Exists(keys.Ocpp) {
|
|
if err := settings.Json(keys.Ocpp, cfg); err != nil {
|
|
log.WARN.Printf("ocpp: failed to load settings: %v", err)
|
|
}
|
|
}
|
|
ocpp.NewServer(*cfg, externalUrl)
|
|
|
|
// Load proxy forwarding rules from DB if present.
|
|
var rules []ocpp.ForwarderRule
|
|
if err := settings.Json(keys.OcppForwarder, &rules); err == nil && len(rules) > 0 {
|
|
// the forwarder relays through the central system; abort if it failed to start
|
|
if _, err := ocpp.Instance(); err != nil {
|
|
log.ERROR.Printf("ocpp: forwarder disabled: %v", err)
|
|
return
|
|
}
|
|
ocpp.ApplyForwarderRules(rules)
|
|
}
|
|
}
|
|
|
|
// setup EEBus
|
|
func configureEEBus(conf *eebus.Config) error {
|
|
// migrate settings
|
|
if settings.Exists(keys.EEBus) {
|
|
if err := migrateYamlToJson(keys.EEBus, conf); err != nil {
|
|
return err
|
|
}
|
|
} else if conf.IsConfigured() {
|
|
yamlSource.eebus = globalconfig.YamlSourceFile
|
|
}
|
|
|
|
if !conf.IsConfigured() {
|
|
cc, err := eebus.DefaultConfig(conf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
*conf = *cc
|
|
if err := settings.SetJson(keys.EEBus, conf); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// generate a SHIP pairing secret for configs that predate SHIP pairing
|
|
if conf.Secret == "" {
|
|
secret, err := eebus.CreatePairingSecret()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
conf.Secret = secret
|
|
if err := settings.SetJson(keys.EEBus, conf); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
srv, err := eebus.NewServer(*conf)
|
|
if err != nil {
|
|
return fmt.Errorf("failed configuring eebus: %w", err)
|
|
}
|
|
|
|
shutdown.Register(srv.Shutdown)
|
|
|
|
return nil
|
|
}
|
|
|
|
func configureMessengers(confMessaging *globalconfig.Messaging, confEvents *globalconfig.MessagingEvents, vehicles messenger.Vehicles) (chan messenger.Event, error) {
|
|
// yaml config from file
|
|
if len(confMessaging.Events) != 0 || len(confMessaging.Services) != 0 {
|
|
yamlSource.messaging = globalconfig.YamlSourceFile
|
|
}
|
|
|
|
// yaml config from db (deprecated)
|
|
if settings.Exists(keys.Messaging) {
|
|
if yamlSource.messaging == globalconfig.YamlSourceFile {
|
|
// just warn, no error to not break previous behavior
|
|
log.WARN.Println("messaging configured via UI yaml; evcc.yaml config will be ignored")
|
|
}
|
|
*confMessaging = globalconfig.Messaging{}
|
|
if err := settings.Yaml(keys.Messaging, new(map[string]any), &confMessaging); err != nil {
|
|
return nil, err
|
|
}
|
|
yamlSource.messaging = globalconfig.YamlSourceDb
|
|
}
|
|
|
|
if settings.Exists(keys.MessagingEvents) {
|
|
*confEvents = globalconfig.MessagingEvents{}
|
|
if err := settings.Json(keys.MessagingEvents, &confEvents); err != nil {
|
|
return nil, err
|
|
}
|
|
if yamlSource.messaging != globalconfig.YamlSourceNone && confEvents != nil {
|
|
return nil, errors.New("yaml and device config exists for messaging; remove yaml config")
|
|
}
|
|
}
|
|
|
|
// events are queued by the value cache, keep enough slack to not stall it
|
|
messageChan := make(chan messenger.Event, 16)
|
|
|
|
var eg errgroup.Group
|
|
|
|
for i, cc := range confMessaging.Services {
|
|
// add name for meter/charger parity
|
|
cc := config.Named{
|
|
Name: fmt.Sprintf("push-%d", i+1),
|
|
Type: cc.Type,
|
|
Other: cc.Other,
|
|
}
|
|
|
|
eg.Go(func() error {
|
|
return staticInstance("messenger", cc, messenger.NewFromConfig, config.Messengers())
|
|
})
|
|
}
|
|
|
|
// append devices from database
|
|
configurable, err := config.ConfigurationsByClass(templates.Messenger)
|
|
if err != nil {
|
|
return messageChan, err
|
|
}
|
|
|
|
for _, conf := range configurable {
|
|
eg.Go(func() error {
|
|
return configurableInstance("messenger", &conf, messenger.NewFromConfig, config.Messengers())
|
|
})
|
|
}
|
|
|
|
if err := eg.Wait(); err != nil {
|
|
return messageChan, &ClassError{ClassMessenger, err}
|
|
}
|
|
|
|
var events globalconfig.MessagingEvents
|
|
|
|
if len(*confEvents) > 0 {
|
|
events = conf.MessagingEvents
|
|
} else {
|
|
events = confMessaging.Events
|
|
}
|
|
|
|
messageHub, err := messenger.NewHub(events, vehicles)
|
|
|
|
if err != nil {
|
|
return messageChan, fmt.Errorf("failed configuring push services: %w", err)
|
|
}
|
|
|
|
for _, dev := range config.Messengers().Devices() {
|
|
if inst := dev.Instance(); inst != nil {
|
|
messageHub.Add(inst)
|
|
}
|
|
}
|
|
|
|
go messageHub.Run(messageChan)
|
|
|
|
return messageChan, nil
|
|
}
|
|
|
|
func tariffInstance(name string, conf config.Typed) (api.Tariff, error) {
|
|
ctx := util.WithLogger(context.TODO(), util.NewLogger(name))
|
|
|
|
typ, other, err := config.CustomDevice(conf.Type, conf.Other)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot decode custom tariff '%s': %w", name, err)
|
|
}
|
|
|
|
instance, err := tariff.NewFromConfig(ctx, typ, other)
|
|
if err != nil {
|
|
if _, ok := errors.AsType[*util.ConfigError](err); ok {
|
|
return nil, err
|
|
}
|
|
|
|
// wrap non-config tariff errors to prevent fatals
|
|
log.ERROR.Printf("creating tariff %s failed: %v", name, err)
|
|
instance = tariff.NewWrapper(conf.Type, conf.Other, err)
|
|
}
|
|
|
|
return instance, nil
|
|
}
|
|
|
|
func configureSolarTariff(conf []config.Typed, t *api.Tariff) error {
|
|
var eg errgroup.Group
|
|
tt := make([]api.Tariff, len(conf))
|
|
|
|
for i, conf := range conf {
|
|
eg.Go(func() error {
|
|
if conf.Type == "" {
|
|
return errors.New("missing type")
|
|
}
|
|
|
|
name := fmt.Sprintf("%s-%s-%d", api.TariffUsageSolar, tariff.Name(conf), i)
|
|
res, err := tariffInstance(name, conf)
|
|
if err != nil {
|
|
return &DeviceError{name, err}
|
|
}
|
|
|
|
tt[i] = res
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if err := eg.Wait(); err != nil {
|
|
return err
|
|
}
|
|
|
|
*t = tariff.NewCombined(tt)
|
|
return nil
|
|
}
|
|
|
|
func configureTariff(conf config.Typed, deviceName string, target *api.Tariff) error {
|
|
if conf.Type != "" {
|
|
instance, err := tariffInstance("tariff", conf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
*target = instance
|
|
return nil
|
|
}
|
|
if deviceName != "" {
|
|
dev, err := config.Tariffs().ByName(deviceName)
|
|
if err != nil {
|
|
return fmt.Errorf("tariff device not found: %w", err)
|
|
}
|
|
*target = dev.Instance()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func configureSolarTariffs(confs []config.Typed, deviceNames []string, target *api.Tariff) error {
|
|
if len(confs) > 0 {
|
|
if len(confs) == 1 {
|
|
return configureTariff(confs[0], "", target)
|
|
}
|
|
return configureSolarTariff(confs, target)
|
|
}
|
|
if len(deviceNames) > 0 {
|
|
if len(deviceNames) == 1 {
|
|
return configureTariff(config.Typed{}, deviceNames[0], target)
|
|
}
|
|
var tt []api.Tariff
|
|
for _, name := range deviceNames {
|
|
dev, err := config.Tariffs().ByName(name)
|
|
if err != nil {
|
|
return fmt.Errorf("tariff device %s not found: %w", name, err)
|
|
}
|
|
// nil instance marks disabled device
|
|
if instance := dev.Instance(); instance != nil {
|
|
tt = append(tt, instance)
|
|
}
|
|
}
|
|
if len(tt) > 0 {
|
|
*target = tariff.NewCombined(tt)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func configureTariffs(conf *globalconfig.Tariffs, names ...string) (*tariff.Tariffs, error) {
|
|
tariffs := tariff.Tariffs{
|
|
Currency: currency.EUR,
|
|
}
|
|
|
|
// yaml config from file
|
|
if conf.IsConfigured() {
|
|
yamlSource.tariffs = globalconfig.YamlSourceFile
|
|
}
|
|
|
|
// yaml config from db (deprecated)
|
|
if settings.Exists(keys.Tariffs) {
|
|
if yamlSource.tariffs == globalconfig.YamlSourceFile {
|
|
// just warn, no error to not break previous behavior
|
|
log.WARN.Println("tariffs configured via UI; evcc.yaml config will be ignored")
|
|
}
|
|
*conf = globalconfig.Tariffs{}
|
|
if err := settings.Yaml(keys.Tariffs, new(map[string]any), &conf); err != nil {
|
|
return &tariffs, err
|
|
}
|
|
yamlSource.tariffs = globalconfig.YamlSourceDb
|
|
}
|
|
|
|
// device config from db
|
|
var refs globalconfig.TariffRefs
|
|
if settings.Exists(keys.TariffRefs) {
|
|
if err := settings.Json(keys.TariffRefs, &refs); err != nil {
|
|
return &tariffs, err
|
|
}
|
|
if yamlSource.tariffs != globalconfig.YamlSourceNone && refs.IsConfigured() {
|
|
return &tariffs, errors.New("tariffs are configured via UI; having an additional yaml config is not allowed")
|
|
}
|
|
}
|
|
|
|
// load tariff devices from database
|
|
configurable, err := config.ConfigurationsByClass(templates.Tariff)
|
|
if err != nil {
|
|
return &tariffs, err
|
|
}
|
|
|
|
var eg errgroup.Group
|
|
|
|
for _, conf := range configurable {
|
|
eg.Go(func() error {
|
|
cc := conf.Named()
|
|
|
|
if len(names) > 0 && !slices.Contains(names, cc.Name) {
|
|
return nil
|
|
}
|
|
|
|
var instance api.Tariff
|
|
if !conf.Disable {
|
|
var err error
|
|
instance, err = tariffInstance(nameForConfig(&conf), config.Typed{Type: cc.Type, Other: cc.Other})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if e := config.Tariffs().Add(config.NewConfigurableDevice(&conf, instance)); e != nil {
|
|
return &DeviceError{cc.Name, e}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if err := eg.Wait(); err != nil {
|
|
return &tariffs, err
|
|
}
|
|
|
|
// resolve tariff roles
|
|
eg = errgroup.Group{}
|
|
eg.Go(func() error { return configureTariff(conf.Grid, refs.Grid, &tariffs.Grid) })
|
|
eg.Go(func() error { return configureTariff(conf.FeedIn, refs.FeedIn, &tariffs.FeedIn) })
|
|
eg.Go(func() error { return configureTariff(conf.Co2, refs.Co2, &tariffs.Co2) })
|
|
eg.Go(func() error { return configureTariff(conf.Planner, refs.Planner, &tariffs.Planner) })
|
|
eg.Go(func() error { return configureSolarTariffs(conf.Solar, refs.Solar, &tariffs.Solar) })
|
|
eg.Go(func() error { return configureTariff(conf.Temperature, refs.Temperature, &tariffs.Temperature) })
|
|
if err := eg.Wait(); err != nil {
|
|
return &tariffs, &ClassError{ClassTariff, err}
|
|
}
|
|
|
|
// validate currency
|
|
if cur, _ := settings.String(keys.Currency); cur != "" {
|
|
conf.Currency = cur
|
|
}
|
|
if conf.Currency != "" {
|
|
cur, err := currency.ParseISO(conf.Currency)
|
|
if err != nil {
|
|
return &tariffs, err
|
|
}
|
|
tariffs.Currency = cur
|
|
}
|
|
|
|
return &tariffs, nil
|
|
}
|
|
|
|
func configureDevices(conf globalconfig.All) error {
|
|
// collect references for filtering used devices
|
|
if err := collectRefs(conf); err != nil {
|
|
return err
|
|
}
|
|
|
|
// make sure all devices are configured
|
|
var errs []error
|
|
|
|
if err := configureMeters(conf.Meters, references.meter...); err != nil {
|
|
errs = append(errs, &ClassError{ClassMeter, err})
|
|
}
|
|
|
|
if err := configureChargers(conf.Chargers, references.charger...); err != nil {
|
|
errs = append(errs, &ClassError{ClassCharger, err})
|
|
}
|
|
|
|
if err := configureVehicles(conf.Vehicles); err != nil {
|
|
errs = append(errs, &ClassError{ClassVehicle, err})
|
|
}
|
|
|
|
if err := configureCircuits(&conf.Circuits); err != nil {
|
|
errs = append(errs, &ClassError{ClassCircuit, err})
|
|
}
|
|
|
|
return joinErrors(errs...)
|
|
}
|
|
|
|
func configureModbusProxy(conf *[]globalconfig.ModbusProxy) error {
|
|
if settings.Exists(keys.ModbusProxy) {
|
|
if err := migrateYamlToJson(keys.ModbusProxy, conf); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// prevent panic
|
|
if conf == nil {
|
|
return nil
|
|
}
|
|
|
|
for _, cfg := range *conf {
|
|
var mode modbus.ReadOnlyMode
|
|
mode, err := modbus.ReadOnlyModeString(cfg.ReadOnly)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = modbus.StartProxy(cfg.Port, cfg.Settings, mode); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) {
|
|
// migrate settings
|
|
if settings.Exists(keys.Interval) {
|
|
d, err := settings.Int(keys.Interval)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
conf.Interval = time.Duration(d)
|
|
}
|
|
|
|
var errs []error
|
|
|
|
if err := configureDevices(*conf); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
|
|
if err := configureLoadpoints(*conf); err != nil {
|
|
errs = append(errs, &ClassError{ClassLoadpoint, err})
|
|
}
|
|
|
|
tariffs, err := configureTariffs(&conf.Tariffs, references.tariff...)
|
|
if err != nil {
|
|
errs = append(errs, &ClassError{ClassTariff, err})
|
|
}
|
|
|
|
// nil entries mark disabled loadpoints- indexes stay aligned with config order
|
|
var loadpoints []*core.Loadpoint
|
|
for _, dev := range config.Loadpoints().Devices() {
|
|
inst, _ := dev.Instance().(*core.Loadpoint)
|
|
loadpoints = append(loadpoints, inst)
|
|
}
|
|
|
|
site, err := configureSite(conf.Site, loadpoints, tariffs)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return site, joinErrors(errs...)
|
|
}
|
|
|
|
if len(config.Circuits().Devices()) > 0 {
|
|
if err := validateCircuits(loadpoints); err != nil {
|
|
return site, &ClassError{ClassCircuit, err}
|
|
}
|
|
}
|
|
|
|
return site, nil
|
|
}
|
|
|
|
func validateCircuits(loadpoints []*core.Loadpoint) error {
|
|
var hasRoot bool
|
|
|
|
CONTINUE:
|
|
for _, dev := range config.Circuits().Devices() {
|
|
instance := dev.Instance()
|
|
|
|
isRoot := instance.GetParent() == nil
|
|
if isRoot {
|
|
if hasRoot {
|
|
return errors.New("multiple root circuits")
|
|
}
|
|
|
|
hasRoot = true
|
|
}
|
|
|
|
if slices.ContainsFunc(loadpoints, func(lp *core.Loadpoint) bool {
|
|
return lp != nil && lp.GetCircuit() == instance
|
|
}) {
|
|
continue CONTINUE
|
|
}
|
|
|
|
if !isRoot && !instance.HasMeter() {
|
|
log.INFO.Printf("circuit %s has no meter and no loadpoint assigned", dev.Config().Name)
|
|
}
|
|
}
|
|
|
|
if !hasRoot {
|
|
return errors.New("missing root circuit")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func configureSite(conf map[string]any, loadpoints []*core.Loadpoint, tariffs *tariff.Tariffs) (*core.Site, error) {
|
|
site, err := core.NewSiteFromConfig(conf)
|
|
if err != nil {
|
|
return site, err
|
|
}
|
|
|
|
if err := site.Boot(log, loadpoints, tariffs); err != nil {
|
|
return site, fmt.Errorf("failed booting site: %w", err)
|
|
}
|
|
|
|
return site, nil
|
|
}
|
|
|
|
func newLoadpoint(idx int, name string, other map[string]any, settingsFn func(*util.Logger) coresettings.Settings) (*core.Loadpoint, error) {
|
|
log := util.NewLoggerWithLoadpoint("lp-"+strconv.Itoa(idx), idx)
|
|
|
|
collector, err := metrics.NewCollector(metrics.Loadpoint, name, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
lp, err := core.NewLoadpointFromConfig(log, settingsFn(log), collector, other)
|
|
if err != nil {
|
|
return lp, err
|
|
}
|
|
|
|
return lp, nil
|
|
}
|
|
|
|
func configureLoadpoints(conf globalconfig.All) error {
|
|
for id, cc := range conf.Loadpoints {
|
|
idx := id + 1
|
|
cc.Name = "lp-" + strconv.Itoa(idx)
|
|
|
|
instance, err := newLoadpoint(idx, cc.Name, cc.Other, func(*util.Logger) coresettings.Settings {
|
|
return coresettings.NewDatabaseSettingsAdapter(fmt.Sprintf("lp%d.", idx))
|
|
})
|
|
if err != nil {
|
|
return &DeviceError{cc.Name, err}
|
|
}
|
|
|
|
if err := config.Loadpoints().Add(config.NewStaticDevice(cc, loadpoint.API(instance))); err != nil {
|
|
return &DeviceError{cc.Name, err}
|
|
}
|
|
}
|
|
|
|
// append devices from database
|
|
configurable, err := config.ConfigurationsByClass(templates.Loadpoint)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, conf := range configurable {
|
|
cc := conf.Named()
|
|
idx := len(config.Loadpoints().Devices()) + 1
|
|
|
|
dynamic, static, err := loadpoint.SplitConfig(cc.Other)
|
|
if err != nil {
|
|
return &DeviceError{cc.Name, err}
|
|
}
|
|
|
|
var instance loadpoint.API
|
|
if !conf.Disable {
|
|
lp, e := newLoadpoint(idx, cc.Name, static, func(log *util.Logger) coresettings.Settings {
|
|
return coresettings.NewConfigSettingsAdapter(log, &conf)
|
|
})
|
|
if e != nil {
|
|
err = &DeviceError{cc.Name, e}
|
|
} else {
|
|
instance = lp
|
|
}
|
|
}
|
|
|
|
dev := config.NewConfigurableDevice(&conf, instance)
|
|
if e := config.Loadpoints().Add(dev); e != nil && err == nil {
|
|
err = &DeviceError{cc.Name, e}
|
|
}
|
|
|
|
if instance != nil {
|
|
// ignore dynamic config in case of startup errors that will leave instance empty
|
|
if e := dynamic.Apply(instance); e != nil && err == nil {
|
|
err = &DeviceError{cc.Name, e}
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// configureAuth handles routing for devices. For now only api.AuthProvider related routes
|
|
func configureAuth(router *mux.Router, paramC chan<- util.Param) {
|
|
auth := router.PathPrefix("/providerauth").Subrouter()
|
|
auth.Use(handlers.CompressHandler)
|
|
auth.Use(handlers.CORS(
|
|
handlers.AllowedHeaders([]string{"Content-Type"}),
|
|
))
|
|
|
|
// backwards-compatible revert of https://github.com/evcc-io/evcc/pull/21266
|
|
router.PathPrefix("/oauth").Handler(auth)
|
|
|
|
// wire the handler
|
|
providerauth.Setup(auth, paramC)
|
|
}
|
|
|
|
// isExperimental returns if experimental features are enabled
|
|
func isExperimental() bool {
|
|
b, _ := settings.Bool(keys.Experimental)
|
|
return b
|
|
}
|
|
|
|
// isOptimizer returns if optimizer is enabled
|
|
func isOptimizer() bool {
|
|
b, _ := settings.Bool(keys.Optimizer)
|
|
return b
|
|
}
|
|
|
|
// isMcp returns if MCP service is enabled
|
|
func isMcp() bool {
|
|
return isExperimental()
|
|
}
|