Repository: evcc-io/evcc
Pull request #1888 (2021-11-16 14:05:04)

Experimentelle geführte Erstellung der Konfigurationsdatei
Dies ist eine **experimentelle** Umsetzung im folgenden aufgeführten Ziele. Es kann sein dass dies irgendwann wieder entfernt wird!
Diese Umsetzung soll auch beim Lernen helfen wie eine anwenderfreundliche Konfiguration aussehen sollte/kann/muss.

## Ziel dieses PRs:

- 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

## Was ist implementiert

- Templates für bekannte Geräte, diese müssen überprüft und ggfs. aktualisiert werden
- Geführte Konfiguration in deutsch
- Es werden Meters, Vehicles, Chargers und Loadpoints konfiguriert. Aber nicht in all derer Möglichenkeiten, sondern das notwendige

## Was steht mittelfristig an, wenn der Weg passt

- Unterstützung für weitere Konfigurationsbereiche
- Generierte Dokumentation der unterstützen Geräte aus Anwendersicht (nicht wie heute die Implementierungssicht) 
- Bearbeiten der Konfiguration (nur nach dem neuen Schema)
- Saubere Implementierung damit auch eine Web-GUI ermöglicht werden kann
- Web-basierte Konfiguration inkl. Bearbeitung
- Unterstützung für mehrere Sprachen, z.B. englisch

## Wie es verwendet

- Start über `evcc configure`
- Die Konfiguration wird in `evcc.yaml` geschrieben. Falls diese existiert kann ein alternativer Dateiname angegeben werden


DerAndereAndi: Es wird so langsam rund. 🙇 
DerAndereAndi: @andig damit der Build nicht mehr failed, müssen wir den neuen PR mit den templates in main mergen, und main dann hier rein. Soll ich?
andig: > @andig damit der Build nicht mehr failed, müssen wir den neuen PR mit den templates in main mergen, und main dann hier rein. Soll ich?

Trau Dich :)

