Experimentelle geführte Erstellung der Konfigurationsdatei (#1888)

Dies ist eine experimentelle Umsetzung im folgenden aufgeführten Ziele.

Wie es verwendet:
- Start über `evcc configure`
- Die Konfiguration wird in evcc.yaml geschrieben. Falls diese existiert kann ein alternativer Dateiname angegeben werden

Was es kann:
- Eine geführte Erstellung der Konfigurationsdatei
- Direktes Testen ob die Konfiguration jedes Gerätes auch funktioniert
- Konfigurationsabhängigkeiten durch direkte Konfiguration der Abhängigkeit lösen (z.B. Sponsorshipt required)
- Konfiguration in für Anwender bekannte Informationen und möglichst wenig Informationseingabe zu ermöglichen, z.B. anhand von Produkten anstatt aus der Implementierungssicht
- Bisherige Konfiguration funktioniert weiter
This commit is contained in:
Andreas Linde 2021-11-30 14:29:09 +01:00 • committed by GitHub
parent 3c311b6234
commit 7e41a970d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 2951 additions and 71 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@ __debug_bin
*.log
*.json
*.yaml
!templates/**/*.yaml
!package*.json
!evcc.dist.yaml
evcc

View file

@ -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

44
charger/template.go Normal file
View file

@ -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)
}

49
charger/template_test.go Normal file
View file

@ -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)
}
})
}
}

41
cmd/configure.go Normal file
View file

@ -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)
}

137
cmd/configure/configure.go Normal file
View file

@ -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
}

View file

@ -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}}

142
cmd/configure/devicetest.go Normal file
View file

@ -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
}

52
cmd/configure/eebus.go Normal file
View file

@ -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
}

197
cmd/configure/flow.go Normal file
View file

@ -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
}

345
cmd/configure/helper.go Normal file
View file

@ -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
}

View file

@ -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"

View file

@ -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"

272
cmd/configure/main.go Normal file
View file

@ -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
}

203
cmd/configure/survey.go Normal file
View file

@ -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
}

97
cmd/configure/texts.go Normal file
View file

@ -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
}

90
cmd/configure/types.go Normal file
View file

@ -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{}

View file

@ -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,

4
go.mod
View file

@ -3,6 +3,8 @@ module github.com/evcc-io/evcc
go 1.16
require (
github.com/AlecAivazis/survey/v2 v2.3.2
github.com/BurntSushi/toml v0.4.1
github.com/Masterminds/sprig/v3 v3.2.2
github.com/PuerkitoBio/goquery v1.8.0
github.com/alvaroloes/enumer v1.1.2
@ -12,6 +14,7 @@ require (
github.com/benbjohnson/clock v1.3.0
github.com/bogosj/tesla v1.0.2
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/containrrr/shoutrrr v0.5.2
github.com/deepmap/oapi-codegen v1.9.0 // indirect
github.com/denisbrodbeck/machineid v1.0.1
@ -59,6 +62,7 @@ require (
github.com/mlnoga/rct v0.1.2-0.20211011124352-7e995f76b592
github.com/muka/go-bluetooth v0.0.0-20211122080231-b99792bbe62a
github.com/mxschmitt/golang-combinations v1.1.0
github.com/nicksnyder/go-i18n/v2 v2.1.2
github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20170819232839-0fbfe93532da
github.com/olekukonko/tablewriter v0.0.5
github.com/onsi/ginkgo v1.16.5 // indirect

20
go.sum
View file

@ -43,7 +43,11 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/AlecAivazis/survey/v2 v2.3.2 h1:TqTB+aDDCLYhf9/bD2TwSO8u8jDSmMUd2SUVO4gCnU8=
github.com/AlecAivazis/survey/v2 v2.3.2/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
@ -51,6 +55,8 @@ github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030I
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8=
github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/goquery v1.7.1/go.mod h1:XY0pP4kfraEmmV1O7Uf6XyjoslwsneBbgeDjLYuN8xY=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
@ -115,6 +121,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 h1:tuijfIjZyjZaHq9xDUh0tNitwXshJpbLkqMOJv4H3do=
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21/go.mod h1:po7NpZ/QiTKzBKyrsEAxwnTamCoh8uDk/egRpQ7siIc=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
@ -395,6 +403,8 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p
github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
@ -474,6 +484,8 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.4 h1:5Myjjh3JY/NaAi4IsUbHADytDyl1VE1Y9PXDlL+P/VQ=
github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
@ -533,6 +545,8 @@ github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
@ -572,6 +586,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mxschmitt/golang-combinations v1.1.0 h1:WlIZCnDm+Xlb2pRPf+R/qPKlGOU1w8lpN69/uy5z+Zg=
github.com/mxschmitt/golang-combinations v1.1.0/go.mod h1:RbMhWvfCelHR6WROvT2bVfxJvZHoEvBj71SKe+H0MYU=
github.com/nicksnyder/go-i18n/v2 v2.1.2 h1:QHYxcUJnGHBaq7XbvgunmZ2Pn0focXFqTD61CkH146c=
github.com/nicksnyder/go-i18n/v2 v2.1.2/go.mod h1:d++QJC9ZVf7pa48qrsRWhMJ5pSHIPmS3OLqK1niyLxs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20170819232839-0fbfe93532da h1:qiPWuGGr+1GQE6s9NPSK8iggR/6x/V+0snIoOPYsBgc=
github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20170819232839-0fbfe93532da/go.mod h1:DvuJJ/w1Y59rG8UTDxsMk5U+UJXJwuvUgbiJSm9yhX8=
@ -711,6 +727,7 @@ github.com/spf13/viper v1.9.0/go.mod h1:+i6ajR7OX2XaiBkrcZJFK21htRk7eDeLg7+O6bhU
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -784,6 +801,7 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@ -1018,6 +1036,8 @@ golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1 h1:kwrAHlwJ0DUBZwQ238v+Uod/3
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View file

@ -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

44
meter/template.go Normal file
View file

@ -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)
}

66
meter/template_test.go Normal file
View file

@ -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)
}
})
}

View file

@ -148,6 +148,9 @@
},
"type": {
"type": "string"
},
"template": {
"type": "string"
}
}
},

View file

@ -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)) {

View file

@ -0,0 +1,8 @@
package definition
import "embed"
var (
//go:embed charger/*.yaml meter/*.yaml vehicle/*.yaml
YamlTemplates embed.FS
)

View file

@ -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
}

73
util/templates/init.go Normal file
View file

@ -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)
}

18
util/templates/modbus.tpl Normal file
View file

@ -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}}

10
util/templates/proxy.tpl Normal file
View file

@ -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 -}}

300
util/templates/template.go Normal file
View file

@ -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
}

View file

@ -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
}
}
}

23
util/templates/utils.go Normal file
View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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

44
vehicle/template.go Normal file
View file

@ -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)
}

38
vehicle/template_test.go Normal file
View file

@ -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)
}
})
}
}