-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
diff --git a/assets/js/components/MaterialIcon/Loadpoint.vue b/assets/js/components/MaterialIcon/Loadpoint.vue
new file mode 100644
index 000000000..5defffd0c
--- /dev/null
+++ b/assets/js/components/MaterialIcon/Loadpoint.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/assets/js/components/Mode.vue b/assets/js/components/Mode.vue
index 0986782b4..49e9394c6 100644
--- a/assets/js/components/Mode.vue
+++ b/assets/js/components/Mode.vue
@@ -6,6 +6,7 @@
type="button"
class="btn flex-grow-1 flex-shrink-1"
:class="{ active: isActive(m) }"
+ tabindex="0"
@click="setTargetMode(m)"
>
{{ label(m) }}
@@ -73,6 +74,10 @@ export default {
.btn:hover {
color: var(--evcc-gray);
}
+.btn:focus {
+ outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color);
+ outline-width: var(--bs-focus-ring-width);
+}
.btn.active {
color: var(--evcc-background);
background: var(--evcc-default-text);
diff --git a/assets/js/components/OfflineIndicator.vue b/assets/js/components/OfflineIndicator.vue
index 96316047f..0d295275a 100644
--- a/assets/js/components/OfflineIndicator.vue
+++ b/assets/js/components/OfflineIndicator.vue
@@ -12,6 +12,7 @@
class="btn btn-secondary me-2 btn-sm d-flex align-items-center"
type="button"
disabled
+ tabindex="0"
>
@@ -62,6 +64,7 @@
type="button"
class="btn-close mt-1"
aria-label="Close"
+ tabindex="0"
@click="dismissed = true"
>
diff --git a/assets/js/components/SelectGroup.vue b/assets/js/components/SelectGroup.vue
index a746dbb70..7af0c5631 100644
--- a/assets/js/components/SelectGroup.vue
+++ b/assets/js/components/SelectGroup.vue
@@ -8,6 +8,8 @@
class="btn btn-sm flex-grow-1 flex-shrink-1"
:class="{ active: option.value === modelValue, 'btn--equal': equalWidth }"
:disabled="option.disabled"
+ :data-testid="`${id}-${option.value}`"
+ tabindex="0"
@click="$emit('update:modelValue', option.value)"
>
{{ option.name }}
@@ -56,6 +58,10 @@ export default {
.btn:hover {
color: var(--evcc-gray);
}
+.btn:focus {
+ outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color);
+ outline-width: var(--bs-focus-ring-width);
+}
.btn.active {
color: var(--evcc-background);
background: var(--evcc-default-text);
diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue
index a31777fb6..217ea270c 100644
--- a/assets/js/components/Site.vue
+++ b/assets/js/components/Site.vue
@@ -3,7 +3,9 @@
- {{ siteTitle || "evcc" }}
+
+ {{ siteTitle || "evcc" }}
+
-
+
{{ $t("startupError.title") }}
+
+
+
+ {{ $t("main.welcome") }}
+
+
+
+ {{ $t("main.startConfiguration") }}
+
+
+
diff --git a/assets/js/components/WelcomeIcons.vue b/assets/js/components/WelcomeIcons.vue
new file mode 100644
index 000000000..0e561d966
--- /dev/null
+++ b/assets/js/components/WelcomeIcons.vue
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/js/mixins/formatter.js b/assets/js/mixins/formatter.js
index a86ff9cb8..1d487e077 100644
--- a/assets/js/mixins/formatter.js
+++ b/assets/js/mixins/formatter.js
@@ -253,13 +253,13 @@ export default {
day: "numeric",
}).format(date);
},
- fmtSecondUnit: function (seconds) {
+ fmtDurationUnit: function (value, unit = "second") {
return new Intl.NumberFormat(this.$i18n?.locale, {
style: "unit",
- unit: "second",
+ unit,
unitDisplay: "long",
})
- .formatToParts(seconds)
+ .formatToParts(value)
.find((part) => part.type === "unit").value;
},
fmtMoney: function (amout = 0, currency = "EUR", decimals = true, withSymbol = false) {
diff --git a/assets/js/utils/deepClone.js b/assets/js/utils/deepClone.js
new file mode 100644
index 000000000..acf99cbc1
--- /dev/null
+++ b/assets/js/utils/deepClone.js
@@ -0,0 +1,3 @@
+export default function (obj) {
+ return JSON.parse(JSON.stringify(obj));
+}
diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue
index 0067c6505..cbc884433 100644
--- a/assets/js/views/Config.vue
+++ b/assets/js/views/Config.vue
@@ -18,7 +18,6 @@
Missing features
- aux meters
- - loadpoints and chargers
- custom/plugin meters and vehicles
- migration for vehicles, chargers, meters, loadpoints
- remove mixed mode (evcc.yaml + db) for meters and vehicles
@@ -36,31 +35,85 @@
+
{{ $t("config.section.loadpoints") }} đź§Ş
+
+
+
{{ $t("config.section.vehicles") }} đź§Ş
+
+
{{ $t("config.section.grid") }} đź§Ş
{{ $t("config.section.meter") }} đź§Ş
@@ -109,163 +168,116 @@
-
-
-
-
{{ $t("config.section.loadpoints") }} đź§Ş
-
-
-
{{ $t("config.section.vehicles") }} đź§Ş
-
-
+
{{ $t("config.section.integrations") }} đź§Ş
-
{{ $t("config.section.integrations") }} đź§Ş
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+ {{ circuit.config?.title }}
+ ({{ circuit.name }})
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ circuit.config?.title }}
- ({{ circuit.name }})
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -280,14 +292,41 @@
+
+
@@ -306,18 +345,17 @@
diff --git a/charger/template_test.go b/charger/template_test.go
index 9b0e6c38a..884d0b006 100644
--- a/charger/template_test.go
+++ b/charger/template_test.go
@@ -29,7 +29,7 @@ var acceptable = []string{
"(Client.Timeout exceeded while awaiting headers)",
"can only have either uri or device", // modbus
"connection already registered with different protocol: localhost:502", // modbus
- "sponsorship required, see https://github.com/evcc-io/evcc#sponsorship",
+ "sponsorship required, see https://docs.evcc.io/docs/sponsorship",
"eebus not configured",
"context deadline exceeded",
"timeout", // ocpp
diff --git a/cmd/demo.yaml b/cmd/demo.yaml
index c54a5b7ee..586f69aa1 100644
--- a/cmd/demo.yaml
+++ b/cmd/demo.yaml
@@ -262,7 +262,6 @@ tariffs:
feedin:
type: fixed
price: 0.08 # EUR/kWh
- co2:
- type: template
- template: grĂĽnstromindex
- zip: 12349
+ #co2:
+ # type: grĂĽnstromindex
+ # zip: 12349
diff --git a/cmd/migrate.go b/cmd/migrate.go
index d384e4bee..d4d71bc4b 100644
--- a/cmd/migrate.go
+++ b/cmd/migrate.go
@@ -2,7 +2,9 @@ package cmd
import (
"github.com/evcc-io/evcc/core/keys"
+ "github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/db/settings"
+ "github.com/evcc-io/evcc/util/config"
"github.com/spf13/cobra"
)
@@ -34,83 +36,95 @@ func runMigrate(cmd *cobra.Command, args []string) {
// TODO remove yaml file
if reset {
- log.WARN.Println("resetting:")
+ log.INFO.Println("resetting:")
} else {
- log.WARN.Println("migrating:")
+ log.INFO.Println("migrating:")
}
- log.DEBUG.Println("- global settings")
+ log.INFO.Println("- global settings")
if reset {
settings.Delete(keys.Interval)
settings.Delete(keys.SponsorToken)
+ settings.Delete(keys.Title)
} else {
settings.SetInt(keys.Interval, int64(conf.Interval))
settings.SetString(keys.SponsorToken, conf.SponsorToken)
}
- log.DEBUG.Println("- network")
+ log.INFO.Println("- network")
if reset {
settings.Delete(keys.Network)
} else {
_ = settings.SetJson(keys.Network, conf.Network)
}
- log.DEBUG.Println("- mqtt")
+ log.INFO.Println("- mqtt")
if reset {
settings.Delete(keys.Mqtt)
} else {
_ = settings.SetJson(keys.Mqtt, conf.Mqtt)
}
- log.DEBUG.Println("- influx")
+ log.INFO.Println("- influx")
if reset {
settings.Delete(keys.Influx)
} else {
_ = settings.SetJson(keys.Influx, conf.Influx)
}
- log.DEBUG.Println("- hems")
+ log.INFO.Println("- hems")
if reset {
settings.Delete(keys.Hems)
} else if conf.HEMS.Type != "" {
_ = settings.SetYaml(keys.Hems, conf.HEMS)
}
- log.DEBUG.Println("- eebus")
+ log.INFO.Println("- eebus")
if reset {
settings.Delete(keys.EEBus)
} else if conf.EEBus.Configured() {
_ = settings.SetYaml(keys.EEBus, conf.EEBus)
}
- log.DEBUG.Println("- modbusproxy")
+ log.INFO.Println("- modbusproxy")
if reset {
settings.Delete(keys.ModbusProxy)
} else if len(conf.ModbusProxy) > 0 {
_ = settings.SetYaml(keys.ModbusProxy, conf.ModbusProxy)
}
- log.DEBUG.Println("- messaging")
+ log.INFO.Println("- messaging")
if reset {
settings.Delete(keys.Messaging)
} else if len(conf.Messaging.Services) > 0 {
_ = settings.SetYaml(keys.Messaging, conf.Messaging)
}
- log.DEBUG.Println("- tariffs")
+ log.INFO.Println("- tariffs")
if reset {
settings.Delete(keys.Tariffs)
} else if conf.Tariffs.Grid.Type != "" || conf.Tariffs.FeedIn.Type != "" || conf.Tariffs.Co2.Type != "" || conf.Tariffs.Planner.Type != "" {
_ = settings.SetYaml(keys.Tariffs, conf.Tariffs)
}
- log.DEBUG.Println("- circuits")
+ log.INFO.Println("- circuits")
if reset {
settings.Delete(keys.Circuits)
} else if len(conf.Circuits) > 0 {
_ = settings.SetYaml(keys.Circuits, conf.Circuits)
}
+ log.INFO.Println("- device configs")
+ if reset {
+ // site keys
+ settings.Delete(keys.GridMeter)
+ settings.Delete(keys.PvMeters)
+ settings.Delete(keys.AuxMeters)
+ settings.Delete(keys.BatteryMeters)
+ // clear config table
+ result := db.Instance.Delete(&config.Config{}, "true")
+ log.INFO.Printf(" %d entries deleted", result.RowsAffected)
+ }
// wait for shutdown
<-shutdownDoneC()
}
diff --git a/cmd/refs.go b/cmd/refs.go
new file mode 100644
index 000000000..f7598815e
--- /dev/null
+++ b/cmd/refs.go
@@ -0,0 +1,98 @@
+package cmd
+
+import (
+ "iter"
+ "slices"
+ "strings"
+
+ "github.com/evcc-io/evcc/api/globalconfig"
+ "github.com/evcc-io/evcc/core"
+ "github.com/evcc-io/evcc/core/keys"
+ "github.com/evcc-io/evcc/server/db/settings"
+ "github.com/evcc-io/evcc/util"
+ "github.com/evcc-io/evcc/util/config"
+ "github.com/evcc-io/evcc/util/templates"
+)
+
+var references struct {
+ meter, charger, vehicle, circuit []string
+}
+
+func collectRefs(conf globalconfig.All) error {
+ // site
+ if err := collectSiteRefs(conf); err != nil {
+ return err
+ }
+
+ // loadpoints
+ if err := collectLoadpointRefs(slices.Values(conf.Loadpoints)); err != nil {
+ return err
+ }
+
+ // append devices from database
+ configurable, err := config.ConfigurationsByClass(templates.Loadpoint)
+ if err != nil {
+ return err
+ }
+
+ return collectLoadpointRefs(func(yield func(config.Named) bool) {
+ for _, cc := range configurable {
+ if !yield(cc.Named()) {
+ return
+ }
+ }
+ })
+}
+
+func collectSiteRefs(conf globalconfig.All) error {
+ var refs struct {
+ Meters core.MetersConfig `mapstructure:"meters"` // Meter references
+ Other map[string]any `mapstructure:",remain"`
+ }
+
+ if err := util.DecodeOther(conf.Site, &refs); err != nil {
+ return err
+ }
+
+ references.meter = append(references.meter, refs.Meters.GridMeterRef)
+ references.meter = append(references.meter, refs.Meters.PVMetersRef...)
+ references.meter = append(references.meter, refs.Meters.BatteryMetersRef...)
+ references.meter = append(references.meter, refs.Meters.ExtMetersRef...)
+ references.meter = append(references.meter, refs.Meters.AuxMetersRef...)
+
+ // append devices from settings
+ if v, err := settings.String(keys.GridMeter); err == nil && v != "" {
+ references.meter = append(references.meter, v)
+ }
+
+ for _, key := range []string{keys.PvMeters, keys.BatteryMeters, keys.ExtMeters, keys.AuxMeters} {
+ if v, err := settings.String(key); err == nil && v != "" {
+ references.meter = append(references.meter, strings.Split(v, ",")...)
+ }
+ }
+
+ return nil
+}
+
+func collectLoadpointRefs(named iter.Seq[config.Named]) error {
+ for cc := range named {
+ var refs struct {
+ CircuitRef string `mapstructure:"circuit"` // Circuit reference
+ ChargerRef string `mapstructure:"charger"` // Charger reference
+ VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
+ MeterRef string `mapstructure:"meter"` // Charge meter reference
+ Other map[string]any `mapstructure:",remain"`
+ }
+
+ if err := util.DecodeOther(cc.Other, &refs); err != nil {
+ return err
+ }
+
+ references.meter = append(references.meter, refs.MeterRef)
+ references.charger = append(references.charger, refs.ChargerRef)
+ references.vehicle = append(references.vehicle, refs.VehicleRef)
+ references.circuit = append(references.circuit, refs.CircuitRef)
+ }
+
+ return nil
+}
diff --git a/cmd/setup.go b/cmd/setup.go
index 5cbeae6bf..f7dd80aa7 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -22,6 +22,8 @@ import (
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/circuit"
"github.com/evcc-io/evcc/core/keys"
+ "github.com/evcc-io/evcc/core/loadpoint"
+ coresettings "github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/hems"
"github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/plugin/golang"
@@ -46,6 +48,7 @@ import (
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/libp2p/zeroconf/v2"
+ "github.com/samber/lo"
"github.com/spf13/cast"
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
@@ -262,7 +265,7 @@ func configureMeters(static []config.Named, names ...string) error {
return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
}
- if err := config.Meters().Add(config.NewConfigurableDevice(conf, instance)); err != nil {
+ if err := config.Meters().Add(config.NewConfigurableDevice(&conf, instance)); err != nil {
return &DeviceError{cc.Name, err}
}
@@ -326,7 +329,7 @@ func configureChargers(static []config.Named, names ...string) error {
return fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)
}
- if err := config.Chargers().Add(config.NewConfigurableDevice(conf, instance)); err != nil {
+ if err := config.Chargers().Add(config.NewConfigurableDevice(&conf, instance)); err != nil {
return &DeviceError{cc.Name, err}
}
@@ -419,7 +422,7 @@ func configureVehicles(static []config.Named, names ...string) error {
mu.Lock()
defer mu.Unlock()
- devs2 = append(devs2, config.NewConfigurableDevice(conf, instance))
+ devs2 = append(devs2, config.NewConfigurableDevice(&conf, instance))
return nil
})
@@ -796,11 +799,16 @@ func configureTariffs(conf globalconfig.Tariffs) (*tariff.Tariffs, error) {
}
func configureDevices(conf globalconfig.All) error {
+ // collect references for filtering used devices
+ if err := collectRefs(conf); err != nil {
+ return err
+ }
+
// TODO: add name/identifier to error for better highlighting in UI
- if err := configureMeters(conf.Meters); err != nil {
+ if err := configureMeters(conf.Meters, references.meter...); err != nil {
return &ClassError{ClassMeter, err}
}
- if err := configureChargers(conf.Chargers); err != nil {
+ if err := configureChargers(conf.Chargers, references.charger...); err != nil {
return &ClassError{ClassCharger, err}
}
if err := configureVehicles(conf.Vehicles); err != nil {
@@ -853,8 +861,7 @@ func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) {
return nil, err
}
- loadpoints, err := configureLoadpoints(*conf)
- if err != nil {
+ if err := configureLoadpoints(*conf); err != nil {
return nil, fmt.Errorf("failed configuring loadpoints: %w", err)
}
@@ -863,6 +870,11 @@ func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) {
return nil, &ClassError{ClassTariff, err}
}
+ loadpoints := lo.Map(config.Loadpoints().Devices(), func(dev config.Device[loadpoint.API], _ int) *core.Loadpoint {
+ lp := dev.Instance()
+ return lp.(*core.Loadpoint)
+ })
+
site, err := configureSite(conf.Site, loadpoints, tariffs)
if err != nil {
return nil, err
@@ -924,26 +936,59 @@ func configureSite(conf map[string]interface{}, loadpoints []*core.Loadpoint, ta
return site, nil
}
-func configureLoadpoints(conf globalconfig.All) ([]*core.Loadpoint, error) {
- if len(conf.Loadpoints) == 0 {
- return nil, errors.New("missing loadpoints")
- }
+func configureLoadpoints(conf globalconfig.All) error {
+ for id, cc := range conf.Loadpoints {
+ cc.Name = "lp-" + strconv.Itoa(id+1)
- var loadpoints []*core.Loadpoint
+ log := util.NewLoggerWithLoadpoint(cc.Name, id+1)
+ settings := coresettings.NewDatabaseSettingsAdapter(fmt.Sprintf("lp%d.", id+1))
- for id, cfg := range conf.Loadpoints {
- log := util.NewLoggerWithLoadpoint("lp-"+strconv.Itoa(id+1), id+1)
- settings := &core.Settings{Key: "lp" + strconv.Itoa(id+1) + "."}
-
- lp, err := core.NewLoadpointFromConfig(log, settings, cfg)
+ instance, err := core.NewLoadpointFromConfig(log, settings, cc.Other)
if err != nil {
- return nil, fmt.Errorf("failed configuring loadpoint: %w", err)
+ return fmt.Errorf("failed configuring loadpoint: %w", err)
}
- loadpoints = append(loadpoints, lp)
+ if err := config.Loadpoints().Add(config.NewStaticDevice(cc, loadpoint.API(instance))); err != nil {
+ return &DeviceError{cc.Name, err}
+ }
}
- return loadpoints, nil
+ // append devices from database
+ configurable, err := config.ConfigurationsByClass(templates.Loadpoint)
+ if err != nil {
+ return err
+ }
+
+ for _, conf := range configurable {
+ cc := conf.Named()
+
+ id := len(config.Loadpoints().Devices())
+ name := "lp-" + strconv.Itoa(id+1)
+ log := util.NewLoggerWithLoadpoint(name, id+1)
+
+ settings := coresettings.NewConfigSettingsAdapter(log, &conf)
+
+ dynamic, static, err := loadpoint.SplitConfig(cc.Other)
+ if err != nil {
+ return fmt.Errorf("failed configuring loadpoint: %w", err)
+ }
+
+ instance, err := core.NewLoadpointFromConfig(log, settings, static)
+ if err != nil {
+ return fmt.Errorf("failed configuring loadpoint: %w", err)
+ }
+
+ if err := dynamic.Apply(instance); err != nil {
+ return err
+ }
+
+ dev := config.NewConfigurableDevice[loadpoint.API](&conf, instance)
+ if err := config.Loadpoints().Add(dev); err != nil {
+ return err
+ }
+ }
+
+ return nil
}
// configureAuth handles routing for devices. For now only api.AuthProvider related routes
diff --git a/cmd/setup_circuits_test.go b/cmd/setup_circuits_test.go
index 80f65190a..5728ace64 100644
--- a/cmd/setup_circuits_test.go
+++ b/cmd/setup_circuits_test.go
@@ -6,6 +6,7 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/globalconfig"
+ "github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/util/config"
"github.com/stretchr/testify/suite"
@@ -59,9 +60,11 @@ loadpoints:
Name: "test",
}, api.Charger(nil))))
- lps, err := configureLoadpoints(conf)
+ err := configureLoadpoints(conf)
suite.Require().NoError(err)
- suite.Require().NotNil(lps[0].GetCircuit())
+
+ lps := config.Loadpoints().Devices()
+ suite.Require().NotNil(lps[0].Instance().GetCircuit())
}
func (suite *circuitsTestSuite) TestCircuitMissingLoadpoint() {
@@ -88,9 +91,15 @@ loadpoints:
Name: "test",
}, api.Charger(nil))))
- lps, err := configureLoadpoints(conf)
+ err := configureLoadpoints(conf)
suite.Require().NoError(err)
+ lpsd := config.Loadpoints().Devices()
+ lps := make([]*core.Loadpoint, 0, len(lpsd))
+ for _, lp := range lpsd {
+ lps = append(lps, lp.Instance().(*core.Loadpoint))
+ }
+
// circuit without device
err = validateCircuits(lps)
suite.Require().Error(err)
@@ -140,9 +149,15 @@ loadpoints:
Name: "test",
}, api.Charger(nil))))
- lps, err := configureLoadpoints(conf)
+ err := configureLoadpoints(conf)
suite.Require().NoError(err)
+ lpsd := config.Loadpoints().Devices()
+ lps := make([]*core.Loadpoint, 0, len(lpsd))
+ for _, lp := range lpsd {
+ lps = append(lps, lp.Instance().(*core.Loadpoint))
+ }
+
// lp using root circuit is valid
suite.Require().NoError(validateCircuits(lps))
}
diff --git a/cmd/setup_test.go b/cmd/setup_test.go
index f4dae37f5..1847a8dda 100644
--- a/cmd/setup_test.go
+++ b/cmd/setup_test.go
@@ -24,11 +24,11 @@ func TestYamlOff(t *testing.T) {
}
var lp core.Loadpoint
- if err := util.DecodeOther(conf.Loadpoints[0], &lp); err != nil {
+ if err := util.DecodeOther(conf.Loadpoints[0].Other, &lp); err != nil {
t.Error(err)
}
- if lp.Mode_ != api.ModeOff {
- t.Errorf("expected `off`, got %s", lp.Mode_)
+ if lp.DefaultMode != api.ModeOff {
+ t.Errorf("expected `off`, got %s", lp.DefaultMode)
}
}
diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go
index 4308fddf3..543f914b9 100644
--- a/core/keys/loadpoint.go
+++ b/core/keys/loadpoint.go
@@ -4,27 +4,31 @@ const (
// loadpoint settings
Title = "title" // loadpoint title
Mode = "mode" // charge mode
+ DefaultMode = "defaultMode" // default charge mode
Priority = "priority" // priority
MinCurrent = "minCurrent" // min current
MaxCurrent = "maxCurrent" // max current
MinSoc = "minSoc" // min soc
LimitSoc = "limitSoc" // limit soc
LimitEnergy = "limitEnergy" // limit energy
+ Soc = "soc"
+ Thresholds = "thresholds"
EnableThreshold = "enableThreshold"
DisableThreshold = "disableThreshold"
EnableDelay = "enableDelay"
DisableDelay = "disableDelay"
BatteryBoost = "batteryBoost"
- PhasesConfigured = "phasesConfigured" // configured phases (1/3, 0 for auto on 1p3p chargers, nil for plain chargers)
+ Phases = "phases" // configured phases (1/3, 0 for auto on 1p3p chargers, nil for plain chargers)
+ PhasesConfigured = "phasesConfigured" // TODO mirrors "phases" for UI purposes
PhasesEnabled = "phasesEnabled" // enabled phases (1/3)
PhasesActive = "phasesActive" // active phases as used by vehicle (1/2/3)
- ChargerIcon = "chargerIcon" // charger icon for ui
- ChargerFeature = "chargerFeature" // charger feature
- ChargerPhysicalPhases = "chargerPhysicalPhases" // charger phases
- ChargerPhases1p3p = "chargerPhases1p3p" // phase switcher (1p3p chargers)
- ChargerStatusReason = "chargerStatusReason" // either awaiting authorization or disconnect required
+ ChargerIcon = "chargerIcon" // charger icon for ui
+ ChargerFeature = "chargerFeature" // charger feature
+ ChargerSinglePhase = "chargerSinglePhase" // api.PhaseDescriber: charger physical phases, sockets only
+ ChargerPhases1p3p = "chargerPhases1p3p" // api.PhaseSwitcher: 1p3p chargers
+ ChargerStatusReason = "chargerStatusReason" // either awaiting authorization or disconnect required
// loadpoint status
Enabled = "enabled" // loadpoint enabled
diff --git a/core/loadpoint.go b/core/loadpoint.go
index e6cff3974..3f761e257 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -6,7 +6,6 @@ import (
"math"
"reflect"
"slices"
- "strings"
"sync"
"testing"
"time"
@@ -20,6 +19,7 @@ import (
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/planner"
"github.com/evcc-io/evcc/core/session"
+ "github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/core/vehicle"
"github.com/evcc-io/evcc/core/wrapper"
@@ -64,32 +64,8 @@ const (
// elapsed is the time an expired timer will be set to
var elapsed = time.Unix(0, 1)
-// PollConfig defines the vehicle polling mode and interval
-type PollConfig struct {
- Mode string `mapstructure:"mode"` // polling mode charging (default), connected, always
- Interval time.Duration `mapstructure:"interval"` // interval when not charging
-}
-
-// SocConfig defines soc settings, estimation and update behavior
-type SocConfig struct {
- Poll PollConfig `mapstructure:"poll"`
- Estimate *bool `mapstructure:"estimate"`
-}
-
// Poll modes
-const (
- pollCharging = "charging"
- pollConnected = "connected"
- pollAlways = "always"
-
- pollInterval = 60 * time.Minute
-)
-
-// ThresholdConfig defines enable/disable hysteresis parameters
-type ThresholdConfig struct {
- Delay time.Duration
- Threshold float64
-}
+const pollInterval = 60 * time.Minute
// Task is the task type
type Task = func()
@@ -107,24 +83,27 @@ type Loadpoint struct {
// exposed public configuration
sync.RWMutex // guard status
- vmu sync.RWMutex // guard vehicle
- Mode_ api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect
+ vmu sync.RWMutex // guard vehicle
- Title_ string `mapstructure:"title"` // UI title
- Priority_ int `mapstructure:"priority"` // Priority
- CircuitRef string `mapstructure:"circuit"` // Circuit reference
- ChargerRef string `mapstructure:"charger"` // Charger reference
- VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
- MeterRef string `mapstructure:"meter"` // Charge meter reference
- Soc SocConfig
- Enable, Disable ThresholdConfig
+ CircuitRef string `mapstructure:"circuit"` // Circuit reference
+ ChargerRef string `mapstructure:"charger"` // Charger reference
+ VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
+ MeterRef string `mapstructure:"meter"` // Charge meter reference
+
+ Soc loadpoint.SocConfig
+ Enable, Disable loadpoint.ThresholdConfig
// TODO deprecated
- GuardDuration_ time.Duration `mapstructure:"guardduration"` // charger enable/disable minimum holding time
- ConfiguredPhases_ int `mapstructure:"phases"`
- MinCurrent_ float64 `mapstructure:"minCurrent"`
- MaxCurrent_ float64 `mapstructure:"maxCurrent"`
+ DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect
+ Title_ string `mapstructure:"title"` // UI title
+ Priority_ int `mapstructure:"priority"` // Priority
+ GuardDuration_ time.Duration `mapstructure:"guardduration"` // charger enable/disable minimum holding time
+ Phases_ int `mapstructure:"phases"`
+ MinCurrent_ float64 `mapstructure:"minCurrent"`
+ MaxCurrent_ float64 `mapstructure:"maxCurrent"`
+ title string // UI title
+ priority int // Priority
minCurrent float64 // PV mode: start current Min+PV mode: min current
maxCurrent float64 // Max allowed current. Physically ensured by the charger
configuredPhases int // Charger configured phase mode 0/1/3
@@ -186,34 +165,31 @@ type Loadpoint struct {
db *session.DB
session *session.Session
- settings *Settings
+ settings settings.Settings
tasks *util.Queue[Task] // tasks to be executed
}
// NewLoadpointFromConfig creates a new loadpoint
-func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[string]interface{}) (*Loadpoint, error) {
+func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, other map[string]interface{}) (*Loadpoint, error) {
lp := NewLoadpoint(log, settings)
if err := util.DecodeOther(other, lp); err != nil {
return nil, err
}
// set vehicle polling mode
- switch lp.Soc.Poll.Mode = strings.ToLower(lp.Soc.Poll.Mode); lp.Soc.Poll.Mode {
- case pollCharging:
- case pollConnected, pollAlways:
+ switch lp.Soc.Poll.Mode {
+ case loadpoint.PollCharging:
+ case loadpoint.PollConnected, loadpoint.PollAlways:
lp.log.WARN.Printf("poll mode '%s' may deplete your battery or lead to API misuse. USE AT YOUR OWN RISK.", lp.Soc.Poll)
default:
- if lp.Soc.Poll.Mode != "" {
- lp.log.WARN.Printf("invalid poll mode: %s", lp.Soc.Poll.Mode)
- }
- lp.Soc.Poll.Mode = pollCharging
+ lp.Soc.Poll.Mode = loadpoint.PollCharging
}
if lp.CircuitRef != "" {
dev, err := config.Circuits().ByName(lp.CircuitRef)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("circuit: %w", err)
}
lp.circuit = dev.Instance()
}
@@ -221,7 +197,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
if lp.MeterRef != "" {
dev, err := config.Meters().ByName(lp.MeterRef)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("meter: %w", err)
}
lp.chargeMeter = dev.Instance()
}
@@ -230,7 +206,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
if lp.VehicleRef != "" {
dev, err := config.Vehicles().ByName(lp.VehicleRef)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("vehicle: %w", err)
}
lp.defaultVehicle = dev.Instance()
}
@@ -240,7 +216,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
}
dev, err := config.Chargers().ByName(lp.ChargerRef)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("charger: %w", err)
}
lp.charger = dev.Instance()
lp.configureChargerType(lp.charger)
@@ -252,24 +228,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
}
// TODO deprecated
- if lp.MinCurrent_ > 0 {
- lp.log.WARN.Println("deprecated: mincurrent setting is ignored, please remove")
- if _, err := lp.settings.Float(keys.MinCurrent); err != nil {
- lp.settings.SetFloat(keys.MinCurrent, lp.MinCurrent_)
- }
- }
- if lp.MaxCurrent_ > 0 {
- lp.log.WARN.Println("deprecated: maxcurrent setting is ignored, please remove")
- if _, err := lp.settings.Float(keys.MaxCurrent); err != nil {
- lp.settings.SetFloat(keys.MaxCurrent, lp.MaxCurrent_)
- }
- }
- if lp.ConfiguredPhases_ > 0 {
- lp.log.WARN.Println("deprecated: phases setting is ignored, please remove")
- if _, err := lp.settings.Int(keys.PhasesConfigured); err != nil {
- lp.settings.SetInt(keys.PhasesConfigured, int64(lp.ConfiguredPhases_))
- }
- }
+ lp.migrateSettings()
// validate thresholds
if lp.Enable.Threshold > lp.Disable.Threshold {
@@ -279,7 +238,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
}
// choose sane default if mode is not set
- if lp.mode = lp.Mode_; lp.mode == "" {
+ if lp.mode = lp.DefaultMode; lp.mode == "" {
lp.mode = api.ModeOff
}
@@ -287,7 +246,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
}
// NewLoadpoint creates a Loadpoint with sane defaults
-func NewLoadpoint(log *util.Logger, settings *Settings) *Loadpoint {
+func NewLoadpoint(log *util.Logger, settings settings.Settings) *Loadpoint {
clock := clock.New()
bus := evbus.New()
@@ -300,31 +259,84 @@ func NewLoadpoint(log *util.Logger, settings *Settings) *Loadpoint {
status: api.StatusNone,
minCurrent: 6, // A
maxCurrent: 16, // A
- Soc: SocConfig{
- Poll: PollConfig{
+ Soc: loadpoint.SocConfig{
+ Poll: loadpoint.PollConfig{
Interval: pollInterval,
- Mode: pollCharging,
+ Mode: loadpoint.PollCharging,
},
},
- Enable: ThresholdConfig{Delay: time.Minute, Threshold: 0}, // t, W
- Disable: ThresholdConfig{Delay: 3 * time.Minute, Threshold: 0}, // t, W
- progress: NewProgress(0, 10), // soc progress indicator
- coordinator: coordinator.NewDummy(), // dummy vehicle coordinator
- tasks: util.NewQueue[Task](), // task queue
+ Enable: loadpoint.ThresholdConfig{Delay: time.Minute, Threshold: 0}, // t, W
+ Disable: loadpoint.ThresholdConfig{Delay: 3 * time.Minute, Threshold: 0}, // t, W
+ progress: NewProgress(0, 10), // soc progress indicator
+ coordinator: coordinator.NewDummy(), // dummy vehicle coordinator
+ tasks: util.NewQueue[Task](), // task queue
}
return lp
}
+// migrateSettings migrates loadpoint settings
+func (lp *Loadpoint) migrateSettings() {
+ // One-time migrations MUST be mirrored in restoreSettings
+ if lp.DefaultMode != "" {
+ lp.log.WARN.Println("deprecated: mode setting is ignored, please remove")
+ if _, err := lp.settings.String(keys.Mode); err != nil {
+ lp.settings.SetString(keys.Mode, string(lp.DefaultMode))
+ }
+ }
+ if lp.Title_ != "" {
+ lp.log.WARN.Println("deprecated: title setting is ignored, please remove")
+ if _, err := lp.settings.String(keys.Title); err != nil {
+ lp.settings.SetString(keys.Title, lp.Title_)
+ }
+ }
+ if lp.Priority_ > 0 {
+ lp.log.WARN.Println("deprecated: priority setting is ignored, please remove")
+ if _, err := lp.settings.String(keys.Priority); err != nil {
+ lp.settings.SetInt(keys.Priority, int64(lp.Priority_))
+ }
+ }
+ if lp.MinCurrent_ > 0 {
+ lp.log.WARN.Println("deprecated: mincurrent setting is ignored, please remove")
+ if _, err := lp.settings.Float(keys.MinCurrent); err != nil {
+ lp.settings.SetFloat(keys.MinCurrent, lp.MinCurrent_)
+ }
+ }
+ if lp.MaxCurrent_ > 0 {
+ lp.log.WARN.Println("deprecated: maxcurrent setting is ignored, please remove")
+ if _, err := lp.settings.Float(keys.MaxCurrent); err != nil {
+ lp.settings.SetFloat(keys.MaxCurrent, lp.MaxCurrent_)
+ }
+ }
+ if lp.Phases_ > 0 {
+ lp.log.WARN.Println("deprecated: phases setting is ignored, please remove")
+ if _, err := lp.settings.Int(keys.Phases); err != nil {
+ lp.settings.SetInt(keys.Phases, int64(lp.Phases_))
+ }
+ }
+ if lp.Soc.Estimate != nil || lp.Soc.Poll.Mode != loadpoint.PollCharging || lp.Soc.Poll.Interval != 0 {
+ lp.log.WARN.Println("deprecated: soc setting is ignored, please remove")
+ if _, err := lp.settings.String(keys.Soc); err != nil {
+ lp.settings.SetJson(keys.Soc, lp.Soc)
+ }
+ }
+}
+
// restoreSettings restores loadpoint settings
func (lp *Loadpoint) restoreSettings() {
if testing.Testing() {
return
}
+ if v, err := lp.settings.String(keys.Title); err == nil && v != "" {
+ lp.setTitle(v)
+ }
if v, err := lp.settings.String(keys.Mode); err == nil && v != "" {
lp.setMode(api.ChargeMode(v))
}
- if v, err := lp.settings.Int(keys.PhasesConfigured); err == nil && (v > 0 || lp.hasPhaseSwitching()) {
+ if v, err := lp.settings.Int(keys.Priority); err == nil && v > 0 {
+ lp.setPriority(int(v))
+ }
+ if v, err := lp.settings.Int(keys.Phases); err == nil && (v > 0 || lp.hasPhaseSwitching()) {
lp.setConfiguredPhases(int(v))
lp.phases = lp.configuredPhases
}
@@ -344,6 +356,16 @@ func (lp *Loadpoint) restoreSettings() {
lp.SetSmartCostLimit(&v)
}
+ var thresholds loadpoint.ThresholdsConfig
+ if err := lp.settings.Json(keys.Thresholds, &thresholds); err == nil {
+ lp.setThresholds(thresholds)
+ }
+
+ var socConfig loadpoint.SocConfig
+ if err := lp.settings.Json(keys.Soc, &socConfig); err == nil {
+ lp.setSocConfig(socConfig)
+ }
+
t, err1 := lp.settings.Time(keys.PlanTime)
v, err2 := lp.settings.Float(keys.PlanEnergy)
if err1 == nil && err2 == nil {
@@ -584,7 +606,7 @@ func (lp *Loadpoint) evChargeCurrentWrappedMeterHandler(current float64) {
// defaultMode executes the action
func (lp *Loadpoint) defaultMode() {
lp.RLock()
- mode := lp.Mode_
+ mode := lp.DefaultMode
lp.RUnlock()
if mode != "" && mode != lp.GetMode() {
@@ -610,7 +632,7 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
lp.restoreSettings()
// publish initial values
- lp.publish(keys.Title, lp.Title())
+ lp.publish(keys.Title, lp.GetTitle())
lp.publish(keys.Mode, lp.GetMode())
lp.publish(keys.Priority, lp.GetPriority())
lp.publish(keys.MinCurrent, lp.GetMinCurrent())
@@ -627,15 +649,17 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
}
lp.phases = phases
lp.configuredPhases = phases
- lp.publish(keys.ChargerPhysicalPhases, phases)
+ lp.publish(keys.Phases, phases)
} else {
- lp.publish(keys.ChargerPhysicalPhases, nil)
+ lp.publish(keys.Phases, nil)
}
lp.publish(keys.PhasesConfigured, lp.configuredPhases)
lp.publish(keys.ChargerPhases1p3p, lp.hasPhaseSwitching())
+ lp.publish(keys.ChargerSinglePhase, lp.getChargerPhysicalPhases() == 1)
lp.publish(keys.PhasesEnabled, lp.phases)
lp.publish(keys.PhasesActive, lp.ActivePhases())
+ lp.publish(keys.SmartCostLimit, lp.smartCostLimit)
lp.publishTimer(phaseTimer, 0, timerInactive)
lp.publishTimer(pvTimer, 0, timerInactive)
@@ -685,6 +709,11 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
lp.log.ERROR.Printf("charger enabled: %v", err)
}
+ // set vehicle polling mode
+ if lp.Soc.Poll.Mode != loadpoint.PollCharging {
+ lp.log.WARN.Printf("poll mode '%s' may deplete your battery or lead to API misuse. USE AT YOUR OWN RISK.", lp.Soc.Poll)
+ }
+
// allow charger to access loadpoint
if ctrl, ok := lp.charger.(loadpoint.Controller); ok {
ctrl.LoadpointControl(lp)
diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go
index e98511f1a..be1357286 100644
--- a/core/loadpoint/api.go
+++ b/core/loadpoint/api.go
@@ -22,12 +22,29 @@ type API interface {
// GetStatus returns the charging status
GetStatus() api.ChargeStatus
+ //
+ // references
+ //
+
+ // GetCharger returns the loadpoint charger
+ GetChargerName() string
+ // GetMeter returns the loadpoint meter
+ GetMeterName() string
+ // GetCircuitName returns the loadpoint circuit name
+ GetCircuitName() string
+ // GetCircuit returns the loadpoint circuit
+ GetCircuit() api.Circuit
+ // GetDefaultVehicle returns the loadpoint default vehicle
+ GetDefaultVehicle() string
+
//
// settings
//
- // Title returns the defined loadpoint title
- Title() string
+ // GetTitle returns the loadpoint title
+ GetTitle() string
+ // SetTitle sets the loadpoint title
+ SetTitle(string)
// GetPriority returns the priority
GetPriority() int
// SetPriority sets the priority
@@ -41,10 +58,14 @@ type API interface {
// SetMaxCurrent sets the max charging current
SetMaxCurrent(float64) error
- // GetMode returns the charge mode
+ // GetMode returns the current charge mode
GetMode() api.ChargeMode
// SetMode sets the charge mode
SetMode(api.ChargeMode)
+ // GetDefaultMode returns the default charge mode (for reset)
+ GetDefaultMode() api.ChargeMode
+ // SetDefaultMode sets the default charge mode (for reset)
+ SetDefaultMode(api.ChargeMode)
// GetPhases returns the enabled phases
GetPhases() int
// SetPhases sets the enabled phases
@@ -95,6 +116,15 @@ type API interface {
// GetPlan creates a charging plan
GetPlan(targetTime time.Time, requiredDuration time.Duration) (api.Rates, error)
+ // GetSocConfig returns the soc poll settings
+ GetSocConfig() SocConfig
+ // SetSocConfig sets the soc poll settings
+ SetSocConfig(soc SocConfig)
+
+ // GetThresholds returns the PV mode threshold settings
+ GetThresholds() ThresholdsConfig
+ // SetThresholds sets the PV mode threshold settings
+ SetThresholds(thresholds ThresholdsConfig)
// GetEnableThreshold gets the loadpoint enable threshold
GetEnableThreshold() float64
// SetEnableThreshold sets loadpoint enable threshold
@@ -164,7 +194,4 @@ type API interface {
SetVehicle(vehicle api.Vehicle)
// StartVehicleDetection allows triggering vehicle detection for debugging purposes
StartVehicleDetection()
-
- // GetCircuit gets the assigned circuit
- GetCircuit() api.Circuit
}
diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go
new file mode 100644
index 000000000..6834a222b
--- /dev/null
+++ b/core/loadpoint/config.go
@@ -0,0 +1,83 @@
+package loadpoint
+
+import (
+ "time"
+
+ "github.com/evcc-io/evcc/api"
+ "github.com/evcc-io/evcc/util"
+)
+
+type StaticConfig struct {
+ // static config
+ Charger string `json:"charger,omitempty"`
+ Meter string `json:"meter,omitempty"`
+ Circuit string `json:"circuit,omitempty"`
+ Vehicle string `json:"vehicle,omitempty"`
+}
+
+type DynamicConfig struct {
+ // dynamic config
+ Title string `json:"title"`
+ DefaultMode string `json:"defaultMode"`
+ Priority int `json:"priority"`
+ Phases int `json:"phases"`
+ MinCurrent float64 `json:"minCurrent"`
+ MaxCurrent float64 `json:"maxCurrent"`
+ SmartCostLimit *float64 `json:"smartCostLimit"`
+ PlanEnergy float64 `json:"planEnergy"`
+ PlanTime time.Time `json:"planTime"`
+ LimitEnergy float64 `json:"limitEnergy"`
+ LimitSoc int `json:"limitSoc"`
+
+ Thresholds ThresholdsConfig `json:"thresholds"`
+ Soc SocConfig `json:"soc"`
+}
+
+func SplitConfig(payload map[string]any) (DynamicConfig, map[string]any, error) {
+ // split static and dynamic config via mapstructure
+ var cc struct {
+ DynamicConfig `mapstructure:",squash"`
+ Other map[string]any `mapstructure:",remain"`
+ }
+
+ if err := util.DecodeOther(payload, &cc); err != nil {
+ return DynamicConfig{}, nil, err
+ }
+
+ // TODO: proper handling of id/name
+ delete(cc.Other, "id")
+ delete(cc.Other, "name")
+
+ return cc.DynamicConfig, cc.Other, nil
+}
+
+func (payload DynamicConfig) Apply(lp API) error {
+ lp.SetTitle(payload.Title)
+ lp.SetPriority(payload.Priority)
+ lp.SetSmartCostLimit(payload.SmartCostLimit)
+ lp.SetThresholds(payload.Thresholds)
+ lp.SetPlanEnergy(payload.PlanTime, payload.PlanEnergy)
+ lp.SetLimitEnergy(payload.LimitEnergy)
+ lp.SetLimitSoc(payload.LimitSoc)
+
+ // TODO mode warning
+ lp.SetSocConfig(payload.Soc)
+
+ mode, err := api.ChargeModeString(payload.DefaultMode)
+ if err == nil {
+ lp.SetDefaultMode(mode)
+ }
+
+ if err == nil {
+ err = lp.SetPhases(payload.Phases)
+ }
+
+ if err == nil && payload.MinCurrent != 0 {
+ err = lp.SetMinCurrent(payload.MinCurrent)
+ }
+ if err == nil && payload.MaxCurrent != 0 {
+ err = lp.SetMaxCurrent(payload.MaxCurrent)
+ }
+
+ return err
+}
diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go
index c50aade36..7664457d9 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -167,6 +167,20 @@ func (mr *MockAPIMockRecorder) GetChargePowerFlexibility() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChargePowerFlexibility", reflect.TypeOf((*MockAPI)(nil).GetChargePowerFlexibility))
}
+// GetChargerName mocks base method.
+func (m *MockAPI) GetChargerName() string {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetChargerName")
+ ret0, _ := ret[0].(string)
+ return ret0
+}
+
+// GetChargerName indicates an expected call of GetChargerName.
+func (mr *MockAPIMockRecorder) GetChargerName() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChargerName", reflect.TypeOf((*MockAPI)(nil).GetChargerName))
+}
+
// GetCircuit mocks base method.
func (m *MockAPI) GetCircuit() api.Circuit {
m.ctrl.T.Helper()
@@ -181,6 +195,48 @@ func (mr *MockAPIMockRecorder) GetCircuit() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCircuit", reflect.TypeOf((*MockAPI)(nil).GetCircuit))
}
+// GetCircuitName mocks base method.
+func (m *MockAPI) GetCircuitName() string {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetCircuitName")
+ ret0, _ := ret[0].(string)
+ return ret0
+}
+
+// GetCircuitName indicates an expected call of GetCircuitName.
+func (mr *MockAPIMockRecorder) GetCircuitName() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCircuitName", reflect.TypeOf((*MockAPI)(nil).GetCircuitName))
+}
+
+// GetDefaultMode mocks base method.
+func (m *MockAPI) GetDefaultMode() api.ChargeMode {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetDefaultMode")
+ ret0, _ := ret[0].(api.ChargeMode)
+ return ret0
+}
+
+// GetDefaultMode indicates an expected call of GetDefaultMode.
+func (mr *MockAPIMockRecorder) GetDefaultMode() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultMode", reflect.TypeOf((*MockAPI)(nil).GetDefaultMode))
+}
+
+// GetDefaultVehicle mocks base method.
+func (m *MockAPI) GetDefaultVehicle() string {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetDefaultVehicle")
+ ret0, _ := ret[0].(string)
+ return ret0
+}
+
+// GetDefaultVehicle indicates an expected call of GetDefaultVehicle.
+func (mr *MockAPIMockRecorder) GetDefaultVehicle() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultVehicle", reflect.TypeOf((*MockAPI)(nil).GetDefaultVehicle))
+}
+
// GetDisableDelay mocks base method.
func (m *MockAPI) GetDisableDelay() time.Duration {
m.ctrl.T.Helper()
@@ -293,6 +349,20 @@ func (mr *MockAPIMockRecorder) GetMaxPhaseCurrent() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxPhaseCurrent", reflect.TypeOf((*MockAPI)(nil).GetMaxPhaseCurrent))
}
+// GetMeterName mocks base method.
+func (m *MockAPI) GetMeterName() string {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetMeterName")
+ ret0, _ := ret[0].(string)
+ return ret0
+}
+
+// GetMeterName indicates an expected call of GetMeterName.
+func (mr *MockAPIMockRecorder) GetMeterName() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMeterName", reflect.TypeOf((*MockAPI)(nil).GetMeterName))
+}
+
// GetMinCurrent mocks base method.
func (m *MockAPI) GetMinCurrent() float64 {
m.ctrl.T.Helper()
@@ -450,6 +520,20 @@ func (mr *MockAPIMockRecorder) GetSmartCostLimit() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSmartCostLimit", reflect.TypeOf((*MockAPI)(nil).GetSmartCostLimit))
}
+// GetSocConfig mocks base method.
+func (m *MockAPI) GetSocConfig() SocConfig {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetSocConfig")
+ ret0, _ := ret[0].(SocConfig)
+ return ret0
+}
+
+// GetSocConfig indicates an expected call of GetSocConfig.
+func (mr *MockAPIMockRecorder) GetSocConfig() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSocConfig", reflect.TypeOf((*MockAPI)(nil).GetSocConfig))
+}
+
// GetStatus mocks base method.
func (m *MockAPI) GetStatus() api.ChargeStatus {
m.ctrl.T.Helper()
@@ -464,6 +548,34 @@ func (mr *MockAPIMockRecorder) GetStatus() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStatus", reflect.TypeOf((*MockAPI)(nil).GetStatus))
}
+// GetThresholds mocks base method.
+func (m *MockAPI) GetThresholds() ThresholdsConfig {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetThresholds")
+ ret0, _ := ret[0].(ThresholdsConfig)
+ return ret0
+}
+
+// GetThresholds indicates an expected call of GetThresholds.
+func (mr *MockAPIMockRecorder) GetThresholds() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetThresholds", reflect.TypeOf((*MockAPI)(nil).GetThresholds))
+}
+
+// GetTitle mocks base method.
+func (m *MockAPI) GetTitle() string {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetTitle")
+ ret0, _ := ret[0].(string)
+ return ret0
+}
+
+// GetTitle indicates an expected call of GetTitle.
+func (mr *MockAPIMockRecorder) GetTitle() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTitle", reflect.TypeOf((*MockAPI)(nil).GetTitle))
+}
+
// GetVehicle mocks base method.
func (m *MockAPI) GetVehicle() api.Vehicle {
m.ctrl.T.Helper()
@@ -544,6 +656,18 @@ func (mr *MockAPIMockRecorder) SetBatteryBoost(enable any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBatteryBoost", reflect.TypeOf((*MockAPI)(nil).SetBatteryBoost), enable)
}
+// SetDefaultMode mocks base method.
+func (m *MockAPI) SetDefaultMode(arg0 api.ChargeMode) {
+ m.ctrl.T.Helper()
+ m.ctrl.Call(m, "SetDefaultMode", arg0)
+}
+
+// SetDefaultMode indicates an expected call of SetDefaultMode.
+func (mr *MockAPIMockRecorder) SetDefaultMode(arg0 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDefaultMode", reflect.TypeOf((*MockAPI)(nil).SetDefaultMode), arg0)
+}
+
// SetDisableDelay mocks base method.
func (m *MockAPI) SetDisableDelay(delay time.Duration) {
m.ctrl.T.Helper()
@@ -708,6 +832,42 @@ func (mr *MockAPIMockRecorder) SetSmartCostLimit(limit any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetSmartCostLimit", reflect.TypeOf((*MockAPI)(nil).SetSmartCostLimit), limit)
}
+// SetSocConfig mocks base method.
+func (m *MockAPI) SetSocConfig(soc SocConfig) {
+ m.ctrl.T.Helper()
+ m.ctrl.Call(m, "SetSocConfig", soc)
+}
+
+// SetSocConfig indicates an expected call of SetSocConfig.
+func (mr *MockAPIMockRecorder) SetSocConfig(soc any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetSocConfig", reflect.TypeOf((*MockAPI)(nil).SetSocConfig), soc)
+}
+
+// SetThresholds mocks base method.
+func (m *MockAPI) SetThresholds(thresholds ThresholdsConfig) {
+ m.ctrl.T.Helper()
+ m.ctrl.Call(m, "SetThresholds", thresholds)
+}
+
+// SetThresholds indicates an expected call of SetThresholds.
+func (mr *MockAPIMockRecorder) SetThresholds(thresholds any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetThresholds", reflect.TypeOf((*MockAPI)(nil).SetThresholds), thresholds)
+}
+
+// SetTitle mocks base method.
+func (m *MockAPI) SetTitle(arg0 string) {
+ m.ctrl.T.Helper()
+ m.ctrl.Call(m, "SetTitle", arg0)
+}
+
+// SetTitle indicates an expected call of SetTitle.
+func (mr *MockAPIMockRecorder) SetTitle(arg0 any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTitle", reflect.TypeOf((*MockAPI)(nil).SetTitle), arg0)
+}
+
// SetVehicle mocks base method.
func (m *MockAPI) SetVehicle(vehicle api.Vehicle) {
m.ctrl.T.Helper()
@@ -745,17 +905,3 @@ func (mr *MockAPIMockRecorder) StartVehicleDetection() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartVehicleDetection", reflect.TypeOf((*MockAPI)(nil).StartVehicleDetection))
}
-
-// Title mocks base method.
-func (m *MockAPI) Title() string {
- m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "Title")
- ret0, _ := ret[0].(string)
- return ret0
-}
-
-// Title indicates an expected call of Title.
-func (mr *MockAPIMockRecorder) Title() *gomock.Call {
- mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Title", reflect.TypeOf((*MockAPI)(nil).Title))
-}
diff --git a/core/loadpoint/pollmode_enumer.go b/core/loadpoint/pollmode_enumer.go
new file mode 100644
index 000000000..6c3b30867
--- /dev/null
+++ b/core/loadpoint/pollmode_enumer.go
@@ -0,0 +1,94 @@
+// Code generated by "enumer -type PollMode -trimprefix Poll -transform=lower -text"; DO NOT EDIT.
+
+package loadpoint
+
+import (
+ "fmt"
+ "strings"
+)
+
+const _PollModeName = "chargingconnectedalways"
+
+var _PollModeIndex = [...]uint8{0, 8, 17, 23}
+
+const _PollModeLowerName = "chargingconnectedalways"
+
+func (i PollMode) String() string {
+ if i < 0 || i >= PollMode(len(_PollModeIndex)-1) {
+ return fmt.Sprintf("PollMode(%d)", i)
+ }
+ return _PollModeName[_PollModeIndex[i]:_PollModeIndex[i+1]]
+}
+
+// An "invalid array index" compiler error signifies that the constant values have changed.
+// Re-run the stringer command to generate them again.
+func _PollModeNoOp() {
+ var x [1]struct{}
+ _ = x[PollCharging-(0)]
+ _ = x[PollConnected-(1)]
+ _ = x[PollAlways-(2)]
+}
+
+var _PollModeValues = []PollMode{PollCharging, PollConnected, PollAlways}
+
+var _PollModeNameToValueMap = map[string]PollMode{
+ _PollModeName[0:8]: PollCharging,
+ _PollModeLowerName[0:8]: PollCharging,
+ _PollModeName[8:17]: PollConnected,
+ _PollModeLowerName[8:17]: PollConnected,
+ _PollModeName[17:23]: PollAlways,
+ _PollModeLowerName[17:23]: PollAlways,
+}
+
+var _PollModeNames = []string{
+ _PollModeName[0:8],
+ _PollModeName[8:17],
+ _PollModeName[17:23],
+}
+
+// PollModeString retrieves an enum value from the enum constants string name.
+// Throws an error if the param is not part of the enum.
+func PollModeString(s string) (PollMode, error) {
+ if val, ok := _PollModeNameToValueMap[s]; ok {
+ return val, nil
+ }
+
+ if val, ok := _PollModeNameToValueMap[strings.ToLower(s)]; ok {
+ return val, nil
+ }
+ return 0, fmt.Errorf("%s does not belong to PollMode values", s)
+}
+
+// PollModeValues returns all values of the enum
+func PollModeValues() []PollMode {
+ return _PollModeValues
+}
+
+// PollModeStrings returns a slice of all String values of the enum
+func PollModeStrings() []string {
+ strs := make([]string, len(_PollModeNames))
+ copy(strs, _PollModeNames)
+ return strs
+}
+
+// IsAPollMode returns "true" if the value is listed in the enum definition. "false" otherwise
+func (i PollMode) IsAPollMode() bool {
+ for _, v := range _PollModeValues {
+ if i == v {
+ return true
+ }
+ }
+ return false
+}
+
+// MarshalText implements the encoding.TextMarshaler interface for PollMode
+func (i PollMode) MarshalText() ([]byte, error) {
+ return []byte(i.String()), nil
+}
+
+// UnmarshalText implements the encoding.TextUnmarshaler interface for PollMode
+func (i *PollMode) UnmarshalText(text []byte) error {
+ var err error
+ *i, err = PollModeString(string(text))
+ return err
+}
diff --git a/core/loadpoint/types.go b/core/loadpoint/types.go
new file mode 100644
index 000000000..2e7b32b30
--- /dev/null
+++ b/core/loadpoint/types.go
@@ -0,0 +1,39 @@
+package loadpoint
+
+import (
+ "time"
+)
+
+// ThresholdsConfig defines pv mode hysteresis parameters
+type ThresholdsConfig struct {
+ Enable ThresholdConfig `json:"enable"`
+ Disable ThresholdConfig `json:"disable"`
+}
+
+// ThresholdConfig defines enable/disable hysteresis parameters
+type ThresholdConfig struct {
+ Delay time.Duration `json:"delay"`
+ Threshold float64 `json:"threshold"`
+}
+
+// SocConfig defines soc settings, estimation and update behavior
+type SocConfig struct {
+ Poll PollConfig `json:"poll"`
+ Estimate *bool `json:"estimate"`
+}
+
+// PollConfig defines the vehicle polling mode and interval
+type PollConfig struct {
+ Mode PollMode `json:"mode"` // polling mode charging (default), connected, always
+ Interval time.Duration `json:"interval"` // interval when not charging
+}
+
+//go:generate enumer -type PollMode -trimprefix Poll -transform=lower -text
+type PollMode int
+
+// Poll modes
+const (
+ PollCharging PollMode = iota
+ PollConnected
+ PollAlways
+)
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index a37429df9..f9158bbbf 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -13,11 +13,50 @@ import (
var _ loadpoint.API = (*Loadpoint)(nil)
-// Title returns the human-readable loadpoint title
-func (lp *Loadpoint) Title() string {
+// GetCharger returns the loadpoint charger
+func (lp *Loadpoint) GetChargerName() string {
+ return lp.ChargerRef
+}
+
+// GetMeter returns the loadpoint meter
+func (lp *Loadpoint) GetMeterName() string {
+ return lp.MeterRef
+}
+
+// GetCircuitName returns the loadpoint circuit
+func (lp *Loadpoint) GetCircuitName() string {
+ return lp.CircuitRef
+}
+
+// GetDefaultVehicle returns the loadpoint default vehicle
+func (lp *Loadpoint) GetDefaultVehicle() string {
+ return lp.VehicleRef
+}
+
+// GetTitle returns the loadpoint title
+func (lp *Loadpoint) GetTitle() string {
lp.RLock()
defer lp.RUnlock()
- return lp.Title_
+ return lp.title
+}
+
+// SetTitle sets the loadpoint title
+func (lp *Loadpoint) SetTitle(title string) {
+ lp.Lock()
+ defer lp.Unlock()
+
+ lp.log.DEBUG.Println("set title:", title)
+
+ if title != lp.title {
+ lp.setTitle(title)
+ }
+}
+
+// setTitle sets the loadpoint title (no mutex)
+func (lp *Loadpoint) setTitle(title string) {
+ lp.title = title
+ lp.publish(keys.Title, lp.title)
+ lp.settings.SetString(keys.Title, lp.title)
}
// GetStatus returns the charging status
@@ -74,6 +113,26 @@ func (lp *Loadpoint) SetMode(mode api.ChargeMode) {
}
}
+// GetDefaultMode returns the default charge mode
+func (lp *Loadpoint) GetDefaultMode() api.ChargeMode {
+ lp.RLock()
+ defer lp.RUnlock()
+ return lp.DefaultMode
+}
+
+// SetDefaultMode sets the default charge mode
+func (lp *Loadpoint) SetDefaultMode(mode api.ChargeMode) {
+ lp.Lock()
+ defer lp.Unlock()
+
+ lp.log.DEBUG.Println("set default mode:", mode)
+
+ if lp.DefaultMode != mode {
+ lp.DefaultMode = mode
+ lp.settings.SetString(keys.DefaultMode, string(mode))
+ }
+}
+
// getChargedEnergy returns session charge energy in Wh
func (lp *Loadpoint) getChargedEnergy() float64 {
lp.RLock()
@@ -85,7 +144,14 @@ func (lp *Loadpoint) getChargedEnergy() float64 {
func (lp *Loadpoint) GetPriority() int {
lp.RLock()
defer lp.RUnlock()
- return lp.Priority_
+ return lp.priority
+}
+
+// setPriority sets the loadpoint priority (no mutex)
+func (lp *Loadpoint) setPriority(prio int) {
+ lp.priority = prio
+ lp.publish(keys.Priority, lp.priority)
+ lp.settings.SetInt(keys.Priority, int64(lp.priority))
}
// SetPriority sets the loadpoint priority
@@ -96,8 +162,7 @@ func (lp *Loadpoint) SetPriority(prio int) {
lp.log.DEBUG.Println("set priority:", prio)
if lp.Priority_ != prio {
- lp.Priority_ = prio
- lp.publish(keys.Priority, prio)
+ lp.setPriority(prio)
}
}
@@ -112,7 +177,7 @@ func (lp *Loadpoint) GetPhases() int {
func (lp *Loadpoint) SetPhases(phases int) error {
// limit auto mode (phases=0) to scalable charger
if !lp.hasPhaseSwitching() && phases == 0 {
- return fmt.Errorf("invalid number of phases: %d", phases)
+ return fmt.Errorf("charger does not support phase switching")
}
if phases != 0 && phases != 1 && phases != 3 {
@@ -239,6 +304,60 @@ func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error {
return nil
}
+// GetSoc returns the PV mode threshold settings
+func (lp *Loadpoint) GetSocConfig() loadpoint.SocConfig {
+ lp.RLock()
+ defer lp.RUnlock()
+ return lp.Soc
+}
+
+func (lp *Loadpoint) setSocConfig(soc loadpoint.SocConfig) {
+ lp.Soc = soc
+ lp.settings.SetJson(keys.Soc, soc)
+ lp.requestUpdate()
+}
+
+// SetSoc sets the PV mode threshold settings
+func (lp *Loadpoint) SetSocConfig(soc loadpoint.SocConfig) {
+ lp.Lock()
+ defer lp.Unlock()
+
+ lp.log.DEBUG.Printf("set soc config: %+v", soc)
+
+ // apply immediately
+ lp.setSocConfig(soc)
+}
+
+// GetThresholds returns the PV mode threshold settings
+func (lp *Loadpoint) GetThresholds() loadpoint.ThresholdsConfig {
+ lp.RLock()
+ defer lp.RUnlock()
+ return loadpoint.ThresholdsConfig{
+ Enable: lp.Enable,
+ Disable: lp.Disable,
+ }
+}
+
+func (lp *Loadpoint) setThresholds(thresholds loadpoint.ThresholdsConfig) {
+ lp.Enable = thresholds.Enable
+ lp.Disable = thresholds.Disable
+ lp.publish(keys.EnableThreshold, lp.Enable.Threshold)
+ lp.publish(keys.DisableThreshold, lp.Disable.Threshold)
+ lp.settings.SetJson(keys.Thresholds, thresholds)
+ lp.requestUpdate()
+}
+
+// SetThresholds sets the PV mode threshold settings
+func (lp *Loadpoint) SetThresholds(thresholds loadpoint.ThresholdsConfig) {
+ lp.Lock()
+ defer lp.Unlock()
+
+ lp.log.DEBUG.Printf("set thresholds: %+v", thresholds)
+
+ // apply immediately
+ lp.setThresholds(thresholds)
+}
+
// GetEnableThreshold gets the loadpoint enable threshold
func (lp *Loadpoint) GetEnableThreshold() float64 {
lp.RLock()
@@ -255,7 +374,11 @@ func (lp *Loadpoint) SetEnableThreshold(threshold float64) {
if lp.Enable.Threshold != threshold {
lp.Enable.Threshold = threshold
- lp.publish(keys.EnableThreshold, threshold)
+ // TODO reduce APIs
+ lp.setThresholds(loadpoint.ThresholdsConfig{
+ Enable: lp.Enable,
+ Disable: lp.Disable,
+ })
}
}
@@ -275,7 +398,11 @@ func (lp *Loadpoint) SetDisableThreshold(threshold float64) {
if lp.Disable.Threshold != threshold {
lp.Disable.Threshold = threshold
- lp.publish(keys.DisableThreshold, threshold)
+ // TODO reduce APIs
+ lp.setThresholds(loadpoint.ThresholdsConfig{
+ Enable: lp.Enable,
+ Disable: lp.Disable,
+ })
}
}
diff --git a/core/loadpoint_phases.go b/core/loadpoint_phases.go
index fc8d3f465..ead795812 100644
--- a/core/loadpoint_phases.go
+++ b/core/loadpoint_phases.go
@@ -8,8 +8,9 @@ import (
// setConfiguredPhases sets the default phase configuration
func (lp *Loadpoint) setConfiguredPhases(phases int) {
lp.configuredPhases = phases
- lp.publish(keys.PhasesConfigured, lp.configuredPhases)
- lp.settings.SetInt(keys.PhasesConfigured, int64(lp.configuredPhases))
+ lp.publish(keys.Phases, lp.configuredPhases)
+ lp.publish(keys.PhasesConfigured, lp.configuredPhases) // TODO remove
+ lp.settings.SetInt(keys.Phases, int64(lp.configuredPhases))
}
// setPhases sets the number of enabled phases without modifying the charger
diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go
index 2faca534a..4dbb6b02f 100644
--- a/core/loadpoint_phases_test.go
+++ b/core/loadpoint_phases_test.go
@@ -8,6 +8,7 @@ import (
evbus "github.com/asaskevich/EventBus"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
+ "github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
@@ -405,10 +406,10 @@ func TestPvScalePhasesTimer(t *testing.T) {
phases: tc.phases,
measuredPhases: tc.measuredPhases,
status: api.StatusC,
- Enable: ThresholdConfig{
+ Enable: loadpoint.ThresholdConfig{
Delay: dt,
},
- Disable: ThresholdConfig{
+ Disable: loadpoint.ThresholdConfig{
Delay: dt,
},
}
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index c9d2d7594..76b26ca5a 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -7,6 +7,8 @@ import (
evbus "github.com/asaskevich/EventBus"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
+ "github.com/evcc-io/evcc/core/loadpoint"
+ "github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/util"
@@ -151,14 +153,14 @@ func TestUpdatePowerZero(t *testing.T) {
for _, tc := range tc {
t.Log(tc)
- clck := clock.NewMock()
+ clock := clock.NewMock()
ctrl := gomock.NewController(t)
charger := api.NewMockCharger(ctrl)
lp := &Loadpoint{
log: util.NewLogger("foo"),
bus: evbus.New(),
- clock: clck,
+ clock: clock,
charger: charger,
chargeMeter: &Null{}, // silence nil panics
chargeRater: &Null{}, // silence nil panics
@@ -302,24 +304,24 @@ func TestPVHysteresis(t *testing.T) {
for _, tc := range tc {
t.Log(tc)
- clck := clock.NewMock()
+ clock := clock.NewMock()
ctrl := gomock.NewController(t)
charger := api.NewMockCharger(ctrl)
Voltage = 100
lp := &Loadpoint{
log: util.NewLogger("foo"),
- clock: clck,
+ clock: clock,
charger: charger,
minCurrent: minA,
maxCurrent: maxA,
phases: phases,
measuredPhases: phases,
- Enable: ThresholdConfig{
+ Enable: loadpoint.ThresholdConfig{
Threshold: tc.enable,
Delay: dt,
},
- Disable: ThresholdConfig{
+ Disable: loadpoint.ThresholdConfig{
Threshold: tc.disable,
Delay: dt,
},
@@ -328,10 +330,10 @@ func TestPVHysteresis(t *testing.T) {
// charging, otherwise PV mode logic is short-circuited
lp.status = status
- start := clck.Now()
+ start := clock.Now()
for step, se := range tc.series {
- clck.Set(start.Add(se.delay))
+ clock.Set(start.Add(se.delay))
// maxCurrent will read actual current and enabled state in PV mode
// charger.EXPECT().Enabled().Return(tc.enabled, nil)
@@ -352,13 +354,13 @@ func TestPVHysteresis(t *testing.T) {
func TestPVHysteresisForStatusOtherThanC(t *testing.T) {
const phases = 3
- clck := clock.NewMock()
+ clock := clock.NewMock()
ctrl := gomock.NewController(t)
Voltage = 100
lp := &Loadpoint{
log: util.NewLogger("foo"),
- clock: clck,
+ clock: clock,
minCurrent: minA,
maxCurrent: maxA,
phases: phases,
@@ -407,9 +409,9 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
socEstimator: socEstimator, // instead of vehicle: vehicle,
mode: api.ModeNow,
limitSoc: 90, // session limit
- Soc: SocConfig{
- Poll: PollConfig{
- Mode: pollConnected, // allow polling when connected
+ Soc: loadpoint.SocConfig{
+ Poll: loadpoint.PollConfig{
+ Mode: loadpoint.PollConnected, // allow polling when connected
Interval: pollInterval,
},
},
@@ -473,6 +475,7 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
log: util.NewLogger("foo"),
bus: evbus.New(),
clock: clock,
+ settings: settings.NewDatabaseSettingsAdapter("foo"),
charger: charger,
chargeMeter: &Null{}, // silence nil panics
chargeRater: &Null{}, // silence nil panics
@@ -481,7 +484,7 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
minCurrent: minA,
maxCurrent: maxA,
status: api.StatusC,
- Mode_: api.ModeOff, // default mode
+ DefaultMode: api.ModeOff, // default mode
}
attachListeners(t, lp)
@@ -616,59 +619,59 @@ func TestSocPoll(t *testing.T) {
lp := &Loadpoint{
clock: clock,
log: util.NewLogger("foo"),
- Soc: SocConfig{
- Poll: PollConfig{
+ Soc: loadpoint.SocConfig{
+ Poll: loadpoint.PollConfig{
Interval: time.Hour,
},
},
}
tc := []struct {
- mode string
+ mode loadpoint.PollMode
status api.ChargeStatus
dt time.Duration
res bool
}{
// pollCharging
- {pollCharging, api.StatusA, -1, false},
- {pollCharging, api.StatusA, 0, false},
- {pollCharging, api.StatusA, tRefresh, false},
- {pollCharging, api.StatusB, -1, true}, // poll once when car connected
- {pollCharging, api.StatusB, 0, false},
- {pollCharging, api.StatusB, tRefresh, false},
- {pollCharging, api.StatusC, -1, true},
- {pollCharging, api.StatusC, 0, true},
- {pollCharging, api.StatusC, tNoRefresh, true}, // cached by vehicle
- {pollCharging, api.StatusB, -1, true}, // fetch if car stopped charging
- {pollCharging, api.StatusB, 0, false}, // no more polling
- {pollCharging, api.StatusB, tRefresh, false}, // no more polling
+ {loadpoint.PollCharging, api.StatusA, -1, false},
+ {loadpoint.PollCharging, api.StatusA, 0, false},
+ {loadpoint.PollCharging, api.StatusA, tRefresh, false},
+ {loadpoint.PollCharging, api.StatusB, -1, true}, // poll once when car connected
+ {loadpoint.PollCharging, api.StatusB, 0, false},
+ {loadpoint.PollCharging, api.StatusB, tRefresh, false},
+ {loadpoint.PollCharging, api.StatusC, -1, true},
+ {loadpoint.PollCharging, api.StatusC, 0, true},
+ {loadpoint.PollCharging, api.StatusC, tNoRefresh, true}, // cached by vehicle
+ {loadpoint.PollCharging, api.StatusB, -1, true}, // fetch if car stopped charging
+ {loadpoint.PollCharging, api.StatusB, 0, false}, // no more polling
+ {loadpoint.PollCharging, api.StatusB, tRefresh, false}, // no more polling
// pollConnected
- {pollConnected, api.StatusA, -1, false},
- {pollConnected, api.StatusA, 0, false},
- {pollConnected, api.StatusA, tRefresh, false},
- {pollConnected, api.StatusB, -1, true},
- {pollConnected, api.StatusB, 0, false},
- {pollConnected, api.StatusB, tNoRefresh, false},
- {pollConnected, api.StatusB, tRefresh, true},
- {pollConnected, api.StatusC, -1, true},
- {pollConnected, api.StatusC, 0, true},
- {pollConnected, api.StatusC, tNoRefresh, true}, // cached by vehicle
- {pollConnected, api.StatusC, tRefresh, true},
+ {loadpoint.PollConnected, api.StatusA, -1, false},
+ {loadpoint.PollConnected, api.StatusA, 0, false},
+ {loadpoint.PollConnected, api.StatusA, tRefresh, false},
+ {loadpoint.PollConnected, api.StatusB, -1, true},
+ {loadpoint.PollConnected, api.StatusB, 0, false},
+ {loadpoint.PollConnected, api.StatusB, tNoRefresh, false},
+ {loadpoint.PollConnected, api.StatusB, tRefresh, true},
+ {loadpoint.PollConnected, api.StatusC, -1, true},
+ {loadpoint.PollConnected, api.StatusC, 0, true},
+ {loadpoint.PollConnected, api.StatusC, tNoRefresh, true}, // cached by vehicle
+ {loadpoint.PollConnected, api.StatusC, tRefresh, true},
// pollAlways
- {pollAlways, api.StatusA, -1, true},
- {pollAlways, api.StatusA, 0, false},
- {pollAlways, api.StatusA, tNoRefresh, false},
- {pollAlways, api.StatusA, tRefresh, true},
- {pollAlways, api.StatusB, -1, true},
- {pollAlways, api.StatusB, 0, false},
- {pollAlways, api.StatusB, tNoRefresh, false},
- {pollAlways, api.StatusB, tRefresh, true},
- {pollAlways, api.StatusC, -1, true},
- {pollAlways, api.StatusC, 0, true},
- {pollAlways, api.StatusC, tNoRefresh, true}, // cached by vehicle
- {pollAlways, api.StatusC, tRefresh, true},
+ {loadpoint.PollAlways, api.StatusA, -1, true},
+ {loadpoint.PollAlways, api.StatusA, 0, false},
+ {loadpoint.PollAlways, api.StatusA, tNoRefresh, false},
+ {loadpoint.PollAlways, api.StatusA, tRefresh, true},
+ {loadpoint.PollAlways, api.StatusB, -1, true},
+ {loadpoint.PollAlways, api.StatusB, 0, false},
+ {loadpoint.PollAlways, api.StatusB, tNoRefresh, false},
+ {loadpoint.PollAlways, api.StatusB, tRefresh, true},
+ {loadpoint.PollAlways, api.StatusC, -1, true},
+ {loadpoint.PollAlways, api.StatusC, 0, true},
+ {loadpoint.PollAlways, api.StatusC, tNoRefresh, true}, // cached by vehicle
+ {loadpoint.PollAlways, api.StatusC, tRefresh, true},
}
for _, tc := range tc {
@@ -723,7 +726,7 @@ func TestPVHysteresisAfterPhaseSwitch(t *testing.T) {
for _, tc := range tc {
t.Log(tc)
- clck := clock.NewMock()
+ clock := clock.NewMock()
ctrl := gomock.NewController(t)
switcher := api.NewMockPhaseSwitcher(ctrl)
@@ -739,21 +742,21 @@ func TestPVHysteresisAfterPhaseSwitch(t *testing.T) {
Voltage = 100
lp := &Loadpoint{
log: util.NewLogger("foo"),
- clock: clck,
+ clock: clock,
charger: charger,
minCurrent: minA,
maxCurrent: maxA,
- Disable: ThresholdConfig{
+ Disable: loadpoint.ThresholdConfig{
Delay: dt,
},
status: api.StatusC,
enabled: true,
}
- start := clck.Now()
+ start := clock.Now()
for step, se := range tc.series {
- clck.Set(start.Add(se.delay))
+ clock.Set(start.Add(se.delay))
assert.Equal(t, se.current, lp.pvMaxCurrent(api.ModePV, se.site, 0, false, false), step)
}
diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go
index e7022d472..d4c2df0dd 100644
--- a/core/loadpoint_vehicle.go
+++ b/core/loadpoint_vehicle.go
@@ -314,9 +314,9 @@ func (lp *Loadpoint) vehicleOdometer() {
// vehicleClimatePollAllowed determines if polling depending on mode and connection status
func (lp *Loadpoint) vehicleClimatePollAllowed() bool {
switch {
- case lp.Soc.Poll.Mode == pollCharging && lp.charging():
+ case lp.Soc.Poll.Mode == loadpoint.PollCharging && lp.charging():
return true
- case (lp.Soc.Poll.Mode == pollConnected || lp.Soc.Poll.Mode == pollAlways) && lp.connected():
+ case (lp.Soc.Poll.Mode == loadpoint.PollConnected || lp.Soc.Poll.Mode == loadpoint.PollAlways) && lp.connected():
return true
default:
return false
@@ -337,8 +337,8 @@ func (lp *Loadpoint) vehicleSocPollAllowed() bool {
remaining := lp.Soc.Poll.Interval - lp.clock.Since(lp.socUpdated)
- honourUpdateInterval := lp.Soc.Poll.Mode == pollAlways ||
- lp.connected() && lp.Soc.Poll.Mode == pollConnected
+ honourUpdateInterval := lp.Soc.Poll.Mode == loadpoint.PollAlways ||
+ lp.connected() && lp.Soc.Poll.Mode == loadpoint.PollConnected
if honourUpdateInterval {
if remaining > 0 {
diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go
index ebe3bbb2b..4c68145e3 100644
--- a/core/loadpoint_vehicle_test.go
+++ b/core/loadpoint_vehicle_test.go
@@ -9,6 +9,7 @@ import (
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/coordinator"
+ "github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
@@ -171,8 +172,8 @@ func TestDefaultVehicle(t *testing.T) {
vehicle.EXPECT().Phases().AnyTimes()
vehicle.EXPECT().OnIdentified().AnyTimes()
- lp := NewLoadpoint(util.NewLogger("foo"), nil)
- lp.Mode_ = api.ModeOff // ondisconnect
+ lp := NewLoadpoint(util.NewLogger("foo"), settings.NewDatabaseSettingsAdapter("foo"))
+ lp.DefaultMode = api.ModeOff // ondisconnect
lp.defaultVehicle = dflt
// populate channels
diff --git a/core/prioritizer/prioritizer.go b/core/prioritizer/prioritizer.go
index 39c90b8bf..ff47cd5d9 100644
--- a/core/prioritizer/prioritizer.go
+++ b/core/prioritizer/prioritizer.go
@@ -40,12 +40,12 @@ func (p *Prioritizer) GetChargePowerFlexibility(lp loadpoint.API) float64 {
for lp, power := range p.demand {
if lp.EffectivePriority() < prio && power > 0 {
reduceBy += power
- msg += fmt.Sprintf("%.0fW from %s at prio %d, ", power, lp.Title(), lp.EffectivePriority())
+ msg += fmt.Sprintf("%.0fW from %s at prio %d, ", power, lp.GetTitle(), lp.EffectivePriority())
}
}
if p.log != nil && reduceBy > 0 {
- p.log.DEBUG.Printf("lp %s at prio %d gets additional %stotal %.0fW\n", lp.Title(), lp.EffectivePriority(), msg, reduceBy)
+ p.log.DEBUG.Printf("lp %s at prio %d gets additional %stotal %.0fW\n", lp.GetTitle(), lp.EffectivePriority(), msg, reduceBy)
}
return reduceBy
diff --git a/core/prioritizer/prioritizer_test.go b/core/prioritizer/prioritizer_test.go
index 870b3aba4..bd36e49de 100644
--- a/core/prioritizer/prioritizer_test.go
+++ b/core/prioritizer/prioritizer_test.go
@@ -14,12 +14,12 @@ func TestPrioritzer(t *testing.T) {
p := New(nil)
lo := loadpoint.NewMockAPI(ctrl)
- lo.EXPECT().Title().AnyTimes()
+ lo.EXPECT().GetTitle().AnyTimes()
lo.EXPECT().GetPriority().Return(0).AnyTimes() // prio 0
lo.EXPECT().EffectivePriority().Return(0).AnyTimes() // prio 0
hi := loadpoint.NewMockAPI(ctrl)
- hi.EXPECT().Title().AnyTimes()
+ hi.EXPECT().GetTitle().AnyTimes()
hi.EXPECT().GetPriority().Return(1).AnyTimes() // prio 1
hi.EXPECT().EffectivePriority().Return(1).AnyTimes() // prio 1
diff --git a/core/settings.go b/core/settings.go
deleted file mode 100644
index a13e50433..000000000
--- a/core/settings.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package core
-
-import (
- "time"
-
- "github.com/evcc-io/evcc/server/db/settings"
-)
-
-// type Settings interface {
-// SetString(val string)
-// SetInt(val int64)
-// SetFloat(val float64)
-// SetTime(val time.Time)
-// SetJson(val any) error
-// SetBool(val bool)
-// String(string, error)
-// Int(int64, error)
-// Float(float64, error)
-// Time(time.Time, error)
-// Bool(bool, error)
-// Json(res any) error
-// }
-
-type Settings struct {
- Key string
-}
-
-func (s *Settings) SetString(key string, val string) {
- if s == nil {
- return
- }
- settings.SetString(s.Key+key, val)
-}
-
-func (s *Settings) SetInt(key string, val int64) {
- if s == nil {
- return
- }
- settings.SetInt(s.Key+key, val)
-}
-
-func (s *Settings) SetFloat(key string, val float64) {
- if s == nil {
- return
- }
- settings.SetFloat(s.Key+key, val)
-}
-
-func (s *Settings) SetTime(key string, val time.Time) {
- if s == nil {
- return
- }
- settings.SetTime(s.Key+key, val)
-}
-
-func (s *Settings) SetBool(key string, val bool) {
- if s == nil {
- return
- }
- settings.SetBool(s.Key+key, val)
-}
-
-// func (s *Settings) SetJson(key string, val any) error {
-// if s == nil {
-// return nil
-// }
-// return settings.SetJson(s.Key+key, val)
-// }
-
-func (s *Settings) String(key string) (string, error) {
- if s == nil {
- return "", nil
- }
- return settings.String(s.Key + key)
-}
-
-func (s *Settings) Int(key string) (int64, error) {
- if s == nil {
- return 0, nil
- }
- return settings.Int(s.Key + key)
-}
-
-func (s *Settings) Float(key string) (float64, error) {
- if s == nil {
- return 0, nil
- }
- return settings.Float(s.Key + key)
-}
-
-func (s *Settings) Time(key string) (time.Time, error) {
- if s == nil {
- return time.Time{}, nil
- }
- return settings.Time(s.Key + key)
-}
-
-func (s *Settings) Bool(key string) (bool, error) {
- if s == nil {
- return false, nil
- }
- return settings.Bool(s.Key + key)
-}
-
-// func (s *Settings) Json(key string, res any) error {
-// return settings.Json(s.Key+key, &res)
-// }
diff --git a/core/settings/config.go b/core/settings/config.go
new file mode 100644
index 000000000..9b7bd5ae0
--- /dev/null
+++ b/core/settings/config.go
@@ -0,0 +1,111 @@
+package settings
+
+import (
+ "encoding/json"
+ "errors"
+ "time"
+
+ "github.com/evcc-io/evcc/util"
+ "github.com/evcc-io/evcc/util/config"
+ "github.com/spf13/cast"
+)
+
+var _ Settings = (*ConfigSettings)(nil)
+
+type ConfigSettings struct {
+ log *util.Logger
+ conf *config.Config
+}
+
+func NewConfigSettingsAdapter(log *util.Logger, conf *config.Config) *ConfigSettings {
+ return &ConfigSettings{log, conf}
+}
+
+func (s *ConfigSettings) get(key string) (any, error) {
+ val := s.conf.Named().Other[key]
+ if val == nil {
+ return nil, errors.New("not found")
+ }
+ return val, nil
+}
+
+func (s *ConfigSettings) set(key string, val any) {
+ data := s.conf.Named().Other
+ data[key] = val
+ if err := s.conf.PartialUpdate(data); err != nil {
+ s.log.ERROR.Println(err)
+ }
+}
+
+func (s *ConfigSettings) SetString(key string, val string) {
+ s.set(key, val)
+}
+
+func (s *ConfigSettings) SetInt(key string, val int64) {
+ s.set(key, val)
+}
+
+func (s *ConfigSettings) SetFloat(key string, val float64) {
+ s.set(key, val)
+}
+
+func (s *ConfigSettings) SetTime(key string, val time.Time) {
+ s.set(key, val)
+}
+
+func (s *ConfigSettings) SetBool(key string, val bool) {
+ s.set(key, val)
+}
+
+func (s *ConfigSettings) SetJson(key string, val any) error {
+ s.set(key, val)
+ return nil
+}
+
+func (s *ConfigSettings) String(key string) (string, error) {
+ val, err := s.get(key)
+ if err != nil {
+ return "", err
+ }
+ return cast.ToStringE(val)
+}
+
+func (s *ConfigSettings) Int(key string) (int64, error) {
+ val, err := s.get(key)
+ if err != nil {
+ return 0, err
+ }
+ return cast.ToInt64E(val)
+}
+
+func (s *ConfigSettings) Float(key string) (float64, error) {
+ val, err := s.get(key)
+ if err != nil {
+ return 0, err
+ }
+ return cast.ToFloat64E(val)
+}
+
+func (s *ConfigSettings) Time(key string) (time.Time, error) {
+ val, err := s.get(key)
+ if err != nil {
+ return time.Time{}, err
+ }
+ return cast.ToTimeE(val)
+}
+
+func (s *ConfigSettings) Bool(key string) (bool, error) {
+ val, err := s.get(key)
+ if err != nil {
+ return false, err
+ }
+ return cast.ToBoolE(val)
+}
+
+func (s *ConfigSettings) Json(key string, res any) error {
+ str, err := s.String(key)
+ if str == "" || err != nil {
+ return err
+ }
+ return json.Unmarshal([]byte(str), &res)
+}
diff --git a/core/settings/database.go b/core/settings/database.go
new file mode 100644
index 000000000..22331c815
--- /dev/null
+++ b/core/settings/database.go
@@ -0,0 +1,65 @@
+package settings
+
+import (
+ "time"
+
+ db "github.com/evcc-io/evcc/server/db/settings"
+)
+
+var _ Settings = (*dbSettings)(nil)
+
+type dbSettings struct {
+ Key string
+}
+
+func NewDatabaseSettingsAdapter(key string) Settings {
+ return &dbSettings{key}
+}
+
+func (s *dbSettings) SetString(key string, val string) {
+ db.SetString(s.Key+key, val)
+}
+
+func (s *dbSettings) SetInt(key string, val int64) {
+ db.SetInt(s.Key+key, val)
+}
+
+func (s *dbSettings) SetFloat(key string, val float64) {
+ db.SetFloat(s.Key+key, val)
+}
+
+func (s *dbSettings) SetTime(key string, val time.Time) {
+ db.SetTime(s.Key+key, val)
+}
+
+func (s *dbSettings) SetBool(key string, val bool) {
+ db.SetBool(s.Key+key, val)
+}
+
+func (s *dbSettings) SetJson(key string, val any) error {
+ return db.SetJson(s.Key+key, val)
+}
+
+func (s *dbSettings) String(key string) (string, error) {
+ return db.String(s.Key + key)
+}
+
+func (s *dbSettings) Int(key string) (int64, error) {
+ return db.Int(s.Key + key)
+}
+
+func (s *dbSettings) Float(key string) (float64, error) {
+ return db.Float(s.Key + key)
+}
+
+func (s *dbSettings) Time(key string) (time.Time, error) {
+ return db.Time(s.Key + key)
+}
+
+func (s *dbSettings) Bool(key string) (bool, error) {
+ return db.Bool(s.Key + key)
+}
+
+func (s *dbSettings) Json(key string, res any) error {
+ return db.Json(s.Key+key, &res)
+}
diff --git a/core/settings/settings.go b/core/settings/settings.go
new file mode 100644
index 000000000..a7701e650
--- /dev/null
+++ b/core/settings/settings.go
@@ -0,0 +1,18 @@
+package settings
+
+import "time"
+
+type Settings interface {
+ SetString(key string, val string)
+ SetInt(key string, val int64)
+ SetFloat(key string, val float64)
+ SetTime(key string, val time.Time)
+ SetJson(key string, val any) error
+ SetBool(key string, val bool)
+ String(key string) (string, error)
+ Int(key string) (int64, error)
+ Float(key string) (float64, error)
+ Time(key string) (time.Time, error)
+ Bool(key string) (bool, error)
+ Json(key string, res any) error
+}
diff --git a/core/site.go b/core/site.go
index 3ed034f1f..0fa446da0 100644
--- a/core/site.go
+++ b/core/site.go
@@ -162,7 +162,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
if db.Instance != nil {
var err error
- if lp.db, err = session.NewStore(lp.Title(), db.Instance); err != nil {
+ if lp.db, err = session.NewStore(lp.GetTitle(), db.Instance); err != nil {
return err
}
// Fix any dangling history
diff --git a/hems/semp/semp.go b/hems/semp/semp.go
index 5445a6059..fdd30d2a1 100644
--- a/hems/semp/semp.go
+++ b/hems/semp/semp.go
@@ -375,7 +375,7 @@ func (s *SEMP) deviceInfo(id int, lp loadpoint.API) DeviceInfo {
res := DeviceInfo{
Identification: Identification{
DeviceID: s.deviceID(id),
- DeviceName: lp.Title(),
+ DeviceName: lp.GetTitle(),
DeviceType: sempCharger,
DeviceSerial: s.serialNumber(id),
DeviceVendor: "github.com/evcc-io/evcc",
diff --git a/i18n/de.toml b/i18n/de.toml
index 7b6147645..1de152487 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -27,6 +27,20 @@ never = "nur mit genug PV-Ăśberschuss."
titleAdd = "Batterie hinzufĂĽgen"
titleEdit = "Batterie bearbeiten"
+[config.charge]
+titleAdd = "Energiezähler hinzufügen"
+titleEdit = "Energiezähler bearbeiten"
+
+[config.charger]
+chargers = "Wallboxen"
+generic = "Generische Integrationen"
+ocppHelp = "Kopiere diese Adresse in die Konfiguration der Wallbox."
+ocppLabel = "OCPP-Server URL"
+switchsockets = "Schaltbare Steckdosen"
+template = "Hersteller"
+titleAdd = "Wallbox hinzufĂĽgen"
+titleEdit = "Wallbox bearbeiten"
+
[config.circuits]
description = "Stellt sicher, dass die Summe aller Ladepunkte, die an einen Stromkreis angeschlossen sind, die konfigurierten Leistungs- und Stromgrenzen nicht überschreitet. Stromkreise können verschachtelt werden, um eine Hierarchie aufzubauen."
title = "Lastmanagement"
@@ -48,8 +62,6 @@ chargeStatus = "Status"
chargeStatusA = "nicht verbunden"
chargeStatusB = "verbunden"
chargeStatusC = "lädt"
-chargeStatusE = "keine Leistung"
-chargeStatusF = "Fehler"
chargedEnergy = "Geladen"
co2 = "Netz-COâ‚‚"
configured = "Konfiguriert"
@@ -68,9 +80,11 @@ org = "Organisation"
phaseCurrents = "Strom L1, L2, L3"
phasePowers = "Leistung L1, L2, L3"
phaseVoltages = "Spannung L1, L2, L3"
+phases1p3p = "Phasenumschaltung"
power = "Leistung"
powerRange = "Leistung"
range = "Reichweite"
+singlePhase = "Einphasig"
soc = "SoC"
socLimit = "SoC Begrenzung"
temp = "Temperatur"
@@ -78,6 +92,11 @@ topic = "Thema"
url = "URL"
yes = "Ja"
+[config.deviceValueChargeStatus]
+A = "A (nicht verbunden)"
+B = "B (verbunden)"
+C = "C (lädt)"
+
[config.eebus]
description = "Grundkonfiguration für die Kommunikation mit anderen EEBus-Geräten."
title = "EEBus"
@@ -88,18 +107,20 @@ optional = "optional"
[config.general]
cancel = "Abbrechen"
+delete = "Löschen"
docsLink = "Siehe Dokumentation."
experimental = "Experimentell"
-hideAdvancedSettings = "Erweiterte einstellungen ausblenden"
+hideAdvancedSettings = "Erweiterte Einstellungen ausblenden"
off = "aus"
on = "an"
password = "Passwort"
readFromFile = "Aus Datei lesen"
remove = "Entfernen"
save = "Speichern"
-showAdvancedSettings = "Erweiterte einstellungen anzeigen"
+showAdvancedSettings = "Erweiterte Einstellungen anzeigen"
telemetry = "Telemetrie"
title = "Titel"
+validateSave = "ĂśberprĂĽfen & speichern"
[config.grid]
title = "Netzzähler"
@@ -126,9 +147,84 @@ title = "InfluxDB"
v1Support = "UnterstĂĽtzung fĂĽr InfluxDB 1.x?"
v2Support = "ZurĂĽck zu InfluxDB 2.x"
+[config.loadpoint]
+addCharger = "Wallbox hinzufĂĽgen"
+addMeter = "Zusätzlichen Zähler hinzufügen"
+cancel = "Abbrechen"
+chargerLabel = "Wallbox"
+chargerPower11kw = "11 kW"
+chargerPower11kwHelp = "Verwendet einen Strombereich von 6 bis 16 A."
+chargerPower22kw = "22 kW"
+chargerPower22kwHelp = "Verwendet einen Strombereich von 6 bis 32 A."
+chargerPowerCustom = "andere"
+chargerPowerCustomHelp = "Definiere einen eigenen Strombereich."
+chargerTypeLabel = "Ladeleistung"
+chargingTitle = "Laden"
+circuitLabel = "Stromkreis"
+circuitUnassigned = "nicht zugewiesen"
+defaultModeHelp = "Lademodus beim AnschlieĂźen des Fahrzeugs."
+defaultModeHelpKeep = "Der letzte ausgewählte Modus wird beibehalten."
+defaultModeLabel = "Standard-Modus"
+delete = "Löschen"
+electricalSubtitle = "ggf. Elektriker fragen"
+electricalTitle = "Elektrisch"
+energyMeterHelp = "Zusätzlicher Zähler, wenn die Wallbox keinen integrierten hat."
+energyMeterLabel = "Energiezähler"
+estimateLabel = "Ladestand zwischen API-Updates interpolieren"
+maxCurrentHelp = "Muss größer als der minimale Strom sein."
+maxCurrentLabel = "Maximaler Strom"
+minCurrentHelp = "Gehe nur unter 6 A, wenn du weiĂźt, was du tust."
+minCurrentLabel = "Minimaler Strom"
+noVehicles = "Keine Fahrzeuge konfiguriert."
+phases1p = "1-phasig"
+phases3p = "3-phasig"
+phasesAutomatic = "Automatische Phasen"
+phasesAutomaticHelp = "Deine Wallbox unterstützt automatisches Umschalten zwischen 1- und 3-phasigem Laden. In der Hauptansicht kannst du das Phasenverhalten während des Ladens anpassen."
+phasesHelp = "Anzahl der an die Wallbox angeschlossenen Phasen."
+phasesLabel = "Phasen"
+pollIntervalHelp = "Zeit zwischen Fahrzeug-API-Updates. Kurze Intervalle können die Fahrzeugbatterie belasten."
+pollIntervalLabel = "Update-Intervall"
+pollModeAlways = "immer"
+pollModeAlwaysHelp = "Statusabfragen immer in regelmäßigen Abständen durchführen."
+pollModeCharging = "nur beim Laden"
+pollModeChargingHelp = "Fahrzeugstatus nur während des Ladens abfragen."
+pollModeConnected = "wenn verbunden"
+pollModeConnectedHelp = "Fahrzeugstatus in regelmäßigen Abständen abfragen, wenn verbunden."
+pollModeLabel = "Update-Verhalten"
+priorityHelp = "Ladepunkte mit höherer Priorität haben Vorrang beim PV-Überschuss."
+priorityLabel = "Priorität"
+save = "Speichern"
+showAllSettings = "Alle Einstellungen anzeigen"
+solarBehaviorCustomHelp = "Definiere eigene Ein- und Ausschaltschwellen und Verzögerungen."
+solarBehaviorDefaultHelp = "Nur mit PV-Ăśberschuss laden. Start nach {enableDelay} Ăśberschuss. Stop wenn fĂĽr {disableDelay} nicht genug Ăśberschuss vorhanden ist."
+solarBehaviorLabel = "PV-Verhalten"
+solarModeCustom = "eigenes"
+solarModeMaximum = "nur Sonne"
+thresholdDisableDelayLabel = "Ausschaltverzögerung"
+thresholdDisableHelpInvalid = "Bitte verwende einen positiven Wert."
+thresholdDisableHelpPositive = "Laden stoppen, wenn mehr als {power} fĂĽr {delay} aus dem Netz bezogen wird."
+thresholdDisableHelpZero = "Stoppen, wenn minimale Ladeleistung fĂĽr {delay} nicht erreicht werden kann."
+thresholdDisableLabel = "Netzleistung ausschalten"
+thresholdEnableDelayLabel = "Einschaltverzögerung"
+thresholdEnableHelpInvalid = "Bitte verwende einen negativen Wert."
+thresholdEnableHelpNegative = "Laden starten, wenn {surplus} Ăśberschuss fĂĽr {delay} verfĂĽgbar ist."
+thresholdEnableHelpZero = "Starten, wenn minimale Ladeleistung fĂĽr {delay} als Ăśberschuss verfĂĽgbar ist."
+thresholdEnableLabel = "Netzleistung einschalten"
+titleAdd = "Ladepunkt hinzufĂĽgen"
+titleEdit = "Ladepunkt bearbeiten"
+titleExample = "Garage, Carport, etc."
+titleLabel = "Titel"
+vehicleAutoDetection = "automatische Erkennung"
+vehicleHelpAutoDetection = "Wählt automatisch das plausibelste Fahrzeug aus. Manuelle Übersteuerung möglich."
+vehicleHelpDefault = "Nimmt immer an, dass dieses Fahrzeug hier lädt. Auto-Erkennung deaktiviert. Manuelle Übersteuerung möglich."
+vehicleLabel = "Standard-Fahrzeug"
+vehiclesTitle = "Fahrzeuge"
+
[config.main]
+addGrid = "Netzzähler hinzufügen"
addLoadpoint = "Ladepunkt hinzufĂĽgen"
addPvBattery = "PV oder Speicher hinzufĂĽgen"
+addTariffs = "Tarife hinzufĂĽgen"
addVehicle = "Fahrzeug hinzufĂĽgen"
configured = "konfiguriert"
edit = "bearbeiten"
@@ -195,6 +291,11 @@ little = "little-endian"
http = "HTTP (unverschlĂĽsselt)"
https = "HTTPS (verschlĂĽsselt)"
+[config.options.status]
+A = "A (nicht verbunden)"
+B = "B (verbunden)"
+C = "C (lädt)"
+
[config.pv]
titleAdd = "Zähler (PV) hinzufügen"
titleEdit = "Zähler (PV) bearbeiten"
@@ -209,13 +310,16 @@ system = "System"
vehicles = "Fahrzeuge"
[config.sponsor]
-addToken = "Sponsortoken eingeben"
-changeToken = "Sponsortoken ändern"
+addToken = "Token eingeben"
+changeToken = "Token ändern"
description = "Das Sponsoring-Modell hilft uns, das Projekt zu pflegen und nachhaltig neue und spannende Funktionen zu entwickeln. Als Sponsor erhältst du Zugriff auf alle Wallbox-Implementierungen."
descriptionToken = "Du erhältst das Token von {url}. Wir bieten auch ein Testtoken für Tests an."
error = "Das Sponsortoken ist ungĂĽltig."
labelToken = "Sponsortoken"
title = "Sponsorship"
+tokenRequired = "Du musst ein Sponsortoken konfigurieren, bevor du dieses Gerät anlegen kannst."
+tokenRequiredLearnMore = "Mehr erfahren."
+trialToken = "Testtoken"
[config.system]
logs = "Logs"
@@ -293,7 +397,7 @@ thisYear = "dieses Jahr"
total = "gesamt"
[footer.sponsor]
-becomeSponsor = "Werden Sie Sponsor"
+becomeSponsor = "Werde Sponsor"
becomeSponsorExtended = "UnterstĂĽtze uns direkt. Es gibt auch Sticker."
confetti = "Lust auf Konfetti?"
confettiPromise = "Es gibt auch Sticker und digitales Konfetti"
@@ -381,7 +485,9 @@ reset = "Passwort zurĂĽcksetzen?"
title = "Authentifizierung"
[main]
+startConfiguration = "Konfiguration beginnen"
vehicles = "Parkplatz"
+welcome = "Herzlich willkommen!"
[main.chargingPlan]
active = "Aktiv"
@@ -629,16 +735,6 @@ csvTotal = "Gesamte CSV herunterladen"
date = "Anfang"
downloadCsv = "Als CSV herunterladen"
energy = "Geladen"
-energyGroupedTitle = "Energiemenge {energy}"
-energyGroupedTitleByGroup = "Energiemenge {byGroup}"
-energySubTitle = "{energy} gesamt"
-energyTitle = "{percent} Sonnenenergie"
-groupedCo2TitleByGroup = "COâ‚‚-Menge {byGroup}"
-groupedPriceTitleByGroup = "Kosten {byGroup}"
-historyCo2SubTitle = "{value} gesamt"
-historyCo2Title = "⌀ {value} CO₂-Emissionen"
-historyPriceSubTitle = "{value} gesamt"
-historyPriceTitle = "⌀ {value} Ladepreis"
loadpoint = "Ladepunkt"
noData = "Noch keine Ladevorgänge in diesem Monat."
overview = "Ăśbersicht"
diff --git a/i18n/en.toml b/i18n/en.toml
index 26ea338b1..ab62c2e74 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -27,6 +27,20 @@ never = "only with enough surplus."
titleAdd = "Add Battery"
titleEdit = "Edit Battery"
+[config.charge]
+titleAdd = "Add Charge Meter"
+titleEdit = "Edit Charge Meter"
+
+[config.charger]
+chargers = "EV chargers"
+generic = "Generic integrations"
+ocppHelp = "Copy this address into your chargers configuration."
+ocppLabel = "OCPP-Server URL"
+switchsockets = "Switchable sockets"
+template = "Manufacturer"
+titleAdd = "Add Charger"
+titleEdit = "Edit Charger"
+
[config.circuits]
description = "Ensures, that the sum of all loadpoints connected to a circuit does not exceed the configured power and current limits. Circuits can be nested to build a hierarchy."
title = "Load Management"
@@ -62,15 +76,18 @@ energy = "Energy"
feedinPrice = "Feed-in price"
gridPrice = "Grid price"
hemsType = "System"
+identifier = "RFID-Identifier"
no = "no"
odometer = "Odometer"
org = "Organization"
phaseCurrents = "Current L1, L2, L3"
phasePowers = "Power L1, L2, L3"
phaseVoltages = "Voltage L1, L2, L3"
+phases1p3p = "Phase switch"
power = "Power"
powerRange = "Power"
range = "Range"
+singlePhase = "Single phase"
soc = "SoC"
socLimit = "Limit"
temp = "Temperature"
@@ -78,6 +95,11 @@ topic = "Topic"
url = "URL"
yes = "yes"
+[config.deviceValueChargeStatus]
+A = "A (not connected)"
+B = "B (connected)"
+C = "C (charging)"
+
[config.eebus]
description = "Configuration that enables evcc to communicate with other EEBus devices."
title = "EEBus"
@@ -88,6 +110,7 @@ optional = "optional"
[config.general]
cancel = "Cancel"
+delete = "Delete"
docsLink = "See documentation."
experimental = "Experimental"
hideAdvancedSettings = "Hide advanced settings"
@@ -100,6 +123,7 @@ save = "Save"
showAdvancedSettings = "Show advanced settings"
telemetry = "Telemetry"
title = "Title"
+validateSave = "Validate & save"
[config.grid]
title = "Grid meter"
@@ -126,9 +150,84 @@ title = "InfluxDB"
v1Support = "Need support for InfluxDB 1.x?"
v2Support = "Back to InfluxDB 2.x"
+[config.loadpoint]
+addCharger = "Add charger"
+addMeter = "Add dedicated charger meter"
+cancel = "Cancel"
+chargerLabel = "Charger"
+chargerPower11kw = "11 kW"
+chargerPower11kwHelp = "Will use a current range of 6 to 16 A."
+chargerPower22kw = "22 kW"
+chargerPower22kwHelp = "Will use a current range of 6 to 32 A."
+chargerPowerCustom = "other"
+chargerPowerCustomHelp = "Define a custom current range."
+chargerTypeLabel = "Charger type"
+chargingTitle = "Charging"
+circuitLabel = "Circuit"
+circuitUnassigned = "unassigned"
+defaultModeHelp = "Charging mode when connecting the vehicle."
+defaultModeHelpKeep = "Keeps the last selected charging mode."
+defaultModeLabel = "Default mode"
+delete = "Delete"
+electricalSubtitle = "When in doubt, ask your electrician."
+electricalTitle = "Electrical"
+energyMeterHelp = "Additional meter if the charger doesn't have an integrated one."
+energyMeterLabel = "Energy meter"
+estimateLabel = "Interpolate charge level between API updates"
+maxCurrentHelp = "Must be greater than minimum current."
+maxCurrentLabel = "Maximum current"
+minCurrentHelp = "Only go below 6 A if you know what you're doing."
+minCurrentLabel = "Minimum current"
+noVehicles = "No vehicles are configured."
+phases1p = "1-phase"
+phases3p = "3-phase"
+phasesAutomatic = "Automatic phases"
+phasesAutomaticHelp = "Your charger supports automatic switching between 1- and 3-phase charging. In the main screen you can adjust phase behaviour while charging."
+phasesHelp = "Number of phases connected to the charger."
+phasesLabel = "Phases"
+pollIntervalHelp = "Time between vehicle API updates. Short intervals may drain the vehicle battery."
+pollIntervalLabel = "Update interval"
+pollModeAlways = "always"
+pollModeAlwaysHelp = "Always request status updates in regular intervals."
+pollModeCharging = "charging"
+pollModeChargingHelp = "Only request vehicle status updates when charging."
+pollModeConnected = "connected"
+pollModeConnectedHelp = "Update vehicle status in regular intervals when connected."
+pollModeLabel = "Update behaviour"
+priorityHelp = "Higher priority charge points get preferred access to solar surplus."
+priorityLabel = "Priority"
+save = "Save"
+showAllSettings = "Show all settings"
+solarBehaviorCustomHelp = "Define your own enable and disable thresholds and delays."
+solarBehaviorDefaultHelp = "Only charge with solar surplus. Start after {enableDelay} of surplus. Stop when there is not enough surplus for {disableDelay}."
+solarBehaviorLabel = "Solar behaviour"
+solarModeCustom = "custom"
+solarModeMaximum = "maximum solar"
+thresholdDisableDelayLabel = "Disable delay"
+thresholdDisableHelpInvalid = "Please use a positive value."
+thresholdDisableHelpPositive = "Stop charging, when more than {power} is used from the grid for {delay}."
+thresholdDisableHelpZero = "Stop when minimum charge power can't be satisfied for {delay}."
+thresholdDisableLabel = "Disable grid power"
+thresholdEnableDelayLabel = "Enable delay"
+thresholdEnableHelpInvalid = "Please use a negative value."
+thresholdEnableHelpNegative = "Start charging, when {surplus} surplus is available for {delay}."
+thresholdEnableHelpZero = "Start when minimum charge power surplus is available for {delay}."
+thresholdEnableLabel = "Enable grid power"
+titleAdd = "Add Charge Point"
+titleEdit = "Edit Charge Point"
+titleExample = "Garage, Carport, etc."
+titleLabel = "Title"
+vehicleAutoDetection = "auto detection"
+vehicleHelpAutoDetection = "Automatically selects the most plausible vehicle. Manual override is possible."
+vehicleHelpDefault = "Always assume this vehicle is charging here. Auto-detection disabled. Manual override is possible."
+vehicleLabel = "Default vehicle"
+vehiclesTitle = "Vehicles"
+
[config.main]
+addGrid = "Add grid meter"
addLoadpoint = "Add charge point"
addPvBattery = "Add solar or battery"
+addTariffs = "Add tariffs"
addVehicle = "Add vehicle"
configured = "configured"
edit = "edit"
@@ -194,6 +293,11 @@ little = "little-endian"
http = "HTTP (unencrypted)"
https = "HTTPS (encrypted)"
+[config.options.status]
+A = "A (not connected)"
+B = "B (connected)"
+C = "C (charging)"
+
[config.pv]
titleAdd = "Add Solar Meter"
titleEdit = "Edit Solar Meter"
@@ -208,13 +312,16 @@ system = "System"
vehicles = "Vehicles"
[config.sponsor]
-addToken = "Enter sponsor token"
-changeToken = "Change sponsor token"
+addToken = "Enter token"
+changeToken = "Change token"
description = "The sponsoring model helps us to maintain the project and sustainably build new and exciting features. As a sponsor you get access to all charger implementations."
descriptionToken = "You get the token from {url}. We also offer a trial token for testing."
error = "The sponsor token is not valid."
labelToken = "Sponsor token"
title = "Sponsorship"
+tokenRequired = "You must configure a sponsor token before you can create this device."
+tokenRequiredLearnMore = "Learn more."
+trialToken = "Trial token"
[config.system]
logs = "Logs"
@@ -380,7 +487,9 @@ reset = "Reset password?"
title = "Authentication"
[main]
+startConfiguration = "Let's start configuration"
vehicles = "Parking"
+welcome = "Hello aboard!"
[main.chargingPlan]
active = "Active"
diff --git a/server/helper.go b/server/helper.go
index 43cd12060..ed6c84428 100644
--- a/server/helper.go
+++ b/server/helper.go
@@ -1,7 +1,9 @@
package server
import (
+ "encoding/json"
"fmt"
+ "io"
"math"
"reflect"
"slices"
@@ -26,6 +28,13 @@ func parseFloat(payload string) (float64, error) {
return f, err
}
+// jsonDecoder returns a json decoder with disallowed unknown fields
+func jsonDecoder(r io.Reader) *json.Decoder {
+ dec := json.NewDecoder(r)
+ dec.DisallowUnknownFields()
+ return dec
+}
+
// omitEmpty returns true if struct field is omitempty
func omitEmpty(f reflect.StructField) bool {
tag := f.Tag.Get("json")
diff --git a/server/http.go b/server/http.go
index a6765cb68..4a87b384e 100644
--- a/server/http.go
+++ b/server/http.go
@@ -156,6 +156,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
}
// loadpoint api
+ // TODO any loadpoint
for id, lp := range site.Loadpoints() {
api := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id+1)).Subrouter()
@@ -236,7 +237,7 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
routes := map[string]route{
"templates": {"GET", "/templates/{class:[a-z]+}", templatesHandler},
"products": {"GET", "/products/{class:[a-z]+}", productsHandler},
- "devices": {"GET", "/devices/{class:[a-z]+}", devicesHandler},
+ "devices": {"GET", "/devices/{class:[a-z]+}", devicesConfigHandler},
"device": {"GET", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceConfigHandler},
"devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
"dirty": {"GET", "/dirty", getHandler(ConfigDirty)},
@@ -247,7 +248,7 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
"testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler},
"interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval)},
"updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler},
- "deletesponsortoken": {"DELETE", "/sponsortoken", settingsDeleteHandler(keys.SponsorToken)},
+ "deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler},
}
// yaml handlers
@@ -279,6 +280,17 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
for _, r := range routes {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
+
+ // loadpoints
+ for _, r := range map[string]route{
+ "loadpoints": {"GET", "/loadpoints", loadpointsConfigHandler()},
+ "loadpoint": {"GET", "/loadpoints/{id:[0-9.]+}", loadpointConfigHandler()},
+ "updateloadpoint": {"PUT", "/loadpoints/{id:[0-9.]+}", updateLoadpointHandler()},
+ "deleteloadpoint": {"DELETE", "/loadpoints/{id:[0-9.]+}", deleteLoadpointHandler()},
+ "newloadpoint": {"POST", "/loadpoints", newLoadpointHandler()},
+ } {
+ api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
+ }
}
{ // api/system
diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go
index 91871d4b4..39474ce86 100644
--- a/server/http_config_device_handler.go
+++ b/server/http_config_device_handler.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"net/http"
"strconv"
@@ -33,11 +34,17 @@ func devicesConfig[T any](class templates.Class, h config.Handler[T]) ([]map[str
return res, nil
}
-// devicesHandler returns a device configurations by class
-func devicesHandler(w http.ResponseWriter, r *http.Request) {
+// devicesConfigHandler returns a device configurations by class
+func devicesConfigHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
class, err := templates.ClassString(vars["class"])
+
+ // TODO exclude loadpoints here
+ if err == nil && class == templates.Loadpoint {
+ err = fmt.Errorf("invalid class %s", class)
+ }
+
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
@@ -142,6 +149,8 @@ func deviceConfigHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ // TODO return application/yaml content type if type != template
+
jsonResult(w, res)
}
@@ -202,7 +211,7 @@ func newDevice[T any](class templates.Class, req map[string]any, newFromConf new
return nil, err
}
- return &conf, h.Add(config.NewConfigurableDevice[T](conf, instance))
+ return &conf, h.Add(config.NewConfigurableDevice[T](&conf, instance))
}
// newDeviceHandler creates a new device by class
@@ -215,6 +224,8 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ // TODO add application/yaml content type, reject type==template
+
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, err)
@@ -288,6 +299,8 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
return
}
+ // TODO add application/yaml content type, reject type==template
+
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, err)
diff --git a/server/http_config_helper.go b/server/http_config_helper.go
index 1d16f2f28..138c712d9 100644
--- a/server/http_config_helper.go
+++ b/server/http_config_helper.go
@@ -188,6 +188,14 @@ func testInstance(instance any) map[string]testResult {
res["chargedEnergy"] = makeResult(val, err)
}
+ if _, ok := instance.(api.PhaseSwitcher); ok {
+ res["phases1p3p"] = makeResult(true, nil)
+ }
+
+ if cc, ok := instance.(api.PhaseDescriber); ok {
+ res["singlePhase"] = makeResult(cc.Phases() == 1, nil)
+ }
+
if dev, ok := instance.(api.VehicleRange); ok {
val, err := dev.Range()
res["range"] = makeResult(val, err)
@@ -198,5 +206,10 @@ func testInstance(instance any) map[string]testResult {
res["socLimit"] = makeResult(val, err)
}
+ if dev, ok := instance.(api.Identifier); ok {
+ val, err := dev.Identify()
+ res["identifier"] = makeResult(val, err)
+ }
+
return res
}
diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go
new file mode 100644
index 000000000..4896b0f1c
--- /dev/null
+++ b/server/http_config_loadpoint_handler.go
@@ -0,0 +1,251 @@
+package server
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "strconv"
+
+ "github.com/evcc-io/evcc/core"
+ "github.com/evcc-io/evcc/core/loadpoint"
+ coresettings "github.com/evcc-io/evcc/core/settings"
+ "github.com/evcc-io/evcc/util"
+ "github.com/evcc-io/evcc/util/config"
+ "github.com/evcc-io/evcc/util/templates"
+ "github.com/gorilla/mux"
+ "github.com/samber/lo"
+)
+
+func getLoadpointStaticConfig(lp loadpoint.API) loadpoint.StaticConfig {
+ return loadpoint.StaticConfig{
+ Charger: lp.GetChargerName(),
+ Meter: lp.GetMeterName(),
+ Circuit: lp.GetCircuitName(),
+ Vehicle: lp.GetDefaultVehicle(),
+ }
+}
+
+func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig {
+ planTime, planEnergy := lp.GetPlanEnergy()
+ return loadpoint.DynamicConfig{
+ Title: lp.GetTitle(),
+ DefaultMode: string(lp.GetDefaultMode()),
+ Priority: lp.GetPriority(),
+ Phases: lp.GetPhases(),
+ MinCurrent: lp.GetMinCurrent(),
+ MaxCurrent: lp.GetMaxCurrent(),
+ SmartCostLimit: lp.GetSmartCostLimit(),
+ Thresholds: lp.GetThresholds(),
+ Soc: lp.GetSocConfig(),
+ PlanEnergy: planEnergy,
+ PlanTime: planTime,
+ LimitEnergy: lp.GetLimitEnergy(),
+ LimitSoc: lp.GetLimitSoc(),
+ }
+}
+
+type loadpointFullConfig struct {
+ ID int `json:"id,omitempty"` // db row id
+ Name string `json:"name"` // either slice index (yaml) or db:
+
+ // static config
+ loadpoint.StaticConfig
+ loadpoint.DynamicConfig
+}
+
+func loadpointSplitConfig(r io.Reader) (loadpoint.DynamicConfig, map[string]any, error) {
+ var payload map[string]any
+
+ if err := jsonDecoder(r).Decode(&payload); err != nil {
+ return loadpoint.DynamicConfig{}, nil, err
+ }
+
+ return loadpoint.SplitConfig(payload)
+}
+
+// loadpointConfig returns a single loadpoint's configuration
+func loadpointConfig(dev config.Device[loadpoint.API]) loadpointFullConfig {
+ lp := dev.Instance()
+
+ var id int
+ if configurable, ok := dev.(config.ConfigurableDevice[loadpoint.API]); ok {
+ id = configurable.ID()
+ }
+
+ res := loadpointFullConfig{
+ ID: id,
+ Name: dev.Config().Name,
+
+ StaticConfig: getLoadpointStaticConfig(lp),
+ DynamicConfig: getLoadpointDynamicConfig(lp),
+ }
+
+ return res
+}
+
+// loadpointsConfigHandler returns a device configurations by class
+func loadpointsConfigHandler() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ res := lo.Map(config.Loadpoints().Devices(), func(dev config.Device[loadpoint.API], _ int) loadpointFullConfig {
+ return loadpointConfig(dev)
+ })
+
+ jsonResult(w, res)
+ }
+}
+
+// loadpointConfigHandler returns a device configurations by class
+func loadpointConfigHandler() http.HandlerFunc {
+ h := config.Loadpoints()
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ dev, err := h.ByName(config.NameForID(id))
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ res := loadpointConfig(dev)
+
+ jsonResult(w, res)
+ }
+}
+
+// newLoadpointHandler creates a new loadpoint
+func newLoadpointHandler() http.HandlerFunc {
+ h := config.Loadpoints()
+
+ // TODO revert charger, meter etc
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ dynamic, static, err := loadpointSplitConfig(r.Body)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ id := len(h.Devices())
+ name := "lp-" + strconv.Itoa(id+1)
+ log := util.NewLoggerWithLoadpoint(name, id+1)
+
+ conf, err := config.AddConfig(templates.Loadpoint, "", static)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ settings := coresettings.NewConfigSettingsAdapter(log, &conf)
+ instance, err := core.NewLoadpointFromConfig(log, settings, static)
+ if err != nil {
+ conf.Delete()
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ dev := config.NewConfigurableDevice[loadpoint.API](&conf, instance)
+ if err := dynamic.Apply(instance); err != nil {
+ conf.Delete()
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ if err := h.Add(dev); err != nil {
+ conf.Delete()
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ setConfigDirty()
+
+ w.WriteHeader(http.StatusOK)
+ }
+}
+
+// updateLoadpointHandler returns a device configurations by class
+func updateLoadpointHandler() http.HandlerFunc {
+ h := config.Loadpoints()
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ dev, err := h.ByName(config.NameForID(id))
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ configurable, ok := dev.(config.ConfigurableDevice[loadpoint.API])
+ if !ok {
+ jsonError(w, http.StatusBadRequest, errors.New("not configurable"))
+ return
+ }
+
+ instance := dev.Instance()
+
+ dynamic, static, err := loadpointSplitConfig(r.Body)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ // static
+ if err := configurable.PartialUpdate(static, instance); err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ // dynamic
+ if err := dynamic.Apply(instance); err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ setConfigDirty()
+
+ w.WriteHeader(http.StatusOK)
+ }
+}
+
+// deleteLoadpointHandler deletes a loadpoint
+func deleteLoadpointHandler() http.HandlerFunc {
+ h := config.Loadpoints()
+
+ return func(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ setConfigDirty()
+
+ if err := deleteDevice(id, h); err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ res := struct {
+ ID int `json:"id"`
+ }{
+ ID: id,
+ }
+
+ jsonResult(w, res)
+ }
+}
diff --git a/server/http_config_site_handler.go b/server/http_config_site_handler.go
index eb3b0a220..eb460f77b 100644
--- a/server/http_config_site_handler.go
+++ b/server/http_config_site_handler.go
@@ -1,7 +1,6 @@
package server
import (
- "encoding/json"
"net/http"
"github.com/evcc-io/evcc/core/site"
@@ -47,7 +46,7 @@ func updateSiteHandler(site site.API) http.HandlerFunc {
Battery *[]string
}
- if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ if err := jsonDecoder(r.Body).Decode(&payload); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
diff --git a/server/http_config_site_other_handler.go b/server/http_config_site_other_handler.go
index 6ee5a7b43..cd8ab90e4 100644
--- a/server/http_config_site_other_handler.go
+++ b/server/http_config_site_other_handler.go
@@ -32,3 +32,9 @@ func updateSponsortokenHandler(w http.ResponseWriter, r *http.Request) {
jsonResult(w, sponsor.Status())
}
+
+func deleteSponsorTokenHandler(w http.ResponseWriter, r *http.Request) {
+ settings.SetString(keys.SponsorToken, "")
+ setConfigDirty()
+ jsonResult(w, true)
+}
diff --git a/server/influxdb.go b/server/influxdb.go
index 0e331eccf..fb395161f 100644
--- a/server/influxdb.go
+++ b/server/influxdb.go
@@ -165,7 +165,7 @@ func (m *Influx) Run(site site.API, in <-chan util.Param) {
if param.Loadpoint != nil {
lp := site.Loadpoints()[*param.Loadpoint]
- tags["loadpoint"] = lp.Title()
+ tags["loadpoint"] = lp.GetTitle()
if v := lp.GetVehicle(); v != nil {
tags["vehicle"] = v.Title()
}
diff --git a/server/mqtt.go b/server/mqtt.go
index fae7a9715..3e0fd7354 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -203,6 +203,7 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.
{"mode", setterFunc(api.ChargeModeString, pass(lp.SetMode))},
{"phases", intSetter(lp.SetPhases)},
{"limitSoc", intSetter(pass(lp.SetLimitSoc))},
+ {"priority", intSetter(pass(lp.SetPriority))},
{"minCurrent", floatSetter(lp.SetMinCurrent)},
{"maxCurrent", floatSetter(lp.SetMaxCurrent)},
{"limitEnergy", floatSetter(pass(lp.SetLimitEnergy))},
diff --git a/templates/definition/charger/demo-charger.yaml b/templates/definition/charger/demo-charger.yaml
index 9ced91830..03da11b27 100644
--- a/templates/definition/charger/demo-charger.yaml
+++ b/templates/definition/charger/demo-charger.yaml
@@ -14,7 +14,7 @@ params:
de: Ladezustand
en: Charge status
type: choice
- choice: [A, B, C, D, E, F]
+ choice: [A, B, C]
default: A
- name: power
description:
@@ -36,6 +36,13 @@ params:
example: 16
type: int
advanced: true
+ - name: phases1p3p
+ description:
+ de: Phasenumschaltung
+ en: Phase switching
+ type: bool
+ default: false
+ advanced: true
render: |
type: custom
@@ -55,3 +62,10 @@ render: |
power:
source: const
value: {{ .power }}
+ {{ if eq .phases1p3p "true" }}
+ phases1p3p:
+ source: js
+ script: |
+ 3
+ tos: true
+ {{ end }}
diff --git a/tests/config-battery.spec.js b/tests/config-battery.spec.js
index 9cb90ee6f..99d442d7e 100644
--- a/tests/config-battery.spec.js
+++ b/tests/config-battery.spec.js
@@ -41,13 +41,16 @@ test.describe("battery meter", async () => {
await expect(meterModal.getByTestId("device-tag-soc")).toContainText("75.0%");
await expect(meterModal.getByTestId("device-tag-power")).toContainText("-2.5 kW");
await meterModal.getByRole("button", { name: "Save" }).click();
+ await expect(meterModal).not.toBeVisible();
await expect(page.getByTestId("battery")).toBeVisible(1);
await expect(page.getByTestId("battery")).toContainText("openems");
// edit #1
await page.getByTestId("battery").getByRole("button", { name: "edit" }).click();
+ await expect(meterModal).toBeVisible();
await meterModal.getByLabel("Battery capacity in kWh").fill("20");
await meterModal.getByRole("button", { name: "Validate & save" }).click();
+ await expect(meterModal).not.toBeVisible();
const battery = page.getByTestId("battery");
await expect(battery).toBeVisible(1);
diff --git a/tests/config-empty.evcc.yaml b/tests/config-empty.evcc.yaml
index c7b0b6519..e69de29bb 100755
--- a/tests/config-empty.evcc.yaml
+++ b/tests/config-empty.evcc.yaml
@@ -1,24 +0,0 @@
-site:
- title: Hello World
-
-loadpoints:
- - title: Carport
- charger: charger
-
-chargers:
- - name: charger
- type: custom
- enable:
- source: js
- script:
- enabled:
- source: js
- script: |
- false
- status:
- source: js
- script: |
- "B"
- maxcurrent:
- source: js
- script:
diff --git a/tests/config-grid.spec.js b/tests/config-grid.spec.js
index d10fc352b..6b420796b 100644
--- a/tests/config-grid.spec.js
+++ b/tests/config-grid.spec.js
@@ -3,13 +3,13 @@ import { start, stop, restart, baseUrl } from "./evcc";
import { startSimulator, stopSimulator, simulatorUrl, simulatorHost } from "./simulator";
import { enableExperimental } from "./utils";
-const CONFIG_EMPTY = "config-empty.evcc.yaml";
+const CONFIG_ONE_LP = "config-one-lp.evcc.yaml";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
await startSimulator();
- await start(CONFIG_EMPTY);
+ await start(CONFIG_ONE_LP);
});
test.afterAll(async () => {
await stop();
@@ -34,11 +34,11 @@ test.describe("grid meter", async () => {
await page.goto("/#/config");
await enableExperimental(page);
- await expect(page.getByTestId("grid")).toHaveCount(1);
- await expect(page.getByTestId("grid").getByTestId("device-tag-configured")).toContainText("no");
+ await expect(page.getByTestId("grid")).toHaveCount(0);
+ await expect(page.getByTestId("add-grid")).toBeVisible();
// create #1
- await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
+ await page.getByTestId("add-grid").click();
const meterModal = page.getByTestId("meter-modal");
await meterModal.getByLabel("Manufacturer").selectOption("OpenEMS");
@@ -50,7 +50,7 @@ test.describe("grid meter", async () => {
await expect(meterModal).not.toBeVisible();
// restart
- await restart(CONFIG_EMPTY);
+ await restart(CONFIG_ONE_LP);
await expect(page.getByTestId("grid").getByTestId("device-tag-power")).toContainText("5.0 kW");
// check in main ui
@@ -64,7 +64,7 @@ test.describe("grid meter", async () => {
await meterModal.getByRole("button", { name: "Delete" }).click();
await expect(meterModal).not.toBeVisible();
- await expect(page.getByTestId("grid")).toHaveCount(1);
- await expect(page.getByTestId("grid").getByTestId("device-tag-configured")).toContainText("no");
+ await expect(page.getByTestId("grid")).not.toBeVisible();
+ await expect(page.getByTestId("add-grid")).toBeVisible();
});
});
diff --git a/tests/config-loadpoint.spec.js b/tests/config-loadpoint.spec.js
new file mode 100644
index 000000000..cb8285e35
--- /dev/null
+++ b/tests/config-loadpoint.spec.js
@@ -0,0 +1,277 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, restart, baseUrl } from "./evcc";
+
+const CONFIG_EMPTY = "config-empty.evcc.yaml";
+const CONFIG_ONE_LP = "config-one-lp.evcc.yaml";
+
+test.use({ baseURL: baseUrl() });
+
+test.afterEach(async () => {
+ await stop();
+});
+
+async function enableExperimental(page) {
+ await page
+ .getByTestId("generalconfig-experimental")
+ .getByRole("button", { name: "edit" })
+ .click();
+ await page.getByLabel("Experimental đź§Ş").click();
+ await page.getByRole("button", { name: "Close" }).click();
+}
+
+async function addDemoCharger(page) {
+ const lpModal = page.getByTestId("loadpoint-modal");
+ await lpModal.getByRole("button", { name: "Add charger" }).click();
+
+ const modal = page.getByTestId("charger-modal");
+ await modal.getByLabel("Manufacturer").selectOption("Demo charger");
+ await modal.getByRole("button", { name: "Save" }).click();
+ await expect(modal).not.toBeVisible();
+}
+
+async function addVehicle(page, title) {
+ await page.getByRole("button", { name: "Add vehicle" }).click();
+ const modal = page.getByTestId("vehicle-modal");
+ await modal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+ await modal.getByLabel("Title").fill(title);
+ await modal.getByRole("button", { name: "Validate & save" }).click();
+ await expect(modal).not.toBeVisible();
+}
+
+async function newLoadpoint(page, title) {
+ const lpModal = page.getByTestId("loadpoint-modal");
+ await page.getByRole("button", { name: "Add charge point" }).click();
+ await expect(lpModal).toBeVisible();
+ await lpModal.getByLabel("Title").fill(title);
+}
+
+test.describe("loadpoint", async () => {
+ test("create, update and delete", async ({ page }) => {
+ await start(CONFIG_EMPTY);
+
+ await page.goto("/#/config");
+ await enableExperimental(page);
+
+ const lpModal = page.getByTestId("loadpoint-modal");
+ const chargerModal = page.getByTestId("charger-modal");
+
+ await expect(page.getByTestId("loadpoint")).toHaveCount(0);
+
+ // new loadpoint
+ await newLoadpoint(page, "Solar Carport");
+ await lpModal.getByRole("button", { name: "Add charger" }).click();
+
+ // add charger
+ await chargerModal.getByLabel("Manufacturer").selectOption("Demo charger");
+ await chargerModal.getByLabel("Charge status").selectOption("C");
+ await chargerModal.getByLabel("Power").fill("11000");
+ await chargerModal.getByRole("button", { name: "Save" }).click();
+ await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport");
+
+ // create loadpoint
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expect(page.getByTestId("loadpoint")).toHaveCount(1);
+ await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport");
+ await expect(page.getByTestId("loadpoint")).toContainText("charging");
+ await expect(page.getByTestId("loadpoint")).toContainText("11.0 kW");
+
+ // restart button appears
+ const restartButton = await page
+ .getByTestId("bottom-banner")
+ .getByRole("button", { name: "Restart" });
+ await expect(restartButton).toBeVisible();
+
+ // restart
+ await restart(CONFIG_EMPTY);
+ await page.reload();
+ await expect(page.getByTestId("loadpoint")).toHaveCount(1);
+ await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport");
+
+ // update loadpoint title
+ await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
+ await lpModal.getByLabel("Title").fill("Solar Carport 2");
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2");
+
+ // restart
+ await restart(CONFIG_EMPTY);
+ await page.reload();
+ await expect(page.getByTestId("loadpoint")).toHaveCount(1);
+ await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2");
+
+ // update loadpoint power
+ await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
+ await lpModal.getByTestId("chargerPower-22kw").click();
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expect(lpModal).not.toBeVisible();
+
+ // restart
+ await restart(CONFIG_EMPTY);
+ await page.reload();
+ await expect(page.getByTestId("loadpoint")).toHaveCount(1);
+ await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
+ await expect(lpModal.getByTestId("chargerPower-22kw")).toHaveClass(/active/);
+ await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport 2");
+ await lpModal.getByRole("button", { name: "Close" }).click();
+
+ // delete loadpoint
+ await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
+ await lpModal.getByRole("button", { name: "Delete" }).click();
+ await expect(page.getByTestId("loadpoint")).toHaveCount(0);
+
+ // restart
+ await restart(CONFIG_EMPTY);
+ await page.reload();
+ await expect(page.getByTestId("loadpoint")).toHaveCount(0);
+ });
+
+ test("priority", async ({ page }) => {
+ await start(CONFIG_ONE_LP);
+ await page.goto("/#/config");
+ await enableExperimental(page);
+
+ const lpModal = page.getByTestId("loadpoint-modal");
+
+ // loadpoint from yaml
+ await expect(page.getByTestId("loadpoint")).toHaveCount(1);
+ await expect(page.getByTestId("loadpoint")).toContainText("Carport");
+
+ // add two more loadpoints
+ for (const title of ["Garage", "Garden"]) {
+ await newLoadpoint(page, title);
+ await addDemoCharger(page);
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expect(lpModal).not.toBeVisible();
+ }
+
+ // three loadpoints
+ await expect(page.getByTestId("loadpoint")).toHaveCount(3);
+ await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport");
+ await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Garage");
+ await expect(page.getByTestId("loadpoint").nth(2)).toContainText("Garden");
+
+ // second loadpoint > priority 2
+ await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click();
+ await lpModal.getByTestId("loadpointParamPriority-2").click();
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expect(lpModal).not.toBeVisible();
+
+ // third loadpoint > priority 1
+ await page.getByTestId("loadpoint").nth(2).getByRole("button", { name: "edit" }).click();
+ await lpModal.getByTestId("loadpointParamPriority-1").click();
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expect(lpModal).not.toBeVisible();
+ // restart
+ await restart(CONFIG_ONE_LP);
+ await page.reload();
+
+ // check priorities
+ await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click();
+ await expect(lpModal.getByTestId("loadpointParamPriority-2")).toHaveClass(/active/);
+ await lpModal.getByRole("button", { name: "Close" }).click();
+
+ await page.getByTestId("loadpoint").nth(2).getByRole("button", { name: "edit" }).click();
+ await expect(lpModal.getByTestId("loadpointParamPriority-1")).toHaveClass(/active/);
+ await lpModal.getByRole("button", { name: "Close" }).click();
+ });
+
+ test("vehicle", async ({ page }) => {
+ await start(CONFIG_EMPTY);
+ await page.goto("/#/config");
+ await enableExperimental(page);
+
+ const LP_1 = "Carport";
+ const LP_2 = "Garage";
+ const VEHICLE_1 = "Green Car";
+ const VEHICLE_2 = "Yellow Van";
+
+ const lpModal = page.getByTestId("loadpoint-modal");
+
+ // add loadpoint > no vehicle option
+ await newLoadpoint(page, LP_1);
+ await addDemoCharger(page);
+ await expect(lpModal.getByLabel("Default vehicle")).not.toBeVisible();
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expect(lpModal).not.toBeVisible();
+
+ // edit loadpoint
+ await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click();
+ await expect(lpModal).toContainText("No vehicles are configured.");
+ await lpModal.getByRole("button", { name: "Close" }).click();
+
+ // add vehicles
+ for (const title of [VEHICLE_1, VEHICLE_2]) {
+ await addVehicle(page, title);
+ }
+
+ // set vehicle as default for loadpoint 1
+ await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click();
+ await expect(lpModal).toBeVisible();
+ await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_1);
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expect(lpModal).not.toBeVisible();
+
+ // add second loadpoint
+ await newLoadpoint(page, LP_2);
+ await addDemoCharger(page);
+ await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_2);
+ await lpModal.getByRole("button", { name: "Save" }).click();
+
+ // restart
+ await restart(CONFIG_EMPTY);
+ await page.reload();
+
+ // check loadpoint default vehicles
+ for (const [index, vehicle] of [VEHICLE_1, VEHICLE_2].entries()) {
+ await page.getByTestId("loadpoint").nth(index).getByRole("button", { name: "edit" }).click();
+ await expect(lpModal.locator("#loadpointParamVehicle option:checked")).toHaveText(vehicle);
+ await lpModal.getByRole("button", { name: "Close" }).click();
+ await expect(lpModal).not.toBeVisible();
+ }
+ });
+
+ test("keep mode", async ({ page }) => {
+ await start(CONFIG_EMPTY);
+ await page.goto("/#/config");
+ await enableExperimental(page);
+
+ // add grid meter
+ await page.getByRole("button", { name: "Add grid meter" }).click();
+ const meterModal = page.getByTestId("meter-modal");
+ await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
+ await meterModal.getByLabel("Power").fill("-1000");
+ await meterModal.getByRole("button", { name: "Save" }).click();
+ await expect(meterModal).not.toBeVisible();
+
+ // add a loadpoint with dummy charger,
+ const lpModal = page.getByTestId("loadpoint-modal");
+ await newLoadpoint(page, "Carport");
+ await addDemoCharger(page);
+ await lpModal.getByLabel("Default mode").selectOption("---");
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expect(lpModal).not.toBeVisible();
+ await restart(CONFIG_EMPTY);
+
+ // change on main ui
+ await page.goto("/");
+ await expect(page.getByRole("button", { name: "Off" })).toHaveClass(/active/);
+ await page.getByRole("button", { name: "Solar", exact: true }).click();
+ await restart(CONFIG_EMPTY);
+ await page.reload();
+ await expect(page.getByRole("button", { name: "Solar", exact: true })).toHaveClass(/active/);
+
+ // change default mode in config to fast
+ await page.goto("/#/config");
+ // open first loadpoint
+ await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
+ await expect(lpModal).toBeVisible();
+ await lpModal.getByLabel("Default mode").selectOption("Fast");
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expect(lpModal).not.toBeVisible();
+ await restart(CONFIG_EMPTY);
+
+ // check loadpoint mode
+ await page.goto("/");
+ await expect(page.getByRole("button", { name: "Fast" })).toHaveClass(/active/);
+ });
+});
diff --git a/tests/config-onboarding.spec.js b/tests/config-onboarding.spec.js
new file mode 100644
index 000000000..15d10c1de
--- /dev/null
+++ b/tests/config-onboarding.spec.js
@@ -0,0 +1,101 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, restart, baseUrl } from "./evcc";
+import { enableExperimental } from "./utils";
+
+const CONFIG = "config-empty.evcc.yaml";
+
+test.use({ baseURL: baseUrl() });
+
+test.beforeAll(async () => {
+ await start(CONFIG, null, "");
+});
+test.afterAll(async () => {
+ await stop();
+});
+
+const PASSWORD = "secret";
+
+test.describe("onboarding", async () => {
+ test("password, loadpoint, grid, pv", async ({ page }) => {
+ await page.goto("/");
+
+ // set admin password
+ const admin = page.getByTestId("password-modal");
+ await expect(admin).toBeVisible();
+ await admin.getByLabel("New password").fill(PASSWORD);
+ await admin.getByLabel("Repeat password").fill(PASSWORD);
+ await admin.getByRole("button", { name: "Create Password" }).click();
+ await expect(admin).not.toBeVisible();
+
+ // onboarding
+ await expect(page.locator("body")).toContainText("Hello aboard!");
+ await page.getByRole("link", { name: "Let's start configuration" }).click();
+
+ // login
+ const login = page.getByTestId("login-modal");
+ await expect(login).toBeVisible();
+ await login.getByLabel("Password").fill(PASSWORD);
+ await login.getByRole("button", { name: "Login" }).click();
+ await expect(login).not.toBeVisible();
+
+ // config page
+ await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
+ await enableExperimental(page);
+
+ // create loadpoint with charger
+ await page.getByTestId("add-loadpoint").click();
+ const lpModal = page.getByTestId("loadpoint-modal");
+ await lpModal.getByLabel("Title").fill("Solar Carport");
+ await lpModal.getByRole("button", { name: "Add charger" }).click();
+
+ const chargerModal = page.getByTestId("charger-modal");
+ await chargerModal.getByLabel("Manufacturer").selectOption("Demo charger");
+ await chargerModal.getByLabel("Charge status").selectOption("C");
+ await chargerModal.getByLabel("Power").fill("3000");
+ await chargerModal.getByRole("button", { name: "Save" }).click();
+ await expect(chargerModal).not.toBeVisible();
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expect(lpModal).not.toBeVisible();
+
+ // create grid meter
+ await page.getByRole("button", { name: "Add grid meter" }).click();
+ const gridModal = page.getByTestId("meter-modal");
+ await expect(gridModal).toBeVisible();
+ await gridModal.getByLabel("Manufacturer").selectOption("Demo meter");
+ await gridModal.getByLabel("Power").fill("-2000");
+ await gridModal.getByRole("button", { name: "Save" }).click();
+ await expect(gridModal).not.toBeVisible();
+
+ // create pv meter
+ await page.getByRole("button", { name: "Add solar or battery" }).click();
+ const pvModal = page.getByTestId("meter-modal");
+ await expect(gridModal).toBeVisible();
+ await pvModal.getByRole("button", { name: "Add solar meter" }).click();
+ await pvModal.getByLabel("Manufacturer").selectOption("Demo meter");
+ await pvModal.getByLabel("Power").fill("5000");
+ await pvModal.getByRole("button", { name: "Save" }).click();
+ await expect(pvModal).not.toBeVisible();
+
+ // restart
+ const restartButton = await page
+ .getByTestId("bottom-banner")
+ .getByRole("button", { name: "Restart" });
+ await expect(restartButton).toBeVisible();
+ await restart(CONFIG);
+ await expect(restartButton).not.toBeVisible();
+
+ // navigate to main screen
+ await page.getByTestId("home-link").click();
+
+ // verify configuration
+ await page.getByTestId("visualization").click();
+ await expect(page.getByTestId("energyflow-entry-production")).toContainText("5.0 kW");
+ await expect(page.getByTestId("energyflow-entry-loadpoints")).toContainText("3.0 kW");
+ await expect(page.getByTestId("energyflow-entry-gridexport")).toContainText("2.0 kW");
+
+ const loadpoint = page.getByTestId("loadpoint");
+ await expect(loadpoint.getByRole("heading", { name: "Solar Carport" })).toBeVisible();
+ await expect(loadpoint).toContainText("3.0 kW");
+ await expect(loadpoint).toContainText("Charging…");
+ });
+});
diff --git a/tests/config-one-lp.evcc.yaml b/tests/config-one-lp.evcc.yaml
new file mode 100755
index 000000000..c7b0b6519
--- /dev/null
+++ b/tests/config-one-lp.evcc.yaml
@@ -0,0 +1,24 @@
+site:
+ title: Hello World
+
+loadpoints:
+ - title: Carport
+ charger: charger
+
+chargers:
+ - name: charger
+ type: custom
+ enable:
+ source: js
+ script:
+ enabled:
+ source: js
+ script: |
+ false
+ status:
+ source: js
+ script: |
+ "B"
+ maxcurrent:
+ source: js
+ script:
diff --git a/tests/config-tariffs.spec.js b/tests/config-tariffs.spec.js
index 9d900497b..263319686 100644
--- a/tests/config-tariffs.spec.js
+++ b/tests/config-tariffs.spec.js
@@ -24,17 +24,15 @@ test.describe("tariffs", async () => {
await start(CONFIG_GRID_ONLY);
await goToConfig(page);
- await expect(page.getByTestId("tariffs")).toBeVisible();
- await expect(page.getByTestId("tariffs")).toContainText(
- ["Tariffs", "Currency", "EUR"].join("")
- );
+ await expect(page.getByTestId("tariffs")).not.toBeVisible();
+ await expect(page.getByTestId("add-tariffs")).toBeVisible();
});
test("tariffs via ui", async ({ page }) => {
await start(CONFIG_GRID_ONLY);
await goToConfig(page);
- await page.getByTestId("tariffs").getByRole("button", { name: "edit" }).click();
+ await page.getByTestId("add-tariffs").click();
const modal = await page.getByTestId("tariffs-modal");
await expect(modal).toBeVisible();
await page.waitForLoadState("networkidle");
diff --git a/tests/simulator/api.js b/tests/simulator/api.js
index 77ad47701..11e8b328d 100644
--- a/tests/simulator/api.js
+++ b/tests/simulator/api.js
@@ -42,6 +42,30 @@ const openemsMiddleware = (req, res, next) => {
}
};
+const teslaloggerMiddleware = (req, res, next) => {
+ if (req.method === "GET" && req.originalUrl.startsWith("/currentjson/")) {
+ const id = parseInt(req.originalUrl.split("/")[2]);
+ const vehicle = state.vehicles[id - 1];
+ if (!vehicle) {
+ res.statusCode = 404;
+ res.end(JSON.stringify({ error: "Vehicle not found" }));
+ return;
+ }
+ const data = {
+ battery_level: vehicle.soc,
+ battery_range_km: vehicle.range,
+ plugged_in: true,
+ charging: false,
+ odometer: 10000,
+ is_preconditioning: false,
+ charge_current_request: 10,
+ };
+ res.end(JSON.stringify(data));
+ } else {
+ next();
+ }
+};
+
export default () => ({
name: "api",
enforce: "pre",
@@ -51,6 +75,7 @@ export default () => ({
server.middlewares.use(bodyParser.json());
server.middlewares.use(stateApiMiddleware);
server.middlewares.use(openemsMiddleware);
+ server.middlewares.use(teslaloggerMiddleware);
};
},
});
diff --git a/util/config/config.go b/util/config/config.go
index ad472f8bc..870ee153e 100644
--- a/util/config/config.go
+++ b/util/config/config.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
+ "dario.cat/mergo"
"github.com/evcc-io/evcc/util/templates"
"gorm.io/gorm"
)
@@ -74,6 +75,29 @@ func (d *Config) Update(conf map[string]any) error {
})
}
+// PartialUpdate partially updates a config's details to the database
+func (d *Config) PartialUpdate(conf map[string]any) error {
+ return db.Transaction(func(tx *gorm.DB) error {
+ var config Config
+ if err := tx.Where(Config{Class: d.Class, ID: d.ID}).First(&config).Error; err != nil {
+ return err
+ }
+
+ actual := d.detailsAsMap()
+ if err := mergo.Merge(&actual, conf, mergo.WithOverride); err != nil {
+ return err
+ }
+
+ val, err := detailsFromMap(actual)
+ if err != nil {
+ return err
+ }
+ d.Value = val
+
+ return tx.Save(&d).Error
+ })
+}
+
// Delete deletes a config from the database
func (d *Config) Delete() error {
return db.Delete(Config{ID: d.ID}).Error
diff --git a/util/config/device.go b/util/config/device.go
index a8a18f38a..0c1ec1f14 100644
--- a/util/config/device.go
+++ b/util/config/device.go
@@ -7,16 +7,19 @@ type Device[T any] interface {
type ConfigurableDevice[T any] interface {
Device[T]
ID() int
+ // Assign(T)
+ // Update1(map[string]any) error
Update(map[string]any, T) error
+ PartialUpdate(map[string]any, T) error
Delete() error
}
type configurableDevice[T any] struct {
- config Config
+ config *Config
instance T
}
-func NewConfigurableDevice[T any](config Config, instance T) ConfigurableDevice[T] {
+func NewConfigurableDevice[T any](config *Config, instance T) ConfigurableDevice[T] {
return &configurableDevice[T]{
config: config,
instance: instance,
@@ -35,6 +38,14 @@ func (d *configurableDevice[T]) ID() int {
return d.config.ID
}
+// func (d *configurableDevice[T]) Assign(instance T) {
+// d.instance = instance
+// }
+
+// func (d *configurableDevice[T]) Update(config map[string]any) error {
+// return d.config.Update(config)
+// }
+
func (d *configurableDevice[T]) Update(config map[string]any, instance T) error {
if err := d.config.Update(config); err != nil {
return err
@@ -43,6 +54,14 @@ func (d *configurableDevice[T]) Update(config map[string]any, instance T) error
return nil
}
+func (d *configurableDevice[T]) PartialUpdate(config map[string]any, instance T) error {
+ if err := d.config.PartialUpdate(config); err != nil {
+ return err
+ }
+ d.instance = instance
+ return nil
+}
+
func (d *configurableDevice[T]) Delete() error {
return d.config.Delete()
}
diff --git a/util/config/instance.go b/util/config/instance.go
index d6fbee1b4..a68b0a2b9 100644
--- a/util/config/instance.go
+++ b/util/config/instance.go
@@ -3,15 +3,17 @@ package config
import (
evbus "github.com/asaskevich/EventBus"
"github.com/evcc-io/evcc/api"
+ "github.com/evcc-io/evcc/core/loadpoint"
)
var bus = evbus.New()
var instance struct {
- meters *handler[api.Meter]
- chargers *handler[api.Charger]
- vehicles *handler[api.Vehicle]
- circuits *handler[api.Circuit]
+ meters *handler[api.Meter]
+ chargers *handler[api.Charger]
+ vehicles *handler[api.Vehicle]
+ circuits *handler[api.Circuit]
+ loadpoints *handler[loadpoint.API]
}
func init() {
@@ -23,6 +25,7 @@ func Reset() {
instance.chargers = &handler[api.Charger]{topic: "charger"}
instance.vehicles = &handler[api.Vehicle]{topic: "vehicle"}
instance.circuits = &handler[api.Circuit]{topic: "circuit"}
+ instance.loadpoints = &handler[loadpoint.API]{topic: "loadpoint"}
}
type Handler[T any] interface {
@@ -49,6 +52,10 @@ func Circuits() Handler[api.Circuit] {
return instance.circuits
}
+func Loadpoints() Handler[loadpoint.API] {
+ return instance.loadpoints
+}
+
// Instances returns the instances of the given devices
func Instances[T any](devices []Device[T]) []T {
res := make([]T, 0, len(devices))
diff --git a/util/templates/class.go b/util/templates/class.go
index 42ea0c109..14a3a65db 100644
--- a/util/templates/class.go
+++ b/util/templates/class.go
@@ -9,5 +9,6 @@ const (
Meter
Vehicle
Tariff
+ Loadpoint
Circuit
)
diff --git a/util/templates/class_enumer.go b/util/templates/class_enumer.go
index 48acc8c8e..bcbf9a0b3 100644
--- a/util/templates/class_enumer.go
+++ b/util/templates/class_enumer.go
@@ -7,11 +7,11 @@ import (
"strings"
)
-const _ClassName = "chargermetervehicletariffcircuit"
+const _ClassName = "chargermetervehicletariffloadpointcircuit"
-var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 32}
+var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 34, 41}
-const _ClassLowerName = "chargermetervehicletariffcircuit"
+const _ClassLowerName = "chargermetervehicletariffloadpointcircuit"
func (i Class) String() string {
i -= 1
@@ -29,10 +29,11 @@ func _ClassNoOp() {
_ = x[Meter-(2)]
_ = x[Vehicle-(3)]
_ = x[Tariff-(4)]
- _ = x[Circuit-(5)]
+ _ = x[Loadpoint-(5)]
+ _ = x[Circuit-(6)]
}
-var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Circuit}
+var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Loadpoint, Circuit}
var _ClassNameToValueMap = map[string]Class{
_ClassName[0:7]: Charger,
@@ -43,8 +44,10 @@ var _ClassNameToValueMap = map[string]Class{
_ClassLowerName[12:19]: Vehicle,
_ClassName[19:25]: Tariff,
_ClassLowerName[19:25]: Tariff,
- _ClassName[25:32]: Circuit,
- _ClassLowerName[25:32]: Circuit,
+ _ClassName[25:34]: Loadpoint,
+ _ClassLowerName[25:34]: Loadpoint,
+ _ClassName[34:41]: Circuit,
+ _ClassLowerName[34:41]: Circuit,
}
var _ClassNames = []string{
@@ -52,7 +55,8 @@ var _ClassNames = []string{
_ClassName[7:12],
_ClassName[12:19],
_ClassName[19:25],
- _ClassName[25:32],
+ _ClassName[25:34],
+ _ClassName[34:41],
}
// ClassString retrieves an enum value from the enum constants string name.
diff --git a/vehicle/embed.go b/vehicle/embed.go
index b9416cb7c..e4259490f 100644
--- a/vehicle/embed.go
+++ b/vehicle/embed.go
@@ -40,6 +40,8 @@ func (v *embed) Capacity() float64 {
return v.Capacity_
}
+var _ api.PhaseDescriber = (*embed)(nil)
+
// Phases returns the phases used by the vehicle
func (v *embed) Phases() int {
return v.Phases_