From 7e41a970d589b516f6687cf43ed00ea755dcd78b Mon Sep 17 00:00:00 2001 From: Andreas Linde <42185+DerAndereAndi@users.noreply.github.com> Date: Tue, 30 Nov 2021 14:29:09 +0100 Subject: [PATCH] =?UTF-8?q?Experimentelle=20gef=C3=BChrte=20Erstellung=20d?= =?UTF-8?q?er=20Konfigurationsdatei=20(#1888)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dies ist eine experimentelle Umsetzung im folgenden aufgeführten Ziele. Wie es verwendet: - Start über `evcc configure` - Die Konfiguration wird in evcc.yaml geschrieben. Falls diese existiert kann ein alternativer Dateiname angegeben werden Was es kann: - Eine geführte Erstellung der Konfigurationsdatei - Direktes Testen ob die Konfiguration jedes Gerätes auch funktioniert - Konfigurationsabhängigkeiten durch direkte Konfiguration der Abhängigkeit lösen (z.B. Sponsorshipt required) - Konfiguration in für Anwender bekannte Informationen und möglichst wenig Informationseingabe zu ermöglichen, z.B. anhand von Produkten anstatt aus der Implementierungssicht - Bisherige Konfiguration funktioniert weiter --- .gitignore | 1 + charger/config_test.go | 40 ++-- charger/template.go | 44 ++++ charger/template_test.go | 49 ++++ cmd/configure.go | 41 ++++ cmd/configure/configure.go | 137 +++++++++++ cmd/configure/configure.tpl | 87 +++++++ cmd/configure/devicetest.go | 142 ++++++++++++ cmd/configure/eebus.go | 52 +++++ cmd/configure/flow.go | 197 ++++++++++++++++ cmd/configure/helper.go | 345 ++++++++++++++++++++++++++++ cmd/configure/localization/de.toml | 124 ++++++++++ cmd/configure/localization/en.toml | 124 ++++++++++ cmd/configure/main.go | 272 ++++++++++++++++++++++ cmd/configure/survey.go | 203 ++++++++++++++++ cmd/configure/texts.go | 97 ++++++++ cmd/configure/types.go | 90 ++++++++ cmd/eebus.go | 8 +- go.mod | 4 + go.sum | 20 ++ meter/config_test.go | 37 +-- meter/template.go | 44 ++++ meter/template_test.go | 66 ++++++ schema.json | 3 + server/eebus.go | 14 +- templates/definition/embed.go | 8 + util/templates/generate/generate.go | 109 +++++++++ util/templates/init.go | 73 ++++++ util/templates/modbus.tpl | 18 ++ util/templates/proxy.tpl | 10 + util/templates/template.go | 300 ++++++++++++++++++++++++ util/templates/template_modbus.go | 82 +++++++ util/templates/utils.go | 23 ++ util/templates/utils_test.go | 29 +++ vehicle/config_test.go | 47 ++-- vehicle/template.go | 44 ++++ vehicle/template_test.go | 38 +++ 37 files changed, 2951 insertions(+), 71 deletions(-) create mode 100644 charger/template.go create mode 100644 charger/template_test.go create mode 100644 cmd/configure.go create mode 100644 cmd/configure/configure.go create mode 100644 cmd/configure/configure.tpl create mode 100644 cmd/configure/devicetest.go create mode 100644 cmd/configure/eebus.go create mode 100644 cmd/configure/flow.go create mode 100644 cmd/configure/helper.go create mode 100644 cmd/configure/localization/de.toml create mode 100644 cmd/configure/localization/en.toml create mode 100644 cmd/configure/main.go create mode 100644 cmd/configure/survey.go create mode 100644 cmd/configure/texts.go create mode 100644 cmd/configure/types.go create mode 100644 meter/template.go create mode 100644 meter/template_test.go create mode 100644 templates/definition/embed.go create mode 100644 util/templates/generate/generate.go create mode 100644 util/templates/init.go create mode 100644 util/templates/modbus.tpl create mode 100644 util/templates/proxy.tpl create mode 100644 util/templates/template.go create mode 100644 util/templates/template_modbus.go create mode 100644 util/templates/utils.go create mode 100644 util/templates/utils_test.go create mode 100644 vehicle/template.go create mode 100644 vehicle/template_test.go diff --git a/.gitignore b/.gitignore index ffdb47d2f..5c74f3cb4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __debug_bin *.log *.json *.yaml +!templates/**/*.yaml !package*.json !evcc.dist.yaml evcc diff --git a/charger/config_test.go b/charger/config_test.go index d4a8661fa..c36423ee7 100644 --- a/charger/config_test.go +++ b/charger/config_test.go @@ -6,27 +6,27 @@ import ( "github.com/evcc-io/evcc/util/test" ) -func TestChargers(t *testing.T) { - test.SkipCI(t) +var acceptable = []string{ + "invalid plugin type: ...", + "missing mqtt broker configuration", + "mqtt not configured", + "invalid charger type: nrgkick-bluetooth", + "NRGKick bluetooth is only supported on linux", + "invalid pin:", + "hciconfig provided no response", + "connect: no route to host", + "connect: connection refused", + "error connecting: Network Error", + "i/o timeout", + "recv timeout", + "(Client.Timeout exceeded while awaiting headers)", + "can only have either uri or device", // modbus + "sponsorship required, see https://github.com/evcc-io/evcc#sponsorship", + "eebus not configured", +} - acceptable := []string{ - "invalid plugin type: ...", - "missing mqtt broker configuration", - "mqtt not configured", - "invalid charger type: nrgkick-bluetooth", - "NRGKick bluetooth is only supported on linux", - "invalid pin:", - "hciconfig provided no response", - "connect: no route to host", - "connect: connection refused", - "error connecting: Network Error", - "i/o timeout", - "recv timeout", - "(Client.Timeout exceeded while awaiting headers)", - "can only have either uri or device", // modbus - "sponsorship required, see https://github.com/evcc-io/evcc#sponsorship", - "eebus not configured", - } +func TestConfigChargers(t *testing.T) { + test.SkipCI(t) for _, tmpl := range test.ConfigTemplates("charger") { tmpl := tmpl diff --git a/charger/template.go b/charger/template.go new file mode 100644 index 000000000..61438432f --- /dev/null +++ b/charger/template.go @@ -0,0 +1,44 @@ +package charger + +import ( + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/templates" + "gopkg.in/yaml.v3" +) + +func init() { + registry.Add("template", NewChargerFromTemplateConfig) +} + +func NewChargerFromTemplateConfig(other map[string]interface{}) (api.Charger, error) { + cc := struct { + Template string + Other map[string]interface{} `mapstructure:",remain"` + }{} + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + tmpl, err := templates.ByTemplate(cc.Template, templates.Charger) + if err != nil { + return nil, err + } + + b, _, err := tmpl.RenderResult(false, other) + if err != nil { + return nil, err + } + + var instance struct { + Type string + Other map[string]interface{} `yaml:",inline"` + } + + if err := yaml.Unmarshal(b, &instance); err != nil { + return nil, err + } + + return NewFromConfig(instance.Type, instance.Other) +} diff --git a/charger/template_test.go b/charger/template_test.go new file mode 100644 index 000000000..e5912cb5b --- /dev/null +++ b/charger/template_test.go @@ -0,0 +1,49 @@ +package charger + +import ( + "testing" + + "github.com/evcc-io/evcc/util/templates" + "github.com/evcc-io/evcc/util/test" + "github.com/thoas/go-funk" +) + +func TestChargerTemplates(t *testing.T) { + test.SkipCI(t) + + for _, tmpl := range templates.ByClass(templates.Charger) { + tmpl := tmpl + + // set default values for all params + values := tmpl.Defaults(true) + + // set the template value which is needed for rendering + values["template"] = tmpl.Template + + // set modbus default test values + if values[templates.ParamModbus] != nil { + modbusChoices := tmpl.ModbusChoices() + if funk.ContainsString(modbusChoices, templates.ModbusChoiceTCPIP) { + values[templates.ModbusTCPIP] = true + } else { + values[templates.ModbusRS485TCPIP] = true + } + } + + t.Run(tmpl.Template, func(t *testing.T) { + t.Parallel() + + b, values, err := tmpl.RenderResult(true, values) + if err != nil { + t.Logf("%s: %s", tmpl.Template, b) + t.Error(err) + } + + _, err = NewFromConfig("template", values) + if err != nil && !test.Acceptable(err, acceptable) { + t.Logf("%s", tmpl.Template) + t.Error(err) + } + }) + } +} diff --git a/cmd/configure.go b/cmd/configure.go new file mode 100644 index 000000000..3fbc4f1a1 --- /dev/null +++ b/cmd/configure.go @@ -0,0 +1,41 @@ +package cmd + +import ( + _ "embed" + + "github.com/evcc-io/evcc/cmd/configure" + "github.com/evcc-io/evcc/util" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// configureCmd represents the configure command +var configureCmd = &cobra.Command{ + Use: "configure", + Short: "Create an EVCC configuration", + Run: runConfigure, +} + +func init() { + rootCmd.AddCommand(configureCmd) + configureCmd.Flags().String("lang", "", "Define the localization to be used (en, de)") + configureCmd.Flags().Bool("expand", false, "Enables rendering expanded configuration files") +} + +func runConfigure(cmd *cobra.Command, args []string) { + impl := &configure.CmdConfigure{} + + lang, err := cmd.Flags().GetString("lang") + if err != nil { + log.FATAL.Fatal(err) + } + + expand, err := cmd.Flags().GetBool("expand") + if err != nil { + panic(err) + } + + util.LogLevel(viper.GetString("log"), nil) + + impl.Run(log, lang, expand) +} diff --git a/cmd/configure/configure.go b/cmd/configure/configure.go new file mode 100644 index 000000000..1c748c147 --- /dev/null +++ b/cmd/configure/configure.go @@ -0,0 +1,137 @@ +package configure + +import ( + "bytes" + _ "embed" + "fmt" + "text/template" + + "github.com/Masterminds/sprig/v3" + "github.com/thoas/go-funk" +) + +type device struct { + Name string + Title string + LogLevel string + Yaml string + ChargerHasMeter bool // only used with chargers to detect if we need to ask for a charge meter +} + +type loadpoint struct { + Title string // TODO Perspektivisch können wir was aus core wiederverwenden, für später + Charger string + ChargeMeter string + Vehicles []string + Mode string + MinCurrent int + MaxCurrent int + Phases int +} + +type config struct { + Meters []device + Chargers []device + Vehicles []device + Loadpoints []loadpoint + Site struct { // TODO Perspektivisch können wir was aus core wiederverwenden, für später + Title string + Grid string + PVs []string + Batteries []string + } + LogLevels []string + Hems string + EEBUS string + SponsorToken string +} + +type Configure struct { + config config +} + +// AddLogLevel adds a log level for a specific device name to the configuration +func (c *Configure) AddLogLevel(name string) { + if name == "" || funk.ContainsString(c.config.LogLevels, name) { + return + } + c.config.LogLevels = append(c.config.LogLevels, name) +} + +// AddDevice adds a device reference of a specific category to the configuration +// e.g. a PV meter to site.PVs +func (c *Configure) AddDevice(d device, category DeviceCategory) { + switch DeviceCategories[category].class { + case DeviceClassCharger: + c.AddLogLevel(d.LogLevel) + if c.config.EEBUS != "" { + c.AddLogLevel("eebus") + } + c.config.Chargers = append(c.config.Chargers, d) + case DeviceClassMeter: + c.AddLogLevel(d.LogLevel) + c.config.Meters = append(c.config.Meters, d) + switch DeviceCategories[category].categoryFilter { + case DeviceCategoryGridMeter: + c.config.Site.Grid = d.Name + case DeviceCategoryPVMeter: + c.config.Site.PVs = append(c.config.Site.PVs, d.Name) + case DeviceCategoryBatteryMeter: + c.config.Site.Batteries = append(c.config.Site.Batteries, d.Name) + } + case DeviceClassVehicle: + c.AddLogLevel(d.LogLevel) + c.config.Vehicles = append(c.config.Vehicles, d) + } +} + +// DevicesOfClass returns all configured devices of a given DeviceClass +func (c *Configure) DevicesOfClass(class DeviceClass) []device { + switch class { + case DeviceClassCharger: + return c.config.Chargers + case DeviceClassMeter: + return c.config.Meters + case DeviceClassVehicle: + return c.config.Vehicles + } + return nil +} + +// AddLoadpoint adds a loadpoint to the configuration +func (c *Configure) AddLoadpoint(l loadpoint) { + c.config.Loadpoints = append(c.config.Loadpoints, l) + c.AddLogLevel(fmt.Sprintf("lp-%d", 1+len(c.config.Loadpoints))) +} + +// MetersOfCategory returns the number of configured meters of a given DeviceCategory +func (c *Configure) MetersOfCategory(category DeviceCategory) int { + switch category { + case DeviceCategoryGridMeter: + if c.config.Site.Grid != "" { + return 1 + } + case DeviceCategoryPVMeter: + return len(c.config.Site.PVs) + case DeviceCategoryBatteryMeter: + return len(c.config.Site.Batteries) + } + + return 0 +} + +//go:embed configure.tpl +var configTmpl string + +// RenderConfiguration creates a yaml configuration +func (c *Configure) RenderConfiguration() ([]byte, error) { + tmpl, err := template.New("yaml").Funcs(template.FuncMap(sprig.FuncMap())).Parse(configTmpl) + if err != nil { + panic(err) + } + + out := new(bytes.Buffer) + err = tmpl.Execute(out, c.config) + + return bytes.TrimSpace(out.Bytes()), err +} diff --git a/cmd/configure/configure.tpl b/cmd/configure/configure.tpl new file mode 100644 index 000000000..a79d53350 --- /dev/null +++ b/cmd/configure/configure.tpl @@ -0,0 +1,87 @@ +uri: 0.0.0.0:7070 # uri for ui +interval: 10s # control cycle interval + +log: info +levels: + core: info +{{- if ne (len .LogLevels) 0 }} +{{- range .LogLevels }} + {{ . }}: info +{{- end }} +{{- end }} +{{- if ne (len .Meters) 0 }} + +meters: +{{- range .Meters }} +- {{ .Yaml | indent 2 | trim }} +{{- end }} +{{- end }} +{{- if ne (len .Chargers) 0 }} + +chargers: +{{- range .Chargers }} +- {{ .Yaml | indent 2 | trim }} +{{- end }} +{{- end }} +{{- if ne (len .Vehicles) 0 }} + +vehicles: +{{- range .Vehicles }} +- {{ .Yaml | indent 2 | trim }} +{{- end }} +{{- end }} +{{- if ne (len .Chargers) 0 }} + +loadpoints: +{{- range .Loadpoints }} +- title: {{ .Title }} + charger: {{ .Charger }} +{{- if .ChargeMeter }} + meters: + charge: {{ .ChargeMeter }} +{{- end }} +{{- if ne (len .Vehicles) 0 }} + vehicles: +{{- range .Vehicles }} + - {{ . }} +{{- end }} +{{- end }} + mode: {{ .Mode }} + phases: {{ .Phases }} + mincurrent: {{ .MinCurrent }} + maxcurrent: {{ .MaxCurrent }} +{{- end }} +{{- end }} + +site: + title: {{ .Site.Title }} + meters: +{{- if .Site.Grid }} + grid: {{ .Site.Grid }} +{{- end }} +{{- if len .Site.PVs }} + pvs: +{{- range .Site.PVs }} + - {{ . }} +{{- end }} +{{- end }} +{{- if len .Site.Batteries }} + batteries: +{{- range .Site.Batteries }} + - {{ . }} +{{- end }} +{{- end }} +{{- if ne (len .Hems) 0 }} + +hems: +{{ .Hems | indent 2 }} +{{- end }} +{{- if ne (len .EEBUS) 0 }} + +eebus: +{{ .EEBUS | indent 2 }} +{{- end }} +{{- if ne (len .SponsorToken) 0 }} + +sponsortoken: {{ .SponsorToken }} +{{- end}} diff --git a/cmd/configure/devicetest.go b/cmd/configure/devicetest.go new file mode 100644 index 000000000..0662385a4 --- /dev/null +++ b/cmd/configure/devicetest.go @@ -0,0 +1,142 @@ +package configure + +import ( + "errors" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/charger" + "github.com/evcc-io/evcc/meter" + "github.com/evcc-io/evcc/util/templates" + "github.com/evcc-io/evcc/vehicle" + "gopkg.in/yaml.v3" +) + +type DeviceTestResult string + +const ( + DeviceTestResultValid DeviceTestResult = "Valid" + DeviceTestResultValidMissingMeter DeviceTestResult = "Valid_MissingMeter" + DeviceTestResultInvalid DeviceTestResult = "Invalid" +) + +type DeviceTest struct { + DeviceCategory DeviceCategory + Template templates.Template + ConfigValues map[string]interface{} +} + +// Test returns: +// - DeviceTestResult: Valid, Valid_MissingMeter, Invalid +// - error: != nil if the device is invalid and can not be configured with the provided settings +func (d *DeviceTest) Test() (DeviceTestResult, error) { + v, err := d.configure() + if err != nil { + return DeviceTestResultInvalid, err + } + + switch DeviceCategories[d.DeviceCategory].class { + case DeviceClassCharger: + return d.testCharger(v) + case DeviceClassMeter: + return d.testMeter(d.DeviceCategory, v) + case DeviceClassVehicle: + return d.testVehicle(v) + } + + return DeviceTestResultInvalid, errors.New("testDevice not implemented for this device class") +} + +// configure creates a configured device from a template so we can test it +func (d *DeviceTest) configure() (interface{}, error) { + b, _, err := d.Template.RenderResult(false, d.ConfigValues) + if err != nil { + return nil, err + } + + var instance struct { + Type string + Other map[string]interface{} `yaml:",inline"` + } + + if err := yaml.Unmarshal(b, &instance); err != nil { + return nil, err + } + + var v interface{} + + switch DeviceCategories[d.DeviceCategory].class { + case DeviceClassMeter: + v, err = meter.NewFromConfig(instance.Type, instance.Other) + case DeviceClassCharger: + v, err = charger.NewFromConfig(instance.Type, instance.Other) + case DeviceClassVehicle: + v, err = vehicle.NewFromConfig(instance.Type, instance.Other) + } + + return v, err +} + +// testCharger tests a charger device +func (d *DeviceTest) testCharger(v interface{}) (DeviceTestResult, error) { + if v, ok := v.(api.Charger); ok { + if _, err := v.Status(); err != nil { + return DeviceTestResultInvalid, err + } + } else { + return DeviceTestResultInvalid, errors.New("Selected device is not a wallbox!") + } + + if v, ok := v.(api.Meter); ok { + if _, err := v.CurrentPower(); err != nil { + return DeviceTestResultValidMissingMeter, nil + } + } + + return DeviceTestResultValid, nil +} + +// testMeter tests a meter device +func (d *DeviceTest) testMeter(deviceCategory DeviceCategory, v interface{}) (DeviceTestResult, error) { + if v, ok := v.(api.Meter); ok { + if _, err := v.CurrentPower(); err != nil { + return DeviceTestResultInvalid, err + } + + if deviceCategory == DeviceCategoryBatteryMeter { + if v, ok := v.(api.Battery); ok { + _, err := v.SoC() + + for err != nil && errors.Is(err, api.ErrMustRetry) { + time.Sleep(3 * time.Second) + _, err = v.SoC() + } + + if err != nil { + return DeviceTestResultInvalid, err + } + } else { + return DeviceTestResultInvalid, errors.New("Selected device is not a battery meter!") + } + } + } else { + return DeviceTestResultInvalid, errors.New("Selected device is not a meter!") + } + + return DeviceTestResultValid, nil +} + +// testVehicle tests a vehicle device +func (d *DeviceTest) testVehicle(v interface{}) (DeviceTestResult, error) { + if _, ok := v.(api.Vehicle); ok { + if v, ok := v.(api.Battery); ok { + if _, err := v.SoC(); err != nil { + return DeviceTestResultInvalid, err + } + } + } else { + return DeviceTestResultInvalid, errors.New("Selected device is not a vehicle!") + } + + return DeviceTestResultValid, nil +} diff --git a/cmd/configure/eebus.go b/cmd/configure/eebus.go new file mode 100644 index 000000000..7784c760b --- /dev/null +++ b/cmd/configure/eebus.go @@ -0,0 +1,52 @@ +package configure + +import ( + "crypto/x509/pkix" + "fmt" + + certhelper "github.com/evcc-io/eebus/cert" + "github.com/evcc-io/evcc/server" +) + +// configureEEBus setup EEBus +func (c *CmdConfigure) configureEEBus(conf map[string]interface{}) error { + var err error + if server.EEBusInstance, err = server.NewEEBus(conf); err == nil { + go server.EEBusInstance.Run() + } + + return nil +} + +// eebusCertificate setup EEBUS certificate +// returns privagte key, public key and error +func (c *CmdConfigure) eebusCertificate() (map[string]interface{}, error) { + details := server.EEBUSDetails + + subject := pkix.Name{ + CommonName: details.DeviceCode, + Country: []string{"DE"}, + Organization: []string{details.BrandName}, + } + + var eebusConfig map[string]interface{} + + cert, err := certhelper.CreateCertificate(true, subject) + if err != nil { + return eebusConfig, fmt.Errorf("%s", c.localizedString("Error_EEBUS_Certificate_Create", nil)) + } + + pubKey, privKey, err := certhelper.GetX509KeyPair(cert) + if err != nil { + return eebusConfig, fmt.Errorf("%s", c.localizedString("Error_EEBUS_Certificate_Use", nil)) + } + + eebusConfig = map[string]interface{}{ + "certificate": map[string]string{ + "public": pubKey, + "private": privKey, + }, + } + + return eebusConfig, nil +} diff --git a/cmd/configure/flow.go b/cmd/configure/flow.go new file mode 100644 index 000000000..ee1f693a6 --- /dev/null +++ b/cmd/configure/flow.go @@ -0,0 +1,197 @@ +package configure + +import ( + "errors" + "fmt" + + "github.com/evcc-io/evcc/util/templates" +) + +// configureDeviceGuidedSetup lets the user choose a device that is set to support guided setup +// these are typically devices that +// - contain multiple usages but have the same parameters like host, port, etc. +// - devices that typically are installed with additional specific devices (e.g. SMA Home Manager with SMA Inverters) +func (c *CmdConfigure) configureDeviceGuidedSetup() { + var err error + + var values map[string]interface{} + var deviceCategory DeviceCategory + var supportedDeviceCategories []DeviceCategory + var templateItem templates.Template + + deviceItem := device{} + + for ok := true; ok; { + fmt.Println() + + templateItem, err = c.processDeviceSelection(DeviceCategoryGuidedSetup) + if err != nil { + return + } + + usageChoices := c.paramChoiceValues(templateItem.Params, templates.ParamUsage) + if len(usageChoices) == 0 { + panic("ERROR: Device template is missing valid usages!") + } + if len(usageChoices) == 0 { + usageChoices = []string{string(DeviceCategoryGridMeter), string(DeviceCategoryPVMeter), string(DeviceCategoryBatteryMeter)} + } + + supportedDeviceCategories = []DeviceCategory{} + + for _, usage := range usageChoices { + switch usage { + case string(DeviceCategoryGridMeter): + supportedDeviceCategories = append(supportedDeviceCategories, DeviceCategoryGridMeter) + case string(DeviceCategoryPVMeter): + supportedDeviceCategories = append(supportedDeviceCategories, DeviceCategoryPVMeter) + case string(DeviceCategoryBatteryMeter): + supportedDeviceCategories = append(supportedDeviceCategories, DeviceCategoryBatteryMeter) + } + } + + // we only ask for the configuration for the first usage + deviceCategory = supportedDeviceCategories[0] + + values = c.processConfig(templateItem.Params, deviceCategory, false) + + deviceItem, err = c.processDeviceValues(values, templateItem, deviceItem, deviceCategory) + if err != nil { + if err != c.errDeviceNotValid { + fmt.Println() + fmt.Println(err) + } + fmt.Println() + if !c.askConfigFailureNextStep() { + return + } + continue + } + + break + } + + c.configuration.AddDevice(deviceItem, deviceCategory) + + if len(supportedDeviceCategories) > 1 { + for _, additionalCategory := range supportedDeviceCategories[1:] { + values[templates.ParamUsage] = additionalCategory.String() + deviceItem, err := c.processDeviceValues(values, templateItem, deviceItem, additionalCategory) + if err != nil { + continue + } + + c.configuration.AddDevice(deviceItem, additionalCategory) + } + } + + fmt.Println() + fmt.Println(templateItem.Description + " " + c.localizedString("Device_Added", nil)) + + c.configureLinkedTypes(templateItem) +} + +// configureLinkedTypes lets the user configure devices that are marked as being linked to a guided device +// e.g. SMA Inverters, Energy Meter with SMA Home Manager +func (c *CmdConfigure) configureLinkedTypes(templateItem templates.Template) { + linkedTemplates := templateItem.GuidedSetup.Linked + + if linkedTemplates == nil { + return + } + + for _, linkedTemplate := range linkedTemplates { + for ok := true; ok; { + deviceItem := device{} + + linkedTemplateItem, err := templates.ByTemplate(linkedTemplate.Template, string(DeviceClassMeter)) + if err != nil { + fmt.Println("Error: " + err.Error()) + return + } + if len(linkedTemplateItem.Params) == 0 || linkedTemplate.Usage == "" { + return + } + + category := DeviceCategory(linkedTemplate.Usage) + + fmt.Println() + if !c.askYesNo(c.localizedString("AddLinkedDeviceInCategory", localizeMap{"Linked": linkedTemplateItem.Description, "Article": DeviceCategories[category].article, "Category": DeviceCategories[category].title})) { + break + } + + values := c.processConfig(linkedTemplateItem.Params, category, false) + deviceItem, err = c.processDeviceValues(values, linkedTemplateItem, deviceItem, category) + if err != nil { + if !errors.Is(err, c.errDeviceNotValid) { + fmt.Println() + fmt.Println(err) + } + fmt.Println() + if c.askConfigFailureNextStep() { + continue + } + + } else { + c.configuration.AddDevice(deviceItem, category) + + fmt.Println() + fmt.Println(linkedTemplateItem.Description + " " + c.localizedString("Device_Added", nil)) + } + break + } + } +} + +// configureDeviceCategory lets the user select and configure a device from a specific category +func (c *CmdConfigure) configureDeviceCategory(deviceCategory DeviceCategory) (device, error) { + fmt.Println() + fmt.Printf("- %s %s\n", c.localizedString("Device_Configure", nil), DeviceCategories[deviceCategory].title) + + device := device{ + Name: DeviceCategories[deviceCategory].defaultName, + } + + var deviceDescription string + + // repeat until the device is added or the user chooses to continue without adding a device + for ok := true; ok; { + fmt.Println() + + templateItem, err := c.processDeviceSelection(deviceCategory) + if err != nil { + return device, c.errItemNotPresent + } + + deviceDescription = templateItem.Description + values := c.processConfig(templateItem.Params, deviceCategory, false) + + device, err = c.processDeviceValues(values, templateItem, device, deviceCategory) + if err != nil { + if err != c.errDeviceNotValid { + fmt.Println() + fmt.Println(err) + } + // ask if the user wants to add the + fmt.Println() + if !c.askConfigFailureNextStep() { + return device, err + } + continue + } + + break + } + + c.configuration.AddDevice(device, deviceCategory) + + var deviceTitle string + if device.Title != "" { + deviceTitle = " " + device.Title + } + + fmt.Println() + fmt.Println(deviceDescription + deviceTitle + " " + c.localizedString("Device_Added", nil)) + + return device, nil +} diff --git a/cmd/configure/helper.go b/cmd/configure/helper.go new file mode 100644 index 000000000..5fce7eded --- /dev/null +++ b/cmd/configure/helper.go @@ -0,0 +1,345 @@ +package configure + +import ( + "fmt" + "sort" + "strings" + + "github.com/evcc-io/evcc/util/sponsor" + "github.com/evcc-io/evcc/util/templates" + "github.com/thoas/go-funk" + "gopkg.in/yaml.v3" +) + +// processDeviceSelection processes the the user selected device, check if it is an actual device and make sure the requirements are set +func (c *CmdConfigure) processDeviceSelection(deviceCategory DeviceCategory) (templates.Template, error) { + templateItem := c.selectItem(deviceCategory) + + if templateItem.Description == c.localizedString("ItemNotPresent", nil) { + return templateItem, c.errItemNotPresent + } + + if err := c.processDeviceRequirements(templateItem); err != nil { + return templateItem, err + } + + return templateItem, nil +} + +// processDeviceValues processes the user provided values, create a device configuration and check if it is a valid device +func (c *CmdConfigure) processDeviceValues(values map[string]interface{}, templateItem templates.Template, device device, deviceCategory DeviceCategory) (device, error) { + c.addedDeviceIndex++ + + device.Name = fmt.Sprintf("%s%d", DeviceCategories[deviceCategory].defaultName, c.addedDeviceIndex) + device.Title = templateItem.Description + device.LogLevel = templateItem.LogLevel + for item, value := range values { + if strings.ToLower(item) != "title" { + continue + } + if len(value.(string)) > 0 { + device.Title = value.(string) + } + } + + categoryWithUsage := deviceCategory == DeviceCategoryPVMeter || deviceCategory == DeviceCategoryBatteryMeter || deviceCategory == DeviceCategoryGridMeter + + fmt.Println() + if categoryWithUsage { + fmt.Println(c.localizedString("TestingDevice_TitleUsage", localizeMap{"Device": templateItem.Description, "Usage": deviceCategory.String()})) + } else { + fmt.Println(c.localizedString("TestingDevice_Title", localizeMap{"Device": templateItem.Description})) + } + + deviceTest := DeviceTest{ + DeviceCategory: deviceCategory, + Template: templateItem, + ConfigValues: values, + } + + testResult, err := deviceTest.Test() + if err != nil { + fmt.Println(" ", c.localizedString("Error", localizeMap{"Error": err})) + fmt.Println() + + question := c.localizedString("TestingDevice_AddFailed", localizeMap{"Device": templateItem.Description}) + if categoryWithUsage { + question = c.localizedString("TestingDevice_AddFailedUsage", localizeMap{"Device": templateItem.Description, "Usage": deviceCategory.String()}) + } + if !c.askYesNo(question) { + c.addedDeviceIndex-- + return device, c.errDeviceNotValid + } + } else { + if deviceCategory == DeviceCategoryCharger && testResult == DeviceTestResultValid { + device.ChargerHasMeter = true + } + } + + templateItem.Params = append(templateItem.Params, templates.Param{Name: "name", Value: device.Name}) + if !c.expandedMode { + b, err := templateItem.RenderProxyWithValues(values, false) + if err != nil { + c.addedDeviceIndex-- + return device, err + } + + device.Yaml = string(b) + } else { + for _, p := range templateItem.Params { + if p.Name == "name" { + values["name"] = p.Value + templateItem.Render = fmt.Sprintf("name: {{ .name }}\n%s", templateItem.Render) + } + } + b, _, err := templateItem.RenderResult(false, values) + if err != nil { + c.addedDeviceIndex-- + return device, err + } + + device.Yaml = string(b) + } + + return device, nil +} + +// processDeviceRequirements handles device requirements +func (c *CmdConfigure) processDeviceRequirements(templateItem templates.Template) error { + if len(templateItem.Requirements.Description.String(c.lang)) > 0 { + fmt.Println() + fmt.Println(c.localizedString("Requirements_Title", nil)) + fmt.Println(" ", templateItem.Requirements.Description.String(c.lang)) + if len(templateItem.Requirements.URI) > 0 { + fmt.Println(" " + c.localizedString("Requirements_More", nil) + " " + templateItem.Requirements.URI) + } + } + + // check if sponsorship is required + if templateItem.Requirements.Sponsorship && c.configuration.config.SponsorToken == "" { + fmt.Println() + fmt.Println(c.localizedString("Requirements_Sponsorship_Title", nil)) + fmt.Println() + if !c.askYesNo(c.localizedString("Requirements_Sponsorship_Token", nil)) { + return c.errItemNotPresent + } + sponsortoken := c.askValue(question{ + label: c.localizedString("Requirements_Sponsorship_Token_Input", nil), + required: true}) + c.configuration.config.SponsorToken = sponsortoken + sponsor.Subject = sponsortoken + } + + // check if we need to setup a HEMS + if templateItem.Requirements.Hems != "" && funk.ContainsString(templates.HemsValueTypes, templateItem.Requirements.Hems) { + switch templateItem.Requirements.Hems { + case templates.HemsTypeSMA: + c.configuration.config.Hems = "type: sma\nAllowControl: false\n" + } + } + + // check if we need to setup an EEBUS HEMS + if templateItem.Requirements.Eebus { + if c.configuration.config.EEBUS == "" { + eebusConfig, err := c.eebusCertificate() + if err != nil { + return fmt.Errorf("%s: %s", c.localizedString("Requirements_EEBUS_Cert_Error", nil), err) + } + + err = c.configureEEBus(eebusConfig) + if err != nil { + return err + } + + eebusYaml, err := yaml.Marshal(eebusConfig) + if err != nil { + return err + } + c.configuration.config.EEBUS = string(eebusYaml) + } + + fmt.Println() + fmt.Println(c.localizedString("Requirements_EEBUS_Pairing", nil)) + fmt.Scanln() + } + + return nil +} + +// fetchElements returns template items of a given class +func (c *CmdConfigure) fetchElements(deviceCategory DeviceCategory) []templates.Template { + var items []templates.Template + + for _, tmpl := range templates.ByClass(DeviceCategories[deviceCategory].class.String()) { + if len(tmpl.Params) == 0 || len(tmpl.Description) == 0 { + continue + } + + if deviceCategory == DeviceCategoryGuidedSetup { + if tmpl.GuidedSetup.Enable { + items = append(items, tmpl) + } + } else { + if len(DeviceCategories[deviceCategory].categoryFilter) == 0 || + c.paramChoiceContains(tmpl.Params, templates.ParamUsage, DeviceCategories[deviceCategory].categoryFilter.String()) { + items = append(items, tmpl) + } + } + } + + sort.Slice(items[:], func(i, j int) bool { + // sort generic templates to the bottom + if items[i].Generic && !items[j].Generic { + return false + } + if !items[i].Generic && items[j].Generic { + return true + } + return strings.ToLower(items[i].Description) < strings.ToLower(items[j].Description) + }) + + return items +} + +// paramChoiceContains is a helper function to check if a param choice contains a given value +func (c *CmdConfigure) paramChoiceContains(params []templates.Param, name, filter string) bool { + choices := c.paramChoiceValues(params, name) + + return funk.ContainsString(choices, filter) +} + +// paramChoiceValues provides a list of possible values for a param choice +func (c *CmdConfigure) paramChoiceValues(params []templates.Param, name string) []string { + choices := []string{} + + for _, item := range params { + if item.Name != name { + continue + } + + choices = append(choices, item.Choice...) + } + + return choices +} + +// processConfig processes an EVCC configuration item +// Returns: +// a map with param name and values +func (c *CmdConfigure) processConfig(paramItems []templates.Param, deviceCategory DeviceCategory, includeAdvanced bool) map[string]interface{} { + usageFilter := DeviceCategories[deviceCategory].categoryFilter + + additionalConfig := make(map[string]interface{}) + selectedModbusKey := "" + + fmt.Println() + fmt.Println(c.localizedString("Config_Title", nil)) + fmt.Println() + + for _, param := range paramItems { + if param.Name == templates.ParamModbus { + // baudrate and comset defaults can be overwritten, as they are device specific + deviceDefaultBaudrate := templates.ModbusParamValueBaudrate + if param.Baudrate != 0 { + deviceDefaultBaudrate = param.Baudrate + } + deviceDefaultComset := templates.ModbusParamValueComset + if param.Comset != "" { + deviceDefaultBaudrate = param.Baudrate + } + + choices := []string{} + choiceKeys := []string{} + for _, choice := range param.Choice { + switch choice { + case templates.ModbusChoiceRS485: + choices = append(choices, "Serial (USB-RS485 Adapter)") + choiceKeys = append(choiceKeys, templates.ModbusKeyRS485Serial) + choices = append(choices, "Serial (Ethernet-RS485 Adapter)") + choiceKeys = append(choiceKeys, templates.ModbusKeyRS485TCPIP) + case templates.ModbusChoiceTCPIP: + choices = append(choices, "TCP/IP") + choiceKeys = append(choiceKeys, templates.ModbusKeyTCPIP) + } + } + + if len(choices) > 0 { + // ask for modbus address + id := c.askValue(question{ + label: "ID", + help: "Modbus ID", + defaultValue: 1, + valueType: templates.ParamValueTypeNumber, + required: true}) + additionalConfig[templates.ModbusParamNameId] = id + + // ask for modbus interface type + index := 0 + if len(choices) > 1 { + index, _ = c.askChoice(c.localizedString("Config_ModbusInterface", nil), choices) + } + selectedModbusKey = choiceKeys[index] + switch selectedModbusKey { + case templates.ModbusKeyRS485Serial: + device := c.askValue(question{ + label: "Device", + help: "USB-RS485 Adapter Adresse", + exampleValue: templates.ModbusParamValueDevice, + required: true}) + additionalConfig[templates.ModbusParamNameDevice] = device + + baudrate := c.askValue(question{ + label: "Baudrate", + defaultValue: deviceDefaultBaudrate, + valueType: templates.ParamValueTypeNumber, + required: true}) + additionalConfig[templates.ModbusParamNameBaudrate] = baudrate + + comset := c.askValue(question{ + label: "ComSet", + defaultValue: deviceDefaultComset, + required: true}) + additionalConfig[templates.ModbusParamNameComset] = comset + + case templates.ModbusKeyRS485TCPIP, templates.ModbusKeyTCPIP: + if selectedModbusKey == templates.ModbusKeyRS485TCPIP { + additionalConfig[templates.ModbusParamNameRTU] = "true" + } + host := c.askValue(question{ + label: "Host", + exampleValue: templates.ModbusParamValueHost, + required: true}) + additionalConfig[templates.ModbusParamNameHost] = host + + port := c.askValue(question{ + label: "Port", + defaultValue: templates.ModbusParamValuePort, + valueType: templates.ParamValueTypeNumber, + required: true}) + additionalConfig[templates.ModbusParamNamePort] = port + } + } + } else if param.Name != templates.ParamUsage { + if !includeAdvanced && param.Advanced { + continue + } + + userFriendly := c.userFriendlyTexts(param) + value := c.askValue(question{ + label: userFriendly.Name, + defaultValue: userFriendly.Default, + exampleValue: userFriendly.Example, + help: userFriendly.Help.String(c.lang), + valueType: userFriendly.ValueType, + mask: userFriendly.Mask, + required: userFriendly.Required}) + additionalConfig[param.Name] = value + } else if param.Name == templates.ParamUsage { + if usageFilter != "" { + additionalConfig[param.Name] = usageFilter.String() + } + } + } + + return additionalConfig +} diff --git a/cmd/configure/localization/de.toml b/cmd/configure/localization/de.toml new file mode 100644 index 000000000..1724fa40c --- /dev/null +++ b/cmd/configure/localization/de.toml @@ -0,0 +1,124 @@ +ItemNotPresent = "Mein Gerät ist nicht in der Liste" + +AddDeviceInCategory = "Möchtest du {{ .Article }} {{ .Category }} hinzufügen?" +AddAnotherDeviceInCategory = "Möchtest du noch {{ .Additional }} {{ .Category }} hinzufügen?" +AddLinkedDeviceInCategory = "Möchtest du '{{ .Linked }}' als {{ .Article }} {{ .Category }} hinzufügen?" + +Error = "Fehler: {{ .Error }}" +Error_ItemNotPresent = "Gerät nicht vorhanden" +Error_DeviceNotValid = "Das Gerät funktioniert nicht" + +Error_EEBUS_Certificate_Create = "Konnte das EEBUS Zertifikat nicht erstellen" +Error_EEBUS_Certificate_Use = "Konnte das EEBUS Zertifikat nicht verwenden" + +File_Exists = "Die Datei {{ .FileName }} existiert bereits. Soll die Datei überschrieben werden?" +File_NewFilename = "Bitte gib einen neuen Dateinamen an" +File_Error_SaveFailed = "Die Konfiguration konnte nicht in der Datei {{ .FileName }} gespeichert werden" +File_SaveSuccess = "Die Konfiguration wurde erfolgreich in der Datei {{ .FileName }} gespeichert" + +Choose = "Wähle" + +UserFriendly_Title_Name = "Titel" +UserFriendly_Title_Help = "Wird in der Benutzeroberfläche angezeigt" +UserFriendly_Device_Name = "Geräteadresse" +UserFriendly_Baudrate_Name = "Baudrate" +UserFriendly_ComSet_Name = "ComSet" +UserFriendly_Host_Name = "IP-Adresse oder Hostname" +UserFriendly_Port_Name = "Port" +UserFriendly_User_Name = "Benutzername" +UserFriendly_Password_Name = "Passwort" +UserFriendly_Capacity_Name = "Akku-Kapazität in kWh" +UserFriendly_Vin_Name = "Fahrzeugidentifikationsnummer" +UserFriendly_Vin_Help = "Erforderlich, wenn mehrere Fahrzeuge des Herstellers vorhanden sind" +UserFriendly_Identifier_Name = "Identifikationsnummer" +UserFriendly_Identifier_Help = "Kann meist erst später eingetragen werden, siehe: https://docs.evcc.io/docs/guides/vehicles/#erkennung-des-fahrzeugs-an-der-wallbox" +UserFriendly_StandByPower_Name = "Standby-Leistung in W" +UserFriendly_StandByPower_Help = "Leistung oberhalb des angegebenen Wertes wird als Ladeleistung gewertet" + +Category_ChargerTitle = "Wallbox" +Category_ChargerArticle = "eine" +Category_ChargerAdditional = "eine weitere" +Category_SystemTitle = "PV System" +Category_SystemArticle = "ein" +Category_SystemAdditional = "ein weiteres" +Category_GridMeterTitle = "Netz-Stromzähler" +Category_GridMeterArticle = "einen" +Category_GridMeterAdditional = "einen weiteren" +Category_PVMeterTitle = "PV Wechselrichter (oder entsprechender Stromzähler)" +Category_PVMeterArticle = "einen" +Category_PVMeterAdditional = "einen weiteren" +Category_BatteryMeter = "Batterie Wechselrichter (oder entsprechender Stromzähler)" +Category_BatteryMeterArticle = "einen" +Category_BatteryMeterAdditional = "einen weiteren" +Category_ChargeMeterTitle = "Ladestromzähler" +Category_ChargeMeterArticle = "einen" +Category_ChargeMeterAdditional = "einen weiteren" +Category_VehicleTitle = "Fahrzeug" +Category_VehicleArticle = "ein" +Category_VehicleAdditional = "ein weiteres" + +TestingDevice_Title = "Teste die Konfiguration von {{ .Device }} ..." +TestingDevice_TitleUsage = "Teste die {{ .Usage}} Konfiguration von {{ .Device }} ..." +TestingDevice_RepeatStep = "Möchtest du erneut ein Gerät aus der Liste auswählen und einrichten?" +TestingDevice_AddFailed = "Der Test von {{ .Device }} ist fehlgeschlagen. Soll es trotzdem in die Konfiguration aufgenommen werden?" +TestingDevice_AddFailedUsage = "Der Test der {{ .Usage }} Konfiguration von {{ .Device }} ist fehlgeschlagen. Soll es trotzdem in die Konfiguration aufgenommen werden?" + +Requirements_Title = "Das Gerät muss die folgenden Voraussetzungen erfüllen:" +Requirements_More = "Weitere Informationen:" + +Requirements_Sponsorship_Title = "Dieses Gerät benötigt ein Sponsorship von evcc. Wie das funktioniert und was ist, findest du hier: https://docs.evcc.io/docs/sponsorship" +Requirements_Sponsorship_Token = "Bist du ein Sponsor?" +Requirements_Sponsorship_Token_Input = "Bitte gib das Sponsortoken ein" + +Requirements_EEBUS_Cert_Error = "Fehler: Das EEBUS Zertifikat konnte nicht erstellt werden" +Requirements_EEBUS_Pairing = "Du hast eine Wallbox ausgewählt, welche über das EEBUS Protokoll angesprochen wird.\nDazu muss die Wallbox nun mit evcc verbunden werden. Dies geschieht üblicherweise auf der Webseite der Wallbox.\nDrücke die Enter-Taste, wenn dies abgeschlossen ist." + +Config_Title = "Führe folgende Einstellungen durch:" +Config_ModbusInterface = "Wähle die ModBus Schnittstelle aus" + +Cancel = "Die Konfiguration wurde abgebrochen.\n\nFalls diese geführte Konfiguration für dich noch nicht funktioniert, versuche es doch mal mit der manuellen Konfiguration. Details findest du auf der folgenden Webseite: https://docs.evcc.io/docs/installation/configuration\n" +InputError = "Bei der Eingabe ist ein Fehler aufgetreten:" + +Value_Required = "erforderlich" +Value_Optional = "optional" +Value_Sample = "Beispiel" +Value_Bool = "'true' für ja oder 'false' für nein" +ValueError_Used = "Dieser Wert wird bereits verwendet." +ValueError_Empty = "Der Wert darf nicht leer sein." +ValueError_Bool = "Der Wert muss 'true' (für ja) oder 'false' (für nein) sein." +ValueError_Float = "Der Wert muss eine Zahl sein. Nachkommastellen mit . anstatt mit , trennen." +ValueError_Number = "Der Wert muss eine ganzzahlige Zahl sein." + +Device_Configure = "Konfiguration" +Device_Added = "wurde erfolgreich hinzugefügt." + +Intro = "Die nächsten Schritte führen durch die Einrichtung einer Konfigurationsdatei für evcc.\nBeachte dass dieser Prozess nicht alle möglichen Szenarien berücksichtigen kann.\nDurch Drücken von CTRL-C kann der Prozess abgebrochen werden.\n\nACHTUNG: Diese Funktionalität hat experimentellen Status!\n D.h. es kann möglich sein, dass die hiermit erstellte Konfigurationsdatei\n in einem Update nicht mehr funktionieren könnte und neu erzeugt werden müsste.\n Wir freuen uns auf euer Feedback auf https://github.com/evcc-io/evcc/discussions/\n\nAuf geht`s:" + +Meters_Setup = "- Hausinstallation einrichten" +Meters_Guide_Select = "Wähle eines der folgenden PV Komplettsysteme aus, oder '{{ .ItemNotPresent }}' falls keines dieser Geräte vorhanden ist" + +Loadpoint_Setup = "- Ladepunkt(e) einrichten" +Loadpoint_Title = "Titel des Ladepunktes" +Loadpoint_AddAnother = "Möchtest du einen weiteren Ladepunkt hinzufügen?" +Loadpoint_DefaultTitle = "Garage" + +Loadpoint_WallboxWOMeter = "Die Wallbox hat keinen Ladestromzähler. Hast du einen externen Zähler dafür installiert, der verwendet werden kann?" +Loadpoint_WallboxMaxPower = "Was ist die maximale Leistung, welche die Wallbox zur Verfügung stellen kann?" +Loadpoint_WallboxMaxAmperage = "Was ist die maximale Stromstärke, welche die Wallbox auf einer Phase zur Verfügung stellen kann?" +Loadpoint_WallboxPhases = "Mit wievielen Phasen ist die Wallbox angeschlossen?" +Loadpoint_WallboxPower36kW = "3,6 kW" +Loadpoint_WallboxPower11kW = "11 kW" +Loadpoint_WallboxPower22kW = "22 kW" +Loadpoint_WallboxPowerOther = "Andere Leistung" + +Loadpoint_VehicleChargeHere = "Wird das Fahrzeug {{ .Vehicle }} hier laden?" + +Loadpoint_DefaultChargeMode = "Was sollte der Standard-Lademodus sein, wenn ein Fahrzeug angeschlossen wird?" +Loadpoint_ChargeModeOff = "Aus" +Loadpoint_ChargeModeNow = "Sofort (mit größtmöglicher Leistung)" +Loadpoint_ChargeModeMinPV = "Min+PV (mit der kleinstmöglichen Leistung, schneller wenn genügend PV Überschuss vorhanden ist)" +Loadpoint_ChargeModePV = "PV (Nur mit PV Überschuss)" + +Site_Setup = "- Standort einrichten" +Site_Title = "Titel des Standortes" +Site_DefaultTitle = "Mein Zuhause" diff --git a/cmd/configure/localization/en.toml b/cmd/configure/localization/en.toml new file mode 100644 index 000000000..a610f3471 --- /dev/null +++ b/cmd/configure/localization/en.toml @@ -0,0 +1,124 @@ +ItemNotPresent = "My device is not in this list" + +AddDeviceInCategory = "Do you want to add {{ .Article }} {{ .Category }}?" +AddAnotherDeviceInCategory = "Do you want to add {{ .Additional }} {{ .Category }}?" +AddLinkedDeviceInCategory = "Do you want to add '{{ .Linked }}' as {{ .Article }} {{ .Category }}?" + +Error = "Error: {{ .Error }}" +Error_ItemNotPresent = "Device not present" +Error_DeviceNotValid = "Device does not work" + +Error_EEBUS_Certificate_Create = "Could not create the EEBUS certificate" +Error_EEBUS_Certificate_Use = "Could not process teh generated EEBUS certificate" + +File_Exists = "The file {{ .FileName }} already exists. Do you want to replace it?" +File_NewFilename = "Please provide a new filename" +File_Error_SaveFailed = "The configuration could not be saved in the file {{ .FileName }}" +File_SaveSuccess = "The configuration was successfully saved in the file {{ .FileName }}" + +Choose = "Choose" + +UserFriendly_Title_Name = "Title" +UserFriendly_Title_Help = "Will be displayed in the user interface" +UserFriendly_Device_Name = "Device address" +UserFriendly_Baudrate_Name = "Baudrate" +UserFriendly_ComSet_Name = "ComSet" +UserFriendly_Host_Name = "IP-Address or Hostname" +UserFriendly_Port_Name = "Port" +UserFriendly_User_Name = "Username" +UserFriendly_Password_Name = "Password" +UserFriendly_Capacity_Name = "Battery-Capacity in kWh" +UserFriendly_Vin_Name = "Vehicle Identification Number" +UserFriendly_Vin_Help = "Required if you own multiple vehicles of the same brand" +UserFriendly_Identifier_Name = "Identification" +UserFriendly_Identifier_Help = "Mostly this can be added later, see: https://docs.evcc.io/docs/guides/vehicles/#erkennung-des-fahrzeugs-an-der-wallbox" +UserFriendly_StandByPower_Name = "Standby power in W" +UserFriendly_StandByPower_Help = "Power values above this value will be considered as charging power" + +Category_ChargerTitle = "wallbox" +Category_ChargerArticle = "a" +Category_ChargerAdditional = "one more" +Category_SystemTitle = "PV system" +Category_SystemArticle = "a" +Category_SystemAdditional = "one more" +Category_GridMeterTitle = "grid meter" +Category_GridMeterArticle = "a" +Category_GridMeterAdditional = "one more" +Category_PVMeterTitle = "PV inverter (or corresponding meter)" +Category_PVMeterArticle = "a" +Category_PVMeterAdditional = "one more" +Category_BatteryMeter = "battery inverter (or corresponding meter)" +Category_BatteryMeterArticle = "a" +Category_BatteryMeterAdditional = "one more" +Category_ChargeMeterTitle = "charge meter" +Category_ChargeMeterArticle = "a" +Category_ChargeMeterAdditional = "one more" +Category_VehicleTitle = "vehicles" +Category_VehicleArticle = "a" +Category_VehicleAdditional = "one more" + +TestingDevice_Title = "Testing the configuration of {{ .Device }} ..." +TestingDevice_TitleUsage = "Testing the {{ .Usage }} configuration of {{ .Device }} ..." +TestingDevice_RepeatStep = "Do you want to repeat choosing a device and configuring it?" +TestingDevice_AddFailed = "Testing {{ .Device }} failed. Do you want to add it anyway?" +TestingDevice_AddFailedUsage = "Testing of the {{ .Usage }} configuration of {{ .Device }} failed. Do you want to add it anyway?" + +Requirements_Title = "The device needs to provide the following requirements:" +Requirements_More = "Additional information:" + +Requirements_Sponsorship_Title = "This device requires an evcc sponsorship. Check the following link for what this is and how it works: https://docs.evcc.io/docs/sponsorship" +Requirements_Sponsorship_Token = "Are you already a sponsor?" +Requirements_Sponsorship_Token_Input = "Please enter the sponsortoken" + +Requirements_EEBUS_Cert_Error = "Error: The EEBUS certificate couldn't be created" +Requirements_EEBUS_Pairing = "You selected a wallbox, which will be access via the EEBUS protocoll.\nFor that the wallbox needs to be connected to evcc. This can usually be done in the web interface of the wallbox.\nPlease press the enter key once this is done." + +Config_Title = "Please provide the following settings:" +Config_ModbusInterface = "Choose the ModBus interface" + +Cancel = "The configuration was cancelled.\n\nIf this guided configuration process doesn't work for you yet, please try the manual configuration. You can find more details about that on our website: https://docs.evcc.io/docs/installation/configuration\n" +InputError = "An input error occurred:" + +Value_Required = "required" +Value_Optional = "optional" +Value_Sample = "Example" +Value_Bool = "'true' for yes or 'false' for no" +ValueError_Used = "This value is aready in use." +ValueError_Empty = "The value may not be empty." +ValueError_Bool = "The value has to be 'true' (for yes) or 'false' (for no)." +ValueError_Float = "The value has to be a number." +ValueError_Number = "The value has to be an integer." + +Device_Configure = "Configuration" +Device_Added = "was successfully added." + +Intro = "The next steps will guide you through the creation of a configuration file for evcc.\nPlease note that this process cannot cover all possible scenarios.\nYou can cancel the process by pressing CTRL-C anytime.\n\nNOTE: This functionality is in an experimental state!\n This means that the created configuration file may not work\n in a future update of evcc and would need to be recreated.\n We are looking forward to your feedback at https://github.com/evcc-io/evcc/discussions/\n\nLet's go:" + +Meters_Setup = "- Setup meters (house installation)" +Meters_Guide_Select = "Choose one of the following PV systems, or '{{ .ItemNotPresent }}' if you have none of them" + +Loadpoint_Setup = "- Setup loadpoint(s)" +Loadpoint_Title = "Loadpoint title" +Loadpoint_AddAnother = "Do you want to add another loadpoint?" +Loadpoint_DefaultTitle = "Garage" + +Loadpoint_WallboxWOMeter = "The wallbox does not provide power data. Do you have an external meter that can be used?" +Loadpoint_WallboxMaxPower = "What is the maximum power your wallbox can provide?" +Loadpoint_WallboxMaxAmperage = "What is the maximum amperage your wallbox can provide on a single phase?" +Loadpoint_WallboxPhases = "How many phases are connected to the wallbox?" +Loadpoint_WallboxPower36kW = "3.6 kW" +Loadpoint_WallboxPower11kW = "11 kW" +Loadpoint_WallboxPower22kW = "22 kW" +Loadpoint_WallboxPowerOther = "Other option" + +Loadpoint_VehicleChargeHere = "Will the vehicle {{ .Vehicle }} be charging here?" + +Loadpoint_DefaultChargeMode = "Was sollte der Standard-Lademodus sein, wenn ein Fahrzeug angeschlossen wird?" +Loadpoint_ChargeModeOff = "Off" +Loadpoint_ChargeModeNow = "Now (charging with maximum power)" +Loadpoint_ChargeModeMinPV = "Min+PV (charging with minimum power, faster if enough PV power surplus is available)" +Loadpoint_ChargeModePV = "PV (Only with PV power surplus)" + +Site_Setup = "- Setup site" +Site_Title = "Site title" +Site_DefaultTitle = "My home" diff --git a/cmd/configure/main.go b/cmd/configure/main.go new file mode 100644 index 000000000..d0e60aa7f --- /dev/null +++ b/cmd/configure/main.go @@ -0,0 +1,272 @@ +package configure + +import ( + _ "embed" + "errors" + "fmt" + "os" + "strconv" + + "github.com/BurntSushi/toml" + "github.com/cloudfoundry/jibber_jabber" + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/server" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/templates" + "github.com/nicksnyder/go-i18n/v2/i18n" + "golang.org/x/text/language" +) + +//go:embed localization/de.toml +var lang_de string + +//go:embed localization/en.toml +var lang_en string + +type CmdConfigure struct { + configuration Configure + localizer *i18n.Localizer + log *util.Logger + + lang string + expandedMode bool + addedDeviceIndex int + errItemNotPresent, errDeviceNotValid error +} + +// Run starts the interactive configuration +func (c *CmdConfigure) Run(log *util.Logger, flagLang string, expandedMode bool) { + c.log = log + c.expandedMode = expandedMode + + c.log.INFO.Printf("evcc %s (%s)", server.Version, server.Commit) + + bundle := i18n.NewBundle(language.German) + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + if _, err := bundle.ParseMessageFileBytes([]byte(lang_de), "localization/de.toml"); err != nil { + panic(err) + } + if _, err := bundle.ParseMessageFileBytes([]byte(lang_en), "localization/en.toml"); err != nil { + panic(err) + } + + c.lang = "de" + systemLanguage, err := jibber_jabber.DetectLanguage() + if err == nil { + c.lang = systemLanguage + } + if flagLang != "" { + c.lang = flagLang + } + + c.localizer = i18n.NewLocalizer(bundle, c.lang) + + c.setDefaultTexts() + + fmt.Println() + fmt.Println(c.localizedString("Intro", nil)) + fmt.Println() + fmt.Println(c.localizedString("Meters_Setup", nil)) + fmt.Println() + fmt.Println(c.localizedString("Meters_Guide_Select", localizeMap{"ItemNotPresent": c.localizedString("ItemNotPresent", nil)})) + c.configureDeviceGuidedSetup() + + c.configureDevices(DeviceCategoryGridMeter, false) + c.configureDevices(DeviceCategoryPVMeter, true) + c.configureDevices(DeviceCategoryBatteryMeter, true) + c.configureDevices(DeviceCategoryVehicle, true) + c.configureLoadpoints() + c.configureSite() + + yaml, err := c.configuration.RenderConfiguration() + if err != nil { + c.log.FATAL.Fatal(err) + } + + fmt.Println() + + filename := DefaultConfigFilename + + for ok := true; ok; { + _, err := os.Open(filename) + if errors.Is(err, os.ErrNotExist) { + break + } + + if c.askYesNo(c.localizedString("File_Exists", localizeMap{"FileName": filename})) { + break + } + + filename = c.askValue(question{ + label: c.localizedString("File_NewFilename", nil), + exampleValue: "evcc_neu.yaml", + required: true}) + } + + err = os.WriteFile(filename, yaml, 0755) + if err != nil { + fmt.Printf("%s: ", c.localizedString("File_Error_SaveFailed", localizeMap{"FileName": filename})) + c.log.FATAL.Fatal(err) + } + fmt.Println(c.localizedString("File_SaveSuccess", localizeMap{"FileName": filename})) +} + +// configureDevices asks device specfic questions +func (c *CmdConfigure) configureDevices(deviceCategory DeviceCategory, askMultiple bool) []device { + var devices []device + + if deviceCategory == DeviceCategoryGridMeter && c.configuration.MetersOfCategory(deviceCategory) > 0 { + return nil + } + + localizeMap := localizeMap{ + "Article": DeviceCategories[deviceCategory].article, + "Additional": DeviceCategories[deviceCategory].additional, + "Category": DeviceCategories[deviceCategory].title, + } + addDeviceText := c.localizedString("AddDeviceInCategory", localizeMap) + if c.configuration.MetersOfCategory(deviceCategory) > 0 { + addDeviceText = c.localizedString("AddAnotherDeviceInCategory", localizeMap) + } + + fmt.Println() + if !c.askYesNo(addDeviceText) { + return nil + } + + for ok := true; ok; { + device, err := c.configureDeviceCategory(deviceCategory) + if err != nil { + break + } + devices = append(devices, device) + + if !askMultiple { + break + } + + fmt.Println() + if !c.askYesNo(c.localizedString("AddAnotherDeviceInCategory", localizeMap)) { + break + } + } + + return devices +} + +// configureLoadpoints asks loadpoint specific questions +func (c *CmdConfigure) configureLoadpoints() { + fmt.Println() + fmt.Println(c.localizedString("Loadpoint_Setup", nil)) + + for ok := true; ok; { + + loadpointTitle := c.askValue(question{ + label: c.localizedString("Loadpoint_Title", nil), + defaultValue: c.localizedString("Loadpoint_DefaultTitle", nil), + required: true}) + loadpoint := loadpoint{ + Title: loadpointTitle, + Phases: 3, + MinCurrent: 6, + } + + charger, err := c.configureDeviceCategory(DeviceCategoryCharger) + if err != nil { + break + } + chargerHasMeter := charger.ChargerHasMeter + + loadpoint.Charger = charger.Name + + if !chargerHasMeter { + if c.askYesNo(c.localizedString("Loadpoint_WallboxWOMeter", nil)) { + chargeMeter, err := c.configureDeviceCategory(DeviceCategoryChargeMeter) + if err == nil { + loadpoint.ChargeMeter = chargeMeter.Name + chargerHasMeter = true + } + } + } + + vehicles := c.configuration.DevicesOfClass(DeviceClassVehicle) + if len(vehicles) == 1 { + loadpoint.Vehicles = append(loadpoint.Vehicles, vehicles[0].Name) + } else if len(vehicles) > 1 { + for _, vehicle := range vehicles { + if c.askYesNo(c.localizedString("Loadpoint_VehicleChargeHere", localizeMap{"Vehicle": vehicle.Title})) { + loadpoint.Vehicles = append(loadpoint.Vehicles, vehicle.Name) + } + } + } + + powerChoices := []string{ + c.localizedString("Loadpoint_WallboxPower36kW", nil), + c.localizedString("Loadpoint_WallboxPower11kW", nil), + c.localizedString("Loadpoint_WallboxPower22kW", nil), + c.localizedString("Loadpoint_WallboxPowerOther", nil), + } + fmt.Println() + powerIndex, _ := c.askChoice(c.localizedString("Loadpoint_WallboxMaxPower", nil), powerChoices) + switch powerIndex { + case 0: + loadpoint.MaxCurrent = 16 + if !chargerHasMeter { + loadpoint.Phases = 1 + } + case 1: + loadpoint.MaxCurrent = 16 + if !chargerHasMeter { + loadpoint.Phases = 3 + } + case 2: + loadpoint.MaxCurrent = 32 + if !chargerHasMeter { + loadpoint.Phases = 3 + } + case 3: + amperage := c.askValue(question{ + label: c.localizedString("Loadpoint_WallboxMaxAmperage", nil), + valueType: templates.ParamValueTypeNumber, + required: true}) + loadpoint.MaxCurrent, _ = strconv.Atoi(amperage) + + if !chargerHasMeter { + phaseChoices := []string{"1", "2", "3"} + fmt.Println() + phaseIndex, _ := c.askChoice(c.localizedString("Loadpoint_WallboxPhases", nil), phaseChoices) + loadpoint.Phases = phaseIndex + 1 + } + } + + chargingModes := []string{string(api.ModeOff), string(api.ModeNow), string(api.ModeMinPV), string(api.ModePV)} + chargeModes := []string{ + c.localizedString("Loadpoint_ChargeModeOff", nil), + c.localizedString("Loadpoint_ChargeModeNow", nil), + c.localizedString("Loadpoint_ChargeModeMinPV", nil), + c.localizedString("Loadpoint_ChargeModePV", nil), + } + fmt.Println() + modeChoice, _ := c.askChoice(c.localizedString("Loadpoint_DefaultChargeMode", nil), chargeModes) + loadpoint.Mode = chargingModes[modeChoice] + + c.configuration.AddLoadpoint(loadpoint) + + fmt.Println() + if !c.askYesNo(c.localizedString("Loadpoint_AddAnother", nil)) { + break + } + } +} + +// configureSite asks site specific questions +func (c *CmdConfigure) configureSite() { + fmt.Println() + fmt.Println(c.localizedString("Site_Setup", nil)) + + siteTitle := c.askValue(question{ + label: c.localizedString("Site_Title", nil), + defaultValue: c.localizedString("Site_DefaultTitle", nil), + required: true}) + c.configuration.config.Site.Title = siteTitle +} diff --git a/cmd/configure/survey.go b/cmd/configure/survey.go new file mode 100644 index 000000000..e8c1e4edc --- /dev/null +++ b/cmd/configure/survey.go @@ -0,0 +1,203 @@ +package configure + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/evcc-io/evcc/util/templates" + "github.com/thoas/go-funk" +) + +// surveyAskOne asks the user for input +func (c *CmdConfigure) surveyAskOne(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error { + opts = append(opts, survey.WithIcons(func(icons *survey.IconSet) { + icons.Question.Text = "" + })) + err := survey.AskOne(p, response, opts...) + + if err != nil { + if err == terminal.InterruptErr { + fmt.Println(c.localizedString("Cancel", nil)) + os.Exit(0) + } + fmt.Printf("%s %s\n", c.localizedString("InputError", nil), err) + } + + return err +} + +// askConfigFailureNextStep asks the user if he/she wants to select another device because the current does not work, or continue +func (c *CmdConfigure) askConfigFailureNextStep() bool { + fmt.Println() + return c.askYesNo(c.localizedString("TestingDevice_RepeatStep", nil)) +} + +// select item from list +func (c *CmdConfigure) askSelection(message string, items []string) (error, string, int) { + selection := "" + prompt := &survey.Select{ + Message: message, + Options: items, + } + + err := c.surveyAskOne(prompt, &selection) + if err != nil { + return err, "", 0 + } + + var selectedIndex int + for index, item := range items { + if item == selection { + selectedIndex = index + break + } + } + + return err, selection, selectedIndex +} + +// selectItem selects item from list +func (c *CmdConfigure) selectItem(deviceCategory DeviceCategory) templates.Template { + var emptyItem templates.Template + emptyItem.Description = c.localizedString("ItemNotPresent", nil) + + elements := c.fetchElements(deviceCategory) + elements = append(elements, emptyItem) + + var items []string + for _, item := range elements { + if item.Description != "" { + items = append(items, item.Description) + } + } + + text := fmt.Sprintf("%s %s %s:", c.localizedString("Choose", nil), DeviceCategories[deviceCategory].article, DeviceCategories[deviceCategory].title) + err, _, selected := c.askSelection(text, items) + if err != nil { + c.log.FATAL.Fatal(err) + } + + return elements[selected] +} + +// askChoice selects item from list +func (c *CmdConfigure) askChoice(label string, choices []string) (int, string) { + err, selection, index := c.askSelection(label, choices) + if err != nil { + c.log.FATAL.Fatal(err) + } + + return index, selection +} + +// askYesNo asks yes/no question, return true if yes is selected +func (c *CmdConfigure) askYesNo(label string) bool { + confirmation := false + prompt := &survey.Confirm{ + Message: label, + } + + err := c.surveyAskOne(prompt, &confirmation) + if err != nil { + c.log.FATAL.Fatal(err) + } + + return confirmation +} + +type question struct { + label, help string + defaultValue, exampleValue interface{} + invalidValues []string + valueType string + mask, required bool +} + +// askValue asks for value input for a given question (template param) +func (c *CmdConfigure) askValue(q question) string { + input := "" + + var err error + + validate := func(val interface{}) error { + value := val.(string) + if q.invalidValues != nil && funk.ContainsString(q.invalidValues, value) { + return errors.New(c.localizedString("ValueError_Used", nil)) + } + + if q.required && len(value) == 0 { + return errors.New(c.localizedString("ValueError_Empty", nil)) + } + + if q.valueType == templates.ParamValueTypeBool { + if strings.ToLower(value) != "true" && strings.ToLower(value) != "false" { + return errors.New(c.localizedString("ValueError_Bool", nil)) + } + } + + if q.valueType == templates.ParamValueTypeFloat { + _, err := strconv.ParseFloat(value, 64) + if err != nil { + return errors.New(c.localizedString("ValueError_Float", nil)) + } + } + + if q.valueType == templates.ParamValueTypeNumber { + _, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return errors.New(c.localizedString("ValueError_Number", nil)) + } + } + + return nil + } + + help := q.help + if q.required { + help += " (" + c.localizedString("Value_Required", nil) + ")" + } else { + help += " (" + c.localizedString("Value_Optional", nil) + ")" + } + if q.exampleValue != "" { + help += fmt.Sprintf(" ("+c.localizedString("Value_Sample", nil)+": %s)", q.exampleValue) + } + if q.valueType == templates.ParamValueTypeBool { + help += " (" + c.localizedString("Value_Bool", nil) + ")" + } + + if q.mask { + prompt := &survey.Password{ + Message: q.label, + Help: help, + } + err = c.surveyAskOne(prompt, &input, survey.WithValidator(validate)) + + } else { + prompt := &survey.Input{ + Message: q.label, + Help: help, + } + if q.defaultValue != nil { + switch q.defaultValue.(type) { + case string: + prompt.Default = q.defaultValue.(string) + case int: + prompt.Default = strconv.Itoa(q.defaultValue.(int)) + case bool: + prompt.Default = strconv.FormatBool(q.defaultValue.(bool)) + } + } + err = c.surveyAskOne(prompt, &input, survey.WithValidator(validate)) + } + + if err != nil { + c.log.FATAL.Fatal(err) + } + + return input +} diff --git a/cmd/configure/texts.go b/cmd/configure/texts.go new file mode 100644 index 000000000..9d4188d8f --- /dev/null +++ b/cmd/configure/texts.go @@ -0,0 +1,97 @@ +package configure + +import ( + "errors" + "strings" + + "github.com/evcc-io/evcc/util/templates" + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/thoas/go-funk" +) + +// setDefaultTexts sets default language specific texts +func (c *CmdConfigure) setDefaultTexts() { + c.errItemNotPresent = errors.New(c.localizedString("Error_ItemNotPresent", nil)) + c.errDeviceNotValid = errors.New(c.localizedString("Error_DeviceNotValid", nil)) + + c.updateDeviceCategoryTexts(DeviceCategoryCharger, "Category_ChargerTitle", "Category_ChargerArticle", "Category_ChargerAdditional") + c.updateDeviceCategoryTexts(DeviceCategoryGuidedSetup, "Category_SystemTitle", "Category_SystemArticle", "Category_SystemAdditional") + c.updateDeviceCategoryTexts(DeviceCategoryGridMeter, "Category_GridMeterTitle", "Category_GridMeterArticle", "Category_GridMeterAdditional") + c.updateDeviceCategoryTexts(DeviceCategoryPVMeter, "Category_PVMeterTitle", "Category_PVMeterArticle", "Category_PVMeterAdditional") + c.updateDeviceCategoryTexts(DeviceCategoryBatteryMeter, "Category_BatteryMeter", "Category_BatteryMeterArticle", "Category_BatteryMeterAdditional") + c.updateDeviceCategoryTexts(DeviceCategoryChargeMeter, "Category_ChargeMeterTitle", "Category_ChargeMeterArticle", "Category_ChargeMeterAdditional") + c.updateDeviceCategoryTexts(DeviceCategoryVehicle, "Category_VehicleTitle", "Category_VehicleArticle", "Category_VehicleAdditional") +} + +// updateDeviceCategoryTexts updates the texts for a device category +func (c *CmdConfigure) updateDeviceCategoryTexts(category DeviceCategory, title, article, additional string) { + data := DeviceCategories[category] + data.title = c.localizedString(title, nil) + data.article = c.localizedString(article, nil) + data.additional = c.localizedString(additional, nil) + DeviceCategories[category] = data +} + +// localizedString is a helper for getting a localized string +func (c *CmdConfigure) localizedString(key string, templateData localizeMap) string { + return c.localizer.MustLocalize(&i18n.LocalizeConfig{ + MessageID: key, + TemplateData: templateData, + }) +} + +// userFriendlyTexts sets user friendly texts for a set of known param names and +// returns them as a new set as we don't want to overwrite the template values +func (c *CmdConfigure) userFriendlyTexts(param templates.Param) templates.Param { + result := param + + if result.ValueType == "" || (result.ValueType != "" && !funk.ContainsString(templates.ParamValueTypes, result.ValueType)) { + result.ValueType = templates.ParamValueTypeString + } + + switch strings.ToLower(result.Name) { + case "title": + result.Name = c.localizedString("UserFriendly_Title_Name", nil) + if result.Help.String(c.lang) == "" { + result.Help.SetString(c.lang, c.localizedString("UserFriendly_Title_Help", nil)) + } + case "device": + result.Name = c.localizedString("UserFriendly_Device_Name", nil) + case "baudrate": + result.Name = c.localizedString("UserFriendly_Baudrate_Name", nil) + case "comset": + result.Name = c.localizedString("UserFriendly_ComSet_Name", nil) + case "host": + result.Name = c.localizedString("UserFriendly_Host_Name", nil) + case "port": + result.Name = c.localizedString("UserFriendly_Port_Name", nil) + result.ValueType = templates.ParamValueTypeNumber + case "user": + result.Name = c.localizedString("UserFriendly_User_Name", nil) + case "password": + result.Name = c.localizedString("UserFriendly_Password_Name", nil) + case "capacity": + result.Name = c.localizedString("UserFriendly_Capacity_Name", nil) + if result.Example == "" { + result.Example = "41.5" + } + result.ValueType = templates.ParamValueTypeFloat + case "vin": + result.Name = c.localizedString("UserFriendly_Vin_Name", nil) + if result.Help.String(c.lang) == "" { + result.Help.SetString(c.lang, c.localizedString("UserFriendly_Vin_Help", nil)) + } + case "identifier": + result.Name = c.localizedString("UserFriendly_Identifier_Name", nil) + if result.Help.String(c.lang) == "" { + result.Help.SetString(c.lang, c.localizedString("UserFriendly_Identifier_Help", nil)) + } + case "standbypower": + result.Name = c.localizedString("UserFriendly_StandByPower_Name", nil) + if result.Help.String(c.lang) == "" { + result.Help.SetString(c.lang, c.localizedString("UserFriendly_StandByPower_Help", nil)) + } + result.ValueType = templates.ParamValueTypeNumber + } + return result +} diff --git a/cmd/configure/types.go b/cmd/configure/types.go new file mode 100644 index 000000000..b9ad56f4f --- /dev/null +++ b/cmd/configure/types.go @@ -0,0 +1,90 @@ +package configure + +import "github.com/evcc-io/evcc/util/templates" + +const ( + DefaultConfigFilename string = "evcc.yaml" +) + +type UsageChoice string + +func (u UsageChoice) String() string { + return string(u) +} + +var ValidModbusChoices = []string{templates.ModbusChoiceRS485, templates.ModbusChoiceTCPIP} + +type DeviceClass string + +func (c DeviceClass) String() string { + return string(c) +} + +const ( + DeviceClassCharger DeviceClass = "charger" + DeviceClassMeter DeviceClass = "meter" + DeviceClassVehicle DeviceClass = "vehicle" +) + +type DeviceCategory string + +func (c DeviceCategory) String() string { + return string(c) +} + +const ( + DeviceCategoryCharger DeviceCategory = "wallbox" + DeviceCategoryGridMeter DeviceCategory = "grid" + DeviceCategoryPVMeter DeviceCategory = "pv" + DeviceCategoryBatteryMeter DeviceCategory = "battery" + DeviceCategoryChargeMeter DeviceCategory = "charge" + DeviceCategoryVehicle DeviceCategory = "vehicle" + DeviceCategoryGuidedSetup DeviceCategory = "guided" +) + +var ValidUsageChoices = []DeviceCategory{DeviceCategoryGridMeter, DeviceCategoryPVMeter, DeviceCategoryBatteryMeter, DeviceCategoryChargeMeter} + +const ( + defaultNameCharger = "wallbox" + defaultNameGridMeter = "grid" + defaultNamePVMeter = "pv" + defaultNameBatteryMeter = "battery" + defaultNameChargeMeter = "charge" + defaultNameVehicle = "ev" +) + +type DeviceCategoryData struct { + title, article, additional string + class DeviceClass + categoryFilter DeviceCategory + defaultName string +} + +var DeviceCategories map[DeviceCategory]DeviceCategoryData = map[DeviceCategory]DeviceCategoryData{ + DeviceCategoryCharger: { + class: DeviceClassCharger, + defaultName: defaultNameCharger}, + DeviceCategoryGuidedSetup: { + class: DeviceClassMeter}, + DeviceCategoryGridMeter: { + class: DeviceClassMeter, + categoryFilter: DeviceCategoryGridMeter, + defaultName: defaultNameGridMeter}, + DeviceCategoryPVMeter: { + class: DeviceClassMeter, + categoryFilter: DeviceCategoryPVMeter, + defaultName: defaultNamePVMeter}, + DeviceCategoryBatteryMeter: { + class: DeviceClassMeter, + categoryFilter: DeviceCategoryBatteryMeter, + defaultName: defaultNameBatteryMeter}, + DeviceCategoryVehicle: { + class: DeviceClassVehicle, + defaultName: defaultNameVehicle}, + DeviceCategoryChargeMeter: { + class: DeviceClassMeter, + categoryFilter: DeviceCategoryChargeMeter, + defaultName: defaultNameChargeMeter}, +} + +type localizeMap map[string]interface{} diff --git a/cmd/eebus.go b/cmd/eebus.go index d39579618..6591045ae 100644 --- a/cmd/eebus.go +++ b/cmd/eebus.go @@ -7,7 +7,6 @@ import ( "github.com/Masterminds/sprig/v3" certhelper "github.com/evcc-io/eebus/cert" - "github.com/evcc-io/eebus/communication" "github.com/evcc-io/evcc/server" "github.com/evcc-io/evcc/util" "github.com/spf13/cobra" @@ -37,12 +36,7 @@ eebus: ` func generateEEBUSCert() { - details := communication.ManufacturerDetails{ - DeviceName: "EVCC", - DeviceCode: "EVCC_HEMS_01", - DeviceAddress: "EVCC_HEMS", - BrandName: "EVCC", - } + details := server.EEBUSDetails subject := pkix.Name{ CommonName: details.DeviceCode, diff --git a/go.mod b/go.mod index edd3b4f8b..8972154ec 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/evcc-io/evcc go 1.16 require ( + github.com/AlecAivazis/survey/v2 v2.3.2 + github.com/BurntSushi/toml v0.4.1 github.com/Masterminds/sprig/v3 v3.2.2 github.com/PuerkitoBio/goquery v1.8.0 github.com/alvaroloes/enumer v1.1.2 @@ -12,6 +14,7 @@ require ( github.com/benbjohnson/clock v1.3.0 github.com/bogosj/tesla v1.0.2 github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 github.com/containrrr/shoutrrr v0.5.2 github.com/deepmap/oapi-codegen v1.9.0 // indirect github.com/denisbrodbeck/machineid v1.0.1 @@ -59,6 +62,7 @@ require ( github.com/mlnoga/rct v0.1.2-0.20211011124352-7e995f76b592 github.com/muka/go-bluetooth v0.0.0-20211122080231-b99792bbe62a github.com/mxschmitt/golang-combinations v1.1.0 + github.com/nicksnyder/go-i18n/v2 v2.1.2 github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20170819232839-0fbfe93532da github.com/olekukonko/tablewriter v0.0.5 github.com/onsi/ginkgo v1.16.5 // indirect diff --git a/go.sum b/go.sum index 658a80420..4cd63e097 100644 --- a/go.sum +++ b/go.sum @@ -43,7 +43,11 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/AlecAivazis/survey/v2 v2.3.2 h1:TqTB+aDDCLYhf9/bD2TwSO8u8jDSmMUd2SUVO4gCnU8= +github.com/AlecAivazis/survey/v2 v2.3.2/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw= +github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= @@ -51,6 +55,8 @@ github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030I github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8= github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= +github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw= +github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/PuerkitoBio/goquery v1.7.1/go.mod h1:XY0pP4kfraEmmV1O7Uf6XyjoslwsneBbgeDjLYuN8xY= github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= @@ -115,6 +121,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 h1:tuijfIjZyjZaHq9xDUh0tNitwXshJpbLkqMOJv4H3do= +github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21/go.mod h1:po7NpZ/QiTKzBKyrsEAxwnTamCoh8uDk/egRpQ7siIc= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -395,6 +403,8 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= +github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ= +github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= @@ -474,6 +484,8 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.4 h1:5Myjjh3JY/NaAi4IsUbHADytDyl1VE1Y9PXDlL+P/VQ= +github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= @@ -533,6 +545,8 @@ github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= @@ -572,6 +586,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxschmitt/golang-combinations v1.1.0 h1:WlIZCnDm+Xlb2pRPf+R/qPKlGOU1w8lpN69/uy5z+Zg= github.com/mxschmitt/golang-combinations v1.1.0/go.mod h1:RbMhWvfCelHR6WROvT2bVfxJvZHoEvBj71SKe+H0MYU= +github.com/nicksnyder/go-i18n/v2 v2.1.2 h1:QHYxcUJnGHBaq7XbvgunmZ2Pn0focXFqTD61CkH146c= +github.com/nicksnyder/go-i18n/v2 v2.1.2/go.mod h1:d++QJC9ZVf7pa48qrsRWhMJ5pSHIPmS3OLqK1niyLxs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20170819232839-0fbfe93532da h1:qiPWuGGr+1GQE6s9NPSK8iggR/6x/V+0snIoOPYsBgc= github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20170819232839-0fbfe93532da/go.mod h1:DvuJJ/w1Y59rG8UTDxsMk5U+UJXJwuvUgbiJSm9yhX8= @@ -711,6 +727,7 @@ github.com/spf13/viper v1.9.0/go.mod h1:+i6ajR7OX2XaiBkrcZJFK21htRk7eDeLg7+O6bhU github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -784,6 +801,7 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -1018,6 +1036,8 @@ golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1 h1:kwrAHlwJ0DUBZwQ238v+Uod/3 golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w= +golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/meter/config_test.go b/meter/config_test.go index 1ba9c0d66..e116485f3 100644 --- a/meter/config_test.go +++ b/meter/config_test.go @@ -6,25 +6,26 @@ import ( "github.com/evcc-io/evcc/util/test" ) -func TestMeters(t *testing.T) { - test.SkipCI(t) +var acceptable = []string{ + "invalid plugin type: ...", + "missing mqtt broker configuration", + "mqtt not configured", + "not a SunSpec device", + "missing password", // Powerwall + "connect: no route to host", + "connect: connection refused", + "i/o timeout", + "connect: network is unreachable", + "no ping response for 192.0.2.2", // SMA + "network is unreachable", + "[1ESY1161052714 1ESY1161229249 1EMH0008842285 1ESY1161978584 1EMH0004864048 1ESY1161979033 7ELS8135823805]", // Discovergy + "can only have either uri or device", // modbus + "(Client.Timeout exceeded while awaiting headers)", // http + "cannot create meter 'discovergy': unexpected status: 401", //Discovergy Proxy +} - acceptable := []string{ - "invalid plugin type: ...", - "missing mqtt broker configuration", - "mqtt not configured", - "not a SunSpec device", - "missing password", // Powerwall - "connect: no route to host", - "connect: connection refused", - "i/o timeout", - "connect: network is unreachable", - "no ping response for 192.0.2.2", // SMA - "network is unreachable", - "[1ESY1161052714 1ESY1161229249 1EMH0008842285 1ESY1161978584 1EMH0004864048 1ESY1161979033 7ELS8135823805]", // Discovergy - "can only have either uri or device", // modbus - "(Client.Timeout exceeded while awaiting headers)", // http - } +func TestConfigMeters(t *testing.T) { + test.SkipCI(t) for _, tmpl := range test.ConfigTemplates("meter") { tmpl := tmpl diff --git a/meter/template.go b/meter/template.go new file mode 100644 index 000000000..f1816b5db --- /dev/null +++ b/meter/template.go @@ -0,0 +1,44 @@ +package meter + +import ( + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/templates" + "gopkg.in/yaml.v3" +) + +func init() { + registry.Add("template", NewMeterFromTemplateConfig) +} + +func NewMeterFromTemplateConfig(other map[string]interface{}) (api.Meter, error) { + cc := struct { + Template string + Other map[string]interface{} `mapstructure:",remain"` + }{} + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + tmpl, err := templates.ByTemplate(cc.Template, templates.Meter) + if err != nil { + return nil, err + } + + b, _, err := tmpl.RenderResult(false, other) + if err != nil { + return nil, err + } + + var instance struct { + Type string + Other map[string]interface{} `yaml:",inline"` + } + + if err := yaml.Unmarshal(b, &instance); err != nil { + return nil, err + } + + return NewFromConfig(instance.Type, instance.Other) +} diff --git a/meter/template_test.go b/meter/template_test.go new file mode 100644 index 000000000..8672671a1 --- /dev/null +++ b/meter/template_test.go @@ -0,0 +1,66 @@ +package meter + +import ( + "testing" + + "github.com/evcc-io/evcc/util/templates" + "github.com/evcc-io/evcc/util/test" + "github.com/thoas/go-funk" +) + +func TestMeterTemplates(t *testing.T) { + test.SkipCI(t) + + for _, tmpl := range templates.ByClass(templates.Meter) { + tmpl := tmpl + + // set default values for all params + values := tmpl.Defaults(true) + + // set the template value which is needed for rendering + values["template"] = tmpl.Template + + // set modbus default test values + if values[templates.ParamModbus] != nil { + modbusChoices := tmpl.ModbusChoices() + if funk.ContainsString(modbusChoices, templates.ModbusChoiceTCPIP) { + values[templates.ModbusKeyTCPIP] = true + } else { + values[templates.ModbusKeyRS485TCPIP] = true + } + } + + usages := tmpl.Usages() + if len(usages) == 0 { + runTest(t, tmpl, values) + } else { + // test all usages + for _, usage := range usages { + + // set the usage param value + if usage != "" { + values[templates.ParamUsage] = usage + } + + runTest(t, tmpl, values) + } + } + } +} + +func runTest(t *testing.T, tmpl templates.Template, values map[string]interface{}) { + t.Run(tmpl.Template, func(t *testing.T) { + t.Parallel() + + b, values, err := tmpl.RenderResult(true, values) + if err != nil { + t.Logf("%s: %s", tmpl.Template, b) + t.Error(err) + } + + if _, err := NewFromConfig("template", values); err != nil && !test.Acceptable(err, acceptable) { + t.Logf("%s", tmpl.Template) + t.Error(err) + } + }) +} diff --git a/schema.json b/schema.json index b2e86b075..18cf29e0e 100644 --- a/schema.json +++ b/schema.json @@ -148,6 +148,9 @@ }, "type": { "type": "string" + }, + "template": { + "type": "string" } } }, diff --git a/server/eebus.go b/server/eebus.go index 09a875631..3d7f1c8c6 100644 --- a/server/eebus.go +++ b/server/eebus.go @@ -20,6 +20,13 @@ import ( "github.com/grandcat/zeroconf" ) +var EEBUSDetails = communication.ManufacturerDetails{ + BrandName: "EVCC", + DeviceName: "EVCC", + DeviceCode: "EVCC_HEMS_01", + DeviceAddress: "EVCC_HEMS", +} + type EEBusClientCBs struct { onConnect func(string, ship.Conn) error onDisconnect func(string) @@ -100,12 +107,7 @@ func NewEEBus(other map[string]interface{}) (*EEBus, error) { } func (c *EEBus) DeviceInfo() communication.ManufacturerDetails { - return communication.ManufacturerDetails{ - BrandName: "EVCC", - DeviceName: "EVCC", - DeviceCode: "EVCC_HEMS_01", - DeviceAddress: "EVCC_HEMS", - } + return EEBUSDetails } func (c *EEBus) Register(ski string, shipConnectHandler func(string, ship.Conn) error, shipDisconnectHandler func(string)) { diff --git a/templates/definition/embed.go b/templates/definition/embed.go new file mode 100644 index 000000000..2208d5b0b --- /dev/null +++ b/templates/definition/embed.go @@ -0,0 +1,8 @@ +package definition + +import "embed" + +var ( + //go:embed charger/*.yaml meter/*.yaml vehicle/*.yaml + YamlTemplates embed.FS +) diff --git a/util/templates/generate/generate.go b/util/templates/generate/generate.go new file mode 100644 index 000000000..a64b8a36d --- /dev/null +++ b/util/templates/generate/generate.go @@ -0,0 +1,109 @@ +package main + +import ( + "fmt" + "io/ioutil" + "os" + "path" + + "github.com/evcc-io/evcc/util/templates" +) + +const basePath = "../../../templates/docs" + +//go:generate go run generate.go + +func main() { + for _, class := range []string{templates.Meter, templates.Charger, templates.Vehicle} { + path := fmt.Sprintf("%s/%s", basePath, class) + _, err := os.Stat(path) + if os.IsNotExist(err) { + if err := os.MkdirAll(path, 0755); err != nil { + panic(err) + } + } + if err := clearDir(path); err != nil { + fmt.Printf("Could not clear directory for %s: %s\n", class, err) + } + + if err := generateClass(class); err != nil { + panic(err) + } + } +} + +func generateClass(class string) error { + for _, tmpl := range templates.ByClass(class) { + usages := tmpl.Usages() + + fmt.Println(tmpl.Template) + + if len(usages) == 0 { + err := writeTemplate(class, tmpl, "") + if err != nil { + return err + } + } + + // render all usages + for _, usage := range usages { + err := writeTemplate(class, tmpl, usage) + if err != nil { + return err + } + } + } + + return nil +} + +func writeTemplate(class string, tmpl templates.Template, usage string) error { + values := tmpl.Defaults(true) + + if usage != "" { + values["usage"] = usage + } + + modbusChoices := tmpl.ModbusChoices() + + for _, modbusChoice := range modbusChoices { + switch modbusChoice { + case templates.ModbusChoiceRS485: + values[templates.ModbusRS485Serial] = true + values[templates.ModbusRS485TCPIP] = true + case templates.ModbusChoiceTCPIP: + values[templates.ModbusTCPIP] = true + } + } + b, err := tmpl.RenderProxyWithValues(values, true) + + if err != nil { + println(string(b)) + return err + } + + filename := fmt.Sprintf("%s/%s/%s.yaml", basePath, class, tmpl.Template) + if usage != "" { + filename = fmt.Sprintf("%s/%s/%s-%s.yaml", basePath, class, tmpl.Template, usage) + } + if err := os.WriteFile(filename, b, 0644); err != nil { + return err + } + + return nil +} + +func clearDir(dir string) error { + names, err := ioutil.ReadDir(dir) + if err != nil { + return err + } + + for _, entry := range names { + if err := os.RemoveAll(path.Join([]string{dir, entry.Name()}...)); err != nil { + return err + } + } + + return nil +} diff --git a/util/templates/init.go b/util/templates/init.go new file mode 100644 index 000000000..621f81d9b --- /dev/null +++ b/util/templates/init.go @@ -0,0 +1,73 @@ +package templates + +import ( + "fmt" + "io/fs" + "path" + + "github.com/evcc-io/evcc/templates/definition" + "gopkg.in/yaml.v3" +) + +var ( + templates = make(map[string][]Template) +) + +const ( + Charger = "charger" + Meter = "meter" + Vehicle = "vehicle" +) + +func loadTemplates(class string) { + if templates[class] != nil { + return + } + + err := fs.WalkDir(definition.YamlTemplates, ".", func(filepath string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + b, err := fs.ReadFile(definition.YamlTemplates, filepath) + if err != nil { + return err + } + + var tmpl Template + if err = yaml.Unmarshal(b, &tmpl); err != nil { + panic(fmt.Errorf("reading template '%s' failed: %w", filepath, err)) + } + tmpl.ResolveParamBase() + + path := path.Dir(filepath) + templates[path] = append(templates[path], tmpl) + + return nil + }) + + if err != nil { + panic(err) + } +} + +func ByClass(class string) []Template { + loadTemplates(class) + + return templates[class] +} + +func ByTemplate(t, class string) (Template, error) { + loadTemplates(class) + + for _, tmpl := range templates[class] { + if tmpl.Template == t { + return tmpl, nil + } + } + + return Template{}, fmt.Errorf("template not found: %s", t) +} diff --git a/util/templates/modbus.tpl b/util/templates/modbus.tpl new file mode 100644 index 000000000..bf4ef5daa --- /dev/null +++ b/util/templates/modbus.tpl @@ -0,0 +1,18 @@ +{{define "modbus"}} +id: {{ .id }} +{{- if or (eq .modbus "rs485serial") .modbusrs485serial }} +# RS485 via adapter: +device: {{ .device }} +baudrate: {{ .baudrate }} +comset: "{{ .comset }}" +{{- end }} +{{- if or (eq .modbus "rs485tcpip") .modbusrs485tcpip }} +# RS485 via TCPIP: +uri: {{ .host }}:{{ .port }} +rtu: true # serial modbus rtu (rs485) device connected using simple ethernet adapter +{{- end }} +{{- if or (eq .modbus "tcpip") .modbustcpip }} +# TCPIP +uri: {{ .host }}:{{ .port }} +{{- end }} +{{end}} \ No newline at end of file diff --git a/util/templates/proxy.tpl b/util/templates/proxy.tpl new file mode 100644 index 000000000..7f4677dc7 --- /dev/null +++ b/util/templates/proxy.tpl @@ -0,0 +1,10 @@ +type: template +template: {{ .Template }} +{{- if .Description }} +description: {{ .Description }} +{{- end }} +{{ range .Params -}} +{{ .Name }}: + {{- if len .Value }} {{ .Value }} {{ end }} + {{- if .Help.DE }} # {{ .Help.DE }} {{- end }} +{{ end -}} diff --git a/util/templates/template.go b/util/templates/template.go new file mode 100644 index 000000000..429c749ef --- /dev/null +++ b/util/templates/template.go @@ -0,0 +1,300 @@ +package templates + +import ( + "bytes" + _ "embed" + "fmt" + "text/template" + + "github.com/Masterminds/sprig/v3" + "github.com/evcc-io/evcc/util" +) + +const ( + ParamUsage = "usage" + ParamModbus = "modbus" + + HemsTypeSMA = "sma" + + ModbusChoiceRS485 = "rs485" + ModbusChoiceTCPIP = "tcpip" + ModbusKeyRS485Serial = "rs485serial" + ModbusKeyRS485TCPIP = "rs485tcpip" + ModbusKeyTCPIP = "tcpip" + + ModbusRS485Serial = "modbusrs485serial" + ModbusRS485TCPIP = "modbusrs485tcpip" + ModbusTCPIP = "modbustcpip" + + ModbusParamNameId = "id" + ModbusParamValueId = 1 + ModbusParamNameDevice = "device" + ModbusParamValueDevice = "/dev/ttyUSB0" + ModbusParamNameBaudrate = "baudrate" + ModbusParamValueBaudrate = 9600 + ModbusParamNameComset = "comset" + ModbusParamValueComset = "8N1" + ModbusParamNameURI = "uri" + ModbusParamNameHost = "host" + ModbusParamValueHost = "192.0.2.2" + ModbusParamNamePort = "port" + ModbusParamValuePort = 502 + ModbusParamNameRTU = "rtu" +) + +var HemsValueTypes = []string{HemsTypeSMA} + +const ( + ParamValueTypeString = "string" + ParamValueTypeNumber = "number" + ParamValueTypeFloat = "float" + ParamValueTypeBool = "bool" +) + +var ParamValueTypes = []string{ParamValueTypeString, ParamValueTypeNumber, ParamValueTypeBool} + +// language specific texts +type TextLanguage struct { + DE string // german text + EN string // english text +} + +func (t *TextLanguage) String(lang string) string { + switch lang { + case "de": + return t.DE + case "en": + return t.EN + } + return t.DE +} + +func (t *TextLanguage) SetString(lang, value string) { + switch lang { + case "de": + t.DE = value + case "en": + t.EN = value + default: + t.DE = value + } +} + +// Requirements +type Requirements struct { + Hems string // HEMS Type + Eebus bool // EEBUS Setup is required + Sponsorship bool // Sponsorship is required + Description TextLanguage // Description of requirements, e.g. how the device needs to be prepared + URI string // URI to a webpage with more details about the preparation requirements +} + +type GuidedSetup struct { + Enable bool // if true, guided setup is possible + Linked []LinkedTemplate // a list of templates that should be processed as part of the guided setup +} + +// Linked Template +type LinkedTemplate struct { + Template string + Usage string // usage: "grid", "pv", "battery" +} + +// Param is a proxy template parameter +type Param struct { + Name string + Required bool // cli if the user has to provide a non empty value + Mask bool // cli if the value should be masked, e.g. for passwords + Advanced bool // cli if the user does not need to be asked. Requires a "Default" to be defined. + Default string // default value if no user value is provided in the configuration + Example string // cli example value + Help TextLanguage // cli configuration help + Test string // testing default value + Value string // user provided value via cli configuration + ValueType string // string representation of the value type, "string" is default + Choice []string // defines which usage choices this config supports, valid elemtents are "grid", "pv", "battery", "charge" + Usages []string + Baudrate int // device specific default for modbus RS485 baudrate + Comset string // device specific default for modbus RS485 comset +} + +// Template describes is a proxy device for use with cli and automated testing +type Template struct { + Template string + Description string // user friendly description of the device this template describes + LogLevel string // the implementation type of the device, equal to the type value under "Render" + Requirements Requirements + GuidedSetup GuidedSetup + Generic bool // if this describes a generic device type rather than a product + ParamsBase string // references a base param set to inherit from + Params []Param + Render string // rendering template +} + +var paramBases = map[string][]Param{ + "vehicle": { + {Name: "title"}, + {Name: "user", Required: true}, + {Name: "password", Required: true, Mask: true}, + {Name: "vin", Example: "W..."}, + {Name: "capacity", Default: "50", ValueType: ParamValueTypeFloat}, + }, +} + +// add the referenced base Params and overwrite existing ones +func (t *Template) ResolveParamBase() { + if t.ParamsBase == "" { + return + } + + base, ok := paramBases[t.ParamsBase] + if !ok { + return + } + + currentParams := make([]Param, len(t.Params)) + copy(currentParams, t.Params) + t.Params = make([]Param, len(base)) + copy(t.Params, base) + for _, p := range currentParams { + if i, item := t.paramWithName(p.Name); item != nil { + // we only allow overwriting a few fields + if p.Default != "" { + t.Params[i].Default = p.Default + } + if p.Example != "" { + t.Params[i].Example = p.Example + } + } else { + t.Params = append(t.Params, p) + } + } +} + +// Defaults returns a map of default values for the template +func (t *Template) Defaults(docsOrTests bool) map[string]interface{} { + values := make(map[string]interface{}) + for _, p := range t.Params { + if p.Test != "" { + values[p.Name] = p.Test + } else if p.Example != "" && docsOrTests { + values[p.Name] = p.Example + } else { + values[p.Name] = p.Default // may be empty + } + } + + return values +} + +// return the param with the given name +func (t *Template) paramWithName(name string) (int, *Param) { + for i, p := range t.Params { + if p.Name == name { + return i, &p + } + } + return 0, nil +} + +// Usages returns the list of supported usages +func (t *Template) Usages() []string { + if _, p := t.paramWithName(ParamUsage); p != nil { + return p.Choice + } + + return nil +} + +func (t *Template) ModbusChoices() []string { + if _, p := t.paramWithName(ParamModbus); p != nil { + return p.Choice + } + + return nil +} + +//go:embed proxy.tpl +var proxyTmpl string + +// RenderProxy renders the proxy template for inclusion in documentation +func (t *Template) RenderProxy() ([]byte, error) { + return t.RenderProxyWithValues(nil, false) +} + +func (t *Template) RenderProxyWithValues(values map[string]interface{}, includeDescription bool) ([]byte, error) { + tmpl, err := template.New("yaml").Funcs(template.FuncMap(sprig.FuncMap())).Parse(proxyTmpl) + if err != nil { + panic(err) + } + + for index, p := range t.Params { + for k, v := range values { + if p.Name == k { + t.Params[index].Value = v.(string) + } + } + } + + // remove params with no values, no defaults and no example + var newParams []Param + for _, param := range t.Params { + if param.Value == "" && param.Default == "" && param.Example == "" && !param.Required { + continue + } + newParams = append(newParams, param) + } + + for index, p := range newParams { + newParams[index].Value = yamlQuote(p.Value) + } + + t.Params = newParams + + out := new(bytes.Buffer) + data := map[string]interface{}{ + "Template": t.Template, + "Params": t.Params, + } + if includeDescription { + data["Description"] = t.Description + } + err = tmpl.Execute(out, data) + + return bytes.TrimSpace(out.Bytes()), err +} + +// RenderResult renders the result template to instantiate the proxy +func (t *Template) RenderResult(docs bool, other map[string]interface{}) ([]byte, map[string]interface{}, error) { + values := t.Defaults(docs) + if err := util.DecodeOther(other, &values); err != nil { + return nil, values, err + } + + t.ModbusValues(values) + + for item, p := range values { + values[item] = yamlQuote(fmt.Sprintf("%v", p)) + } + + tmpl := template.New("yaml") + var funcMap template.FuncMap = map[string]interface{}{} + // copied from: https://github.com/helm/helm/blob/8648ccf5d35d682dcd5f7a9c2082f0aaf071e817/pkg/engine/engine.go#L147-L154 + funcMap["include"] = func(name string, data interface{}) (string, error) { + buf := bytes.NewBuffer(nil) + if err := tmpl.ExecuteTemplate(buf, name, data); err != nil { + return "", err + } + return buf.String(), nil + } + + tmpl, err := tmpl.Funcs(template.FuncMap(sprig.FuncMap())).Funcs(funcMap).Parse(t.Render) + if err != nil { + return nil, values, err + } + + out := new(bytes.Buffer) + err = tmpl.Execute(out, values) + + return bytes.TrimSpace(out.Bytes()), values, err +} diff --git a/util/templates/template_modbus.go b/util/templates/template_modbus.go new file mode 100644 index 000000000..59eac61c6 --- /dev/null +++ b/util/templates/template_modbus.go @@ -0,0 +1,82 @@ +package templates + +import ( + _ "embed" + "errors" + "fmt" + "strings" + + "github.com/thoas/go-funk" +) + +//go:embed modbus.tpl +var modbusTmpl string + +// set the modbus values required from modbus.tpl and and the template to the render +func (t *Template) ModbusValues(values map[string]interface{}) { + if len(t.ModbusChoices()) == 0 { + return + } + + // either modbus param is defined or defaults for all modbus choices need to be set + hasModbusValues := false + if values[ModbusRS485Serial] == nil && values[ModbusRS485TCPIP] == nil && values[ModbusTCPIP] == nil { + for k, v := range values { + if k != ParamModbus { + continue + } + + switch s := v.(string); s { + case ModbusKeyRS485Serial, ModbusKeyRS485TCPIP, ModbusKeyTCPIP: + hasModbusValues = true + values[s] = true + default: + // this happens during tests + } + + break + } + } + + // only add the template once, when testing multiple usages, it might already be present + if !strings.Contains(t.Render, modbusTmpl) { + t.Render = fmt.Sprintf("%s\n%s", t.Render, modbusTmpl) + } + + if hasModbusValues { + return + } + + // modbus defaults + for k, v := range map[string]interface{}{ + ModbusParamNameId: ModbusParamValueId, + ModbusParamNameHost: ModbusParamValueHost, + ModbusParamNamePort: ModbusParamValuePort, + ModbusParamNameDevice: ModbusParamValueDevice, + ModbusParamNameBaudrate: ModbusParamValueBaudrate, + ModbusParamNameComset: ModbusParamValueComset, + } { + values[k] = v + } + + for _, p := range t.Params { + if p.Name != ParamModbus { + continue + } + + for _, choice := range p.Choice { + if !funk.ContainsString([]string{ModbusChoiceRS485, ModbusChoiceTCPIP}, choice) { + panic(errors.New("Invalid modbus choice: " + choice)) + } + } + + if funk.ContainsString(p.Choice, ModbusChoiceRS485) { + values[ModbusRS485Serial] = true + values[ModbusRS485TCPIP] = true + } + + if funk.ContainsString(p.Choice, ModbusChoiceTCPIP) { + values[ModbusTCPIP] = true + } + } +} diff --git a/util/templates/utils.go b/util/templates/utils.go new file mode 100644 index 000000000..56598a40a --- /dev/null +++ b/util/templates/utils.go @@ -0,0 +1,23 @@ +package templates + +import ( + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +func yamlQuote(value string) string { + input := fmt.Sprintf("key: %s", value) + + var res struct { + Value string `yaml:"key"` + } + + if err := yaml.Unmarshal([]byte(input), &res); err != nil || value != res.Value { + quoted := strings.ReplaceAll(value, `'`, `''`) + return fmt.Sprintf("'%s'", quoted) + } + + return value +} diff --git a/util/templates/utils_test.go b/util/templates/utils_test.go new file mode 100644 index 000000000..da82f42d9 --- /dev/null +++ b/util/templates/utils_test.go @@ -0,0 +1,29 @@ +package templates + +import ( + "fmt" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestYamlDecode(t *testing.T) { + for _, value := range []string{`value`, `!value`, `@value`, `"value"`, `"va"lue"`, `va'lue`, `@va'lue`} { + t.Run(value, func(t *testing.T) { + quoted := yamlQuote(value) + input := fmt.Sprintf("key: %s", quoted) + + var res struct { + Value string `yaml:"key"` + } + + if err := yaml.Unmarshal([]byte(input), &res); err != nil { + t.Fatalf("expected %s (quoted: %s), got error %v", value, quoted, err) + } + + if value != res.Value { + t.Fatalf("expected %s (quoted: %s), got %s", value, quoted, res.Value) + } + }) + } +} diff --git a/vehicle/config_test.go b/vehicle/config_test.go index 74575b0c6..8bc180cdf 100644 --- a/vehicle/config_test.go +++ b/vehicle/config_test.go @@ -6,27 +6,34 @@ import ( "github.com/evcc-io/evcc/util/test" ) -func TestVehicles(t *testing.T) { - test.SkipCI(t) +var acceptable = []string{ + "invalid plugin type: ...", + "missing mqtt broker configuration", + "received status code 404 (INVALID PARAMS)", // Nissan + "missing personID", + "401 Unauthorized", + "unexpected length", + "i/o timeout", + "no such host", + "network is unreachable", + "Missing required parameter", // Renault + "error connecting: Network Error", + "unexpected status: 401", + "could not obtain token", // Porsche + "missing credentials", // Tesla + "invalid vehicle type: hyundai", + "invalid vehicle type: kia", + "missing user, password or serial", // Niu + "missing credentials id", // Tronity + "missing access and/or refresh token, use `evcc token` to create", // Tesla + "login failed: Unauthorized: Authentication Failed", // Nissan + "login failed: no auth code", // Porsche + "invalid_client:Client authentication failed (e.g., login failure, unknown client, no client authentication included or unsupported authentication method)", // BMW, Mini + "login failed: oauth2: cannot fetch token: 400 Bad Request Response: {\"error\":\"invalid_request\",\"error_description\":\"Missing parameter, 'username'\"}", // Opel, DS, Citroen, PSA +} - acceptable := []string{ - "invalid plugin type: ...", - "missing mqtt broker configuration", - "received status code 404 (INVALID PARAMS)", // Nissan - "missing personID", - "401 Unauthorized", - "unexpected length", - "i/o timeout", - "no such host", - "network is unreachable", - "Missing required parameter", // Renault - "error connecting: Network Error", - "unexpected status: 401", - "could not obtain token", // Porsche - "missing credentials", // Tesla - "invalid vehicle type: hyundai", - "invalid vehicle type: kia", - } +func TestConfigVehicles(t *testing.T) { + test.SkipCI(t) for _, tmpl := range test.ConfigTemplates("vehicle") { tmpl := tmpl diff --git a/vehicle/template.go b/vehicle/template.go new file mode 100644 index 000000000..0de82c7d1 --- /dev/null +++ b/vehicle/template.go @@ -0,0 +1,44 @@ +package vehicle + +import ( + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/templates" + "gopkg.in/yaml.v3" +) + +func init() { + registry.Add("template", NewVehicleFromTemplateConfig) +} + +func NewVehicleFromTemplateConfig(other map[string]interface{}) (api.Vehicle, error) { + cc := struct { + Template string + Other map[string]interface{} `mapstructure:",remain"` + }{} + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + tmpl, err := templates.ByTemplate(cc.Template, templates.Vehicle) + if err != nil { + return nil, err + } + + b, _, err := tmpl.RenderResult(false, other) + if err != nil { + return nil, err + } + + var instance struct { + Type string + Other map[string]interface{} `yaml:",inline"` + } + + if err := yaml.Unmarshal(b, &instance); err != nil { + return nil, err + } + + return NewFromConfig(instance.Type, instance.Other) +} diff --git a/vehicle/template_test.go b/vehicle/template_test.go new file mode 100644 index 000000000..108b83b6b --- /dev/null +++ b/vehicle/template_test.go @@ -0,0 +1,38 @@ +package vehicle + +import ( + "testing" + + "github.com/evcc-io/evcc/util/templates" + "github.com/evcc-io/evcc/util/test" +) + +func TestVehicleTemplates(t *testing.T) { + test.SkipCI(t) + + for _, tmpl := range templates.ByClass(templates.Vehicle) { + tmpl := tmpl + + // set default values for all params + values := tmpl.Defaults(true) + + // set the template value which is needed for rendering + values["template"] = tmpl.Template + + t.Run(tmpl.Template, func(t *testing.T) { + t.Parallel() + + b, values, err := tmpl.RenderResult(true, values) + if err != nil { + t.Logf("%s: %s", tmpl.Template, b) + t.Error(err) + } + + _, err = NewFromConfig("template", values) + if err != nil && !test.Acceptable(err, acceptable) { + t.Logf("%s", tmpl.Template) + t.Error(err) + } + }) + } +}