Patch:
diff --git a/.gitignore b/.gitignore
index ffdb47d2..5c74f3cb 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/easee.go b/charger/easee.go
index b047b82b..f7e92b4d 100644
--- a/charger/easee.go
+++ b/charger/easee.go
@@ -203,7 +203,7 @@ func (c *Easee) observe(typ string, i json.RawMessage) {
 		c.dynamicChargerCurrent = value.(float64)
 		// ensure that charger current matches evcc's expectation
 		if c.dynamicChargerCurrent > 0 && c.dynamicChargerCurrent != c.current {
-			if err = c.MaxCurrentMillis(c.current); err != nil {
+			if err = c.MaxCurrent(int64(c.current)); err != nil {
 				c.log.ERROR.Println(err)
 			}
 		}
@@ -307,21 +307,15 @@ func (c *Easee) Enable(enable bool) error {
 
 // MaxCurrent implements the api.Charger interface
 func (c *Easee) MaxCurrent(current int64) error {
-	return c.MaxCurrentMillis(float64(current))
-}
-
-var _ api.ChargerEx = (*Easee)(nil)
-
-// MaxCurrentMillis implements the api.ChargerEx interface
-func (c *Easee) MaxCurrentMillis(current float64) error {
+	cur := float64(current)
 	data := easee.ChargerSettings{
-		DynamicChargerCurrent: &current,
+		DynamicChargerCurrent: &cur,
 	}
 
 	uri := fmt.Sprintf("%s/chargers/%s/settings", easee.API, c.charger)
 	resp, err := c.Post(uri, request.JSONContent, request.MarshalJSON(data))
 	if err == nil {
-		c.current = current
+		c.current = cur
 		resp.Body.Close()
 	}
 
diff --git a/charger/easee/log.go b/charger/easee/log.go
index 8c928ce7..b8c0666d 100644
--- a/charger/easee/log.go
+++ b/charger/easee/log.go
@@ -5,6 +5,7 @@ import (
 	"strings"
 
 	"github.com/philippseith/signalr"
+	"github.com/thoas/go-funk"
 )
 
 // Logger is a simple logger interface
@@ -13,7 +14,6 @@ type Logger interface {
 }
 
 type logger struct {
-	b   strings.Builder
 	log Logger
 }
 
@@ -21,21 +21,36 @@ func SignalrLogger(log Logger) signalr.StructuredLogger {
 	return &logger{log: log}
 }
 
+var skipped = []string{"ts", "class", "hub", "protocol"}
+
 func (l *logger) Log(keyVals ...interface{}) error {
+	b := new(strings.Builder)
+
+	var skip bool
 	for i, v := range keyVals {
+		if skip {
+			skip = false
+			continue
+		}
+
 		if i%2 == 0 {
-			if l.b.Len() > 0 {
-				l.b.WriteRune(' ')
+			if funk.Contains(skipped, v) {
+				skip = true
+				continue
 			}
-			l.b.WriteString(fmt.Sprintf("%v", v))
-			l.b.WriteRune('=')
+
+			if b.Len() > 0 {
+				b.WriteRune(' ')
+			}
+
+			b.WriteString(fmt.Sprintf("%v", v))
+			b.WriteRune('=')
 		} else {
-			l.b.WriteString(fmt.Sprintf("%v", v))
+			b.WriteString(fmt.Sprintf("%v", v))
 		}
 	}
 
-	l.log.Println(l.b.String())
-	l.b.Reset()
+	l.log.Println(b.String())
 
 	return nil
 }
diff --git a/charger/template.go b/charger/template.go
new file mode 100644
index 00000000..61438432
--- /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/cmd/configure.go b/cmd/configure.go
new file mode 100644
index 00000000..3fbc4f1a
--- /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 00000000..1c748c14
--- /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 00000000..a79d5335
--- /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/eebus.go b/cmd/configure/eebus.go
new file mode 100644
index 00000000..7784c760
--- /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 00000000..ee1f693a
--- /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 00000000..5fce7ede
--- /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 00000000..1724fa40
--- /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 00000000..a610f347
--- /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 00000000..d0e60aa7
--- /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 00000000..e8c1e4ed
--- /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 00000000..9d4188d8
--- /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 00000000..b9ad56f4
--- /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/detect.go b/cmd/detect.go
index 7683716e..33521878 100644
--- a/cmd/detect.go
+++ b/cmd/detect.go
@@ -95,13 +95,6 @@ func display(res []tasks.Result) {
 
 	fmt.Println("")
 	table.Render()
-
-	fmt.Println(`
-Please open https://github.com/evcc-io/evcc/issues/new in your browser and copy the
-results above into a new issue. Please tell us:
-
-	1. Is the scan result correct?
-	2. If not correct: please describe your hardware setup.`)
 }
 
 func runDetect(cmd *cobra.Command, args []string) {
diff --git a/cmd/eebus.go b/cmd/eebus.go
index d3957961..6591045a 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 5a0464bb..8972154e 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,11 +62,12 @@ 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
 	github.com/onsi/gomega v1.17.0 // indirect
-	github.com/philippseith/signalr v0.5.1
+	github.com/philippseith/signalr v0.5.2
 	github.com/prometheus/client_golang v1.11.0
 	github.com/prometheus/common v0.32.1 // indirect
 	github.com/prometheus/procfs v0.7.3 // indirect
diff --git a/go.sum b/go.sum
index 8474d9ec..4cd63e09 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=
@@ -609,6 +625,8 @@ github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCko
 github.com/peterbourgon/ff v1.2.0/go.mod h1:ljiF7yxtUvZaxUDyUqQa0+uiEOgwVboj+Q2S2+0nq40=
 github.com/philippseith/signalr v0.5.1 h1:SbCWYJiZUo0+AXWSlfBxPDcM7AidNEvVgeeq+jYzP+c=
 github.com/philippseith/signalr v0.5.1/go.mod h1:lvHqx1JLduRcRHqWiDX6u/LHtSsfb08g+e+QP/B2gTA=
+github.com/philippseith/signalr v0.5.2 h1:lI5Mn0n0vw/ItwDzDa5rXhvMVBrIJYcpw6UvRb/P8hM=
+github.com/philippseith/signalr v0.5.2/go.mod h1:lvHqx1JLduRcRHqWiDX6u/LHtSsfb08g+e+QP/B2gTA=
 github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -709,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=
@@ -782,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=
@@ -1016,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/template.go b/meter/template.go
new file mode 100644
index 00000000..f1816b5d
--- /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/provider/http.go b/provider/http.go
index b880ba85..815b0587 100644
--- a/provider/http.go
+++ b/provider/http.go
@@ -1,6 +1,7 @@
 package provider
 
 import (
+	"encoding/hex"
 	"fmt"
 	"io"
 	"math"
@@ -9,12 +10,15 @@ import (
 	"strings"
 	"time"
 
+	"github.com/evcc-io/evcc/provider/javascript"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/jq"
 	"github.com/evcc-io/evcc/util/request"
 	"github.com/evcc-io/evcc/util/transport"
 	"github.com/itchyny/gojq"
 	"github.com/jpfielding/go-http-digest/pkg/digest"
+	"github.com/robertkrimen/otto"
+	"github.com/volkszaehler/mbmd/meters/rs485"
 )
 
 // HTTP implements HTTP request provider
@@ -25,6 +29,10 @@ type HTTP struct {
 	body        string
 	re          *regexp.Regexp
 	jq          *gojq.Query
+	unpack      string
+	decode      string
+	vm          *otto.Otto
+	script      string
 	scale       float64
 	cache       time.Duration
 	updated     time.Time
@@ -49,6 +57,10 @@ func NewHTTPProviderFromConfig(other map[string]interface{}) (IntProvider, error
 		Body        string
 		Regex       string
 		Jq          string
+		Unpack      string
+		Decode      string
+		VM          string
+		Script      string
 		Scale       float64
 		Insecure    bool
 		Auth        Auth
@@ -83,6 +95,18 @@ func NewHTTPProviderFromConfig(other map[string]interface{}) (IntProvider, error
 		_, err = http.WithJq(cc.Jq)
 	}
 
+	if err == nil && cc.Unpack != "" {
+		_, err = http.WithUnpack(cc.Unpack)
+	}
+
+	if err == nil && cc.Decode != "" {
+		_, err = http.WithDecode(cc.Decode)
+	}
+
+	if err == nil && cc.Script != "" {
+		_, err = http.WithScript(cc.VM, cc.Script)
+	}
+
 	if err == nil && cc.Auth.Type != "" {
 		_, err = http.WithAuth(cc.Auth.Type, cc.Auth.User, cc.Auth.Password)
 	}
@@ -149,6 +173,30 @@ func (p *HTTP) WithJq(jq string) (*HTTP, error) {
 	return p, nil
 }
 
+// WithUnpack adds data unpacking
+func (p *HTTP) WithUnpack(unpack string) (*HTTP, error) {
+	p.unpack = strings.ToLower(unpack)
+
+	return p, nil
+}
+
+// WithDecode adds data decoding
+func (p *HTTP) WithDecode(decode string) (*HTTP, error) {
+	p.decode = strings.ToLower(decode)
+
+	return p, nil
+}
+
+// WithScript adds a javascript script to process the response
+func (p *HTTP) WithScript(vm, script string) (*HTTP, error) {
+	regvm := javascript.RegisteredVM(strings.ToLower(vm))
+
+	p.vm = regvm
+	p.script = script
+
+	return p, nil
+}
+
 // WithAuth adds authorized transport
 func (p *HTTP) WithAuth(typ, user, password string) (*HTTP, error) {
 	switch strings.ToLower(typ) {
@@ -184,6 +232,48 @@ func (p *HTTP) request(body ...string) ([]byte, error) {
 	return p.val, p.err
 }
 
+func (p *HTTP) unpackValue(value []byte) (string, error) {
+	switch p.unpack {
+	case "hex":
+		b, err := hex.DecodeString(string(value))
+		if err != nil {
+			return "", err
+		}
+		return string(b), nil
+	}
+
+	return "", fmt.Errorf("invalid unpack: %s", p.unpack)
+}
+
+// decode a hex string to a proper value
+// TODO reuse similar code from Modbus
+func (p *HTTP) decodeValue(value []byte) (interface{}, error) {
+	switch p.decode {
+	case "float32", "ieee754":
+		return rs485.RTUIeee754ToFloat64(value), nil
+	case "float32s", "ieee754s":
+		return rs485.RTUIeee754ToFloat64Swapped(value), nil
+	case "float64":
+		return rs485.RTUUint64ToFloat64(value), nil
+	case "uint16":
+		return rs485.RTUUint16ToFloat64(value), nil
+	case "uint32":
+		return rs485.RTUUint32ToFloat64(value), nil
+	case "uint32s":
+		return rs485.RTUUint32ToFloat64Swapped(value), nil
+	case "uint64":
+		return rs485.RTUUint64ToFloat64(value), nil
+	case "int16":
+		return rs485.RTUInt16ToFloat64(value), nil
+	case "int32":
+		return rs485.RTUInt32ToFloat64(value), nil
+	case "int32s":
+		return rs485.RTUInt32ToFloat64Swapped(value), nil
+	}
+
+	return nil, fmt.Errorf("invalid decoding: %s", p.decode)
+}
+
 // FloatGetter parses float from request
 func (p *HTTP) FloatGetter() func() (float64, error) {
 	g := p.StringGetter()
@@ -230,7 +320,40 @@ func (p *HTTP) StringGetter() func() (string, error) {
 
 		if p.jq != nil {
 			v, err := jq.Query(p.jq, b)
-			return fmt.Sprintf("%v", v), err
+			if err != nil {
+				return string(b), err
+			}
+			b = []byte(fmt.Sprintf("%v", v))
+		}
+
+		if p.unpack != "" {
+			v, err := p.unpackValue(b)
+			if err != nil {
+				return string(b), err
+			}
+			b = []byte(fmt.Sprintf("%v", v))
+		}
+
+		if p.decode != "" {
+			v, err := p.decodeValue(b)
+			if err != nil {
+				return string(b), err
+			}
+			b = []byte(fmt.Sprintf("%v", v))
+		}
+
+		if p.vm != nil {
+			err := p.vm.Set("val", string(b))
+			if err != nil {
+				return string(b), err
+			}
+
+			v, err := p.vm.Eval(p.script)
+			if err != nil {
+				return string(b), err
+			}
+
+			return v.ToString()
 		}
 
 		return string(b), err
diff --git a/schema.json b/schema.json
index b2e86b07..18cf29e0 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 09a87563..3d7f1c8c 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 00000000..2208d5b0
--- /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 00000000..a64b8a36
--- /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 00000000..621f81d9
--- /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 00000000..bf4ef5da
--- /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 00000000..7f4677dc
--- /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 00000000..429c749e
--- /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 00000000..59eac61c
--- /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 00000000..56598a40
--- /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/vehicle/template.go b/vehicle/template.go
new file mode 100644
index 00000000..0de82c7d
--- /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)
+}


Test patch:
diff --git a/charger/config_test.go b/charger/config_test.go
index d4a8661f..c36423ee 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_test.go b/charger/template_test.go
new file mode 100644
index 00000000..e5912cb5
--- /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/devicetest.go b/cmd/configure/devicetest.go
new file mode 100644
index 00000000..0662385a
--- /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/meter/config_test.go b/meter/config_test.go
index 1ba9c0d6..e116485f 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_test.go b/meter/template_test.go
new file mode 100644
index 00000000..8672671a
--- /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/util/templates/utils_test.go b/util/templates/utils_test.go
new file mode 100644
index 00000000..da82f42d
--- /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 74575b0c..8bc180cd 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_test.go b/vehicle/template_test.go
new file mode 100644
index 00000000..108b83b6
--- /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)
+			}
+		})
+	}
+}