diff --git a/.gitignore b/.gitignore
index 81e4c508c..7bd2fa6b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ __debug_bin*
!templates/**/*.yaml
!templates/**/*-schema.json
!util/templates/**/*.yaml
+!assets/js/**/*.yaml
!package*.json
!evcc.dist.yaml
!tests/**/*.evcc.yaml
diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go
new file mode 100644
index 000000000..a69765674
--- /dev/null
+++ b/api/globalconfig/types.go
@@ -0,0 +1,142 @@
+package globalconfig
+
+import (
+ "fmt"
+ "net"
+ "strconv"
+ "time"
+
+ "github.com/evcc-io/evcc/charger/eebus"
+ "github.com/evcc-io/evcc/provider/mqtt"
+ "github.com/evcc-io/evcc/push"
+ "github.com/evcc-io/evcc/util/config"
+ "github.com/evcc-io/evcc/util/modbus"
+)
+
+type All struct {
+ Network Network
+ Log string
+ SponsorToken string
+ Plant string // telemetry plant id
+ Telemetry bool
+ Metrics bool
+ Profile bool
+ Levels map[string]string
+ Interval time.Duration
+ Database DB
+ Mqtt Mqtt
+ ModbusProxy []ModbusProxy
+ Javascript []Javascript
+ Go []Go
+ Influx Influx
+ EEBus eebus.Config
+ HEMS config.Typed
+ Messaging Messaging
+ Meters []config.Named
+ Chargers []config.Named
+ Vehicles []config.Named
+ Tariffs Tariffs
+ Site map[string]interface{}
+ Loadpoints []map[string]interface{}
+ Circuits []config.Named
+}
+
+type Javascript struct {
+ VM string
+ Script string
+}
+
+type Go struct {
+ VM string
+ Script string
+}
+
+type ModbusProxy struct {
+ Port int
+ ReadOnly string
+ modbus.Settings `mapstructure:",squash"`
+}
+
+type Mqtt struct {
+ mqtt.Config `mapstructure:",squash"`
+ Topic string `json:"topic"`
+}
+
+// Redacted implements the redactor interface used by the tee publisher
+func (m Mqtt) Redacted() any {
+ // TODO add masked password
+ return struct {
+ Broker string `json:"broker"`
+ Topic string `json:"topic"`
+ User string `json:"user"`
+ ClientID string `json:"clientID"`
+ Insecure bool `json:"insecure"`
+ }{
+ Broker: m.Broker,
+ Topic: m.Topic,
+ User: m.User,
+ ClientID: m.ClientID,
+ Insecure: m.Insecure,
+ }
+}
+
+// Influx is the influx db configuration
+type Influx struct {
+ URL string `json:"url"`
+ Database string `json:"database"`
+ Token string `json:"token"`
+ Org string `json:"org"`
+ User string `json:"user"`
+ Password string `json:"password"`
+}
+
+// Redacted implements the redactor interface used by the tee publisher
+func (c Influx) Redacted() any {
+ // TODO add masked password
+ return struct {
+ URL string `json:"url"`
+ Database string `json:"database"`
+ Org string `json:"org"`
+ User string `json:"user"`
+ }{
+ URL: c.URL,
+ Database: c.Database,
+ Org: c.Org,
+ User: c.User,
+ }
+}
+
+type DB struct {
+ Type string
+ Dsn string
+}
+
+type Messaging struct {
+ Events map[string]push.EventTemplateConfig
+ Services []config.Typed
+}
+
+type Tariffs struct {
+ Currency string
+ Grid config.Typed
+ FeedIn config.Typed
+ Co2 config.Typed
+ Planner config.Typed
+}
+
+type Network struct {
+ Schema string `json:"schema"`
+ Host string `json:"host"`
+ Port int `json:"port"`
+}
+
+func (c Network) HostPort() string {
+ if c.Schema == "http" && c.Port == 80 || c.Schema == "https" && c.Port == 443 {
+ return c.Host
+ }
+ return net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
+}
+
+func (c Network) URI() string {
+ return fmt.Sprintf("%s://%s", c.Schema, c.HostPort())
+}
diff --git a/assets/css/app.css b/assets/css/app.css
index eae80f4f6..f3ae7cee3 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -35,9 +35,11 @@
--evcc-red-rgb: 252, 68, 15;
--bs-gray-deep: #010322;
--bs-gray-dark: #28293e;
+ --bs-gray-darker: #151630;
--bs-gray-medium: #93949e;
--bs-gray-light: #b5b6c3;
--bs-gray-bright: #f3f3f7;
+ --bs-gray-brighter: #f9f9fb;
--evcc-grid: var(--bs-gray-dark);
--evcc-self: var(--evcc-dark-green);
@@ -46,6 +48,7 @@
--evcc-export: var(--evcc-yellow);
--evcc-background: var(--bs-gray-bright);
--evcc-box: var(--bs-white);
+ --evcc-box-border: var(--bs-gray-brighter);
--evcc-default-text: var(--bs-gray-dark);
--evcc-gray: var(--bs-gray-medium);
--evcc-gray-50: #93949e80;
@@ -65,6 +68,10 @@
--bs-warning: var(--evcc-orange);
--bs-warning-rgb: var(--evcc-orange-rgb);
+ --bs-danger-50: #dc354580;
+
+ --bs-danger: var(--evcc-red);
+ --bs-danger-rgb: var(--evcc-red-rgb);
--bs-danger: var(--evcc-red);
--bs-danger-rgb: var(--evcc-red-rgb);
@@ -76,12 +83,14 @@
--bs-success: var(--evcc-primary);
--bs-success-rgb: var(--bs-primary-rgb);
--bs-code-color: var(--evcc-darkest-green);
+ --evcc-backdrop: #93949eaa; /* --bs-gray-medium + transparent */
}
:root.dark {
--evcc-grid: var(--bs-gray-medium);
--evcc-background: var(--bs-gray-deep);
--evcc-box: var(--bs-gray-dark);
+ --evcc-box-border: var(--vs-gray-darker);
--evcc-default-text: var(--bs-white);
--evcc-gray: var(--bs-gray-light);
--evcc-accent1: var(--evcc-yellow);
@@ -90,15 +99,10 @@
--bs-primary: var(--evcc-dark-green);
--bs-border-color-translucent: rgba(255, 255, 255, 0.175);
--bs-code-color: var(--evcc-dark-green);
+ --evcc-backdrop: #000000aa; /* black + transparent */
color-scheme: dark;
}
-html {
- /* prevents content jump when scrollbar is activate */
- width: 100vw;
- overflow-x: hidden;
-}
-
html.no-transitions * {
transition: none !important;
}
@@ -322,7 +326,19 @@ a:hover {
}
.modal-backdrop.show {
- opacity: 0.8;
+ opacity: 1;
+}
+
+.modal-backdrop {
+ --bs-backdrop-bg: var(--evcc-backdrop);
+ -webkit-backdrop-filter: blur(8px);
+ backdrop-filter: blur(8px);
+}
+
+.modal-backdrop.fade {
+ transition-property: opacity, backdrop-filter, background-color;
+ transition-duration: var(--evcc-transition-fast);
+ transition-timing-function: linear;
}
.modal-header {
@@ -342,6 +358,13 @@ a:hover {
color: var(--evcc-default-text);
}
+.modal-content h6 {
+ margin-top: 2rem;
+ margin-bottom: 1.5rem;
+ font-size: 1rem;
+ font-weight: bold;
+}
+
/* breakpoint sm */
@media (min-width: 576px) {
.modal-content {
@@ -362,6 +385,10 @@ a:hover {
margin: 0;
}
+html:has(.modal.show) {
+ overflow-y: hidden;
+}
+
.cursor-pointer {
cursor: pointer;
}
@@ -514,6 +541,9 @@ input[type="time"]::-webkit-calendar-picker-indicator {
.w-sm-50 {
width: 50% !important;
}
+ .w-sm-25 {
+ width: 25% !important;
+ }
}
html.app .hide-in-app {
@@ -553,13 +583,20 @@ html.app .modal-dialog {
.round-box {
border-radius: 1rem;
- box-shadow: 0 0 0 0 var(--evcc-gray-50);
color: var(--evcc-default-text);
background: var(--evcc-box);
padding: 1rem;
- border: 1px solid var(--evcc-gray-50);
- transition: box-shadow var(--evcc-transition-fast) linear;
}
-.round-box:hover {
- border-color: var(--evcc-gray);
+
+.round-box--error {
+ box-shadow: 0 0 0.5rem var(--bs-danger);
+ border: 1px solid var(--bs-danger);
+}
+
+.round-box--error .tags * {
+ color: var(--bs-danger-text-emphasis) !important;
+}
+
+.alert-danger code {
+ color: var(--evcc-darkest-green);
}
diff --git a/assets/js/components/Config/CircuitsModal.vue b/assets/js/components/Config/CircuitsModal.vue
new file mode 100644
index 000000000..d11227866
--- /dev/null
+++ b/assets/js/components/Config/CircuitsModal.vue
@@ -0,0 +1,26 @@
+
+ {{ $t("config.control.description") }} {{ error }}
+
+ + +
+ ++ {{ description + " " }} + + {{ $t("config.general.docsLink") }} + +
++ {{ errorMessage }} + {{ error }} +
+ +{{ error }}
- -- {{ $t(`footer.sponsor.${isVictronDevice ? "victron" : "thanks"}`, { sponsor }) }} + {{ $t(`footer.sponsor.${isVictronDevice ? "victron" : "thanks"}`, { sponsor: name }) }}
- {{ $t("startupError.hint") }}
-
-
- {{ $t("startupError.configFile") }}
-
-
-
- {{ $t("startupError.title") }}
-
-
-
- {{ $t("startupError.configuration") }}
- {{ file }}
-
-
-
-
-
encode.New() | cache.State()
encode.New() | chan | cache.All() | chan | - |
+| time.Time | rfc3999/nil | rfc3999/nil | rfc3999 | time.Time | time.Time | ? |
+| time.Duration | s | s | s | time.Duration | time.Duration | s |
+| fmt.Stringer | string | string | string | string | string | ? |
+| struct{} | ? | recursive | recursive | n/a | ? | ? |
diff --git a/go.mod b/go.mod
index a5096fc6c..dd000cd3d 100644
--- a/go.mod
+++ b/go.mod
@@ -194,3 +194,5 @@ require (
modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.29.9 // indirect
)
+
+replace gopkg.in/yaml.v3 => github.com/andig/yaml v0.0.0-20240531135838-1ff5761ab467
diff --git a/go.sum b/go.sum
index c89c92335..768524982 100644
--- a/go.sum
+++ b/go.sum
@@ -33,6 +33,8 @@ github.com/andig/gosunspec v0.0.0-20231205122018-1daccfa17912 h1:pphS+WUa9zK0ZH/
github.com/andig/gosunspec v0.0.0-20231205122018-1daccfa17912/go.mod h1:c6P6szcR+ROkqZruOR4f6qbDKFjZX6OitPpj+yJ/r8k=
github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e h1:m/NTP3JWpR7M0ljLxiQU4fzR25jjhe1LDtxLMNcoNJQ=
github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e/go.mod h1:4VtYzTm//oUipwvO3yh0g/udTE7pYJM+U/kyAuFDsgM=
+github.com/andig/yaml v0.0.0-20240531135838-1ff5761ab467 h1:JqIoHxsQSV39xaemvVRu3HOkSf5AbhQ1YEENJa2cL3Q=
+github.com/andig/yaml v0.0.0-20240531135838-1ff5761ab467/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
@@ -921,9 +923,6 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/hems/config.go b/hems/config.go
index a5f88b15b..5de65493f 100644
--- a/hems/config.go
+++ b/hems/config.go
@@ -5,7 +5,6 @@ import (
"strings"
"github.com/evcc-io/evcc/core/site"
- "github.com/evcc-io/evcc/hems/ocpp"
"github.com/evcc-io/evcc/hems/semp"
"github.com/evcc-io/evcc/server"
)
@@ -20,8 +19,8 @@ func NewFromConfig(typ string, other map[string]interface{}, site site.API, http
switch strings.ToLower(typ) {
case "sma", "shm", "semp":
return semp.New(other, site, httpd)
- case "ocpp":
- return ocpp.New(other, site)
+ // case "ocpp":
+ // return ocpp.New(other, site)
default:
return nil, errors.New("unknown hems: " + typ)
}
diff --git a/hems/ocpp/ocpp.go b/hems/ocpp/ocpp.go
deleted file mode 100644
index dc9ce5bf8..000000000
--- a/hems/ocpp/ocpp.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package ocpp
-
-import (
- "fmt"
- "math/rand"
- "strings"
- "time"
-
- "github.com/evcc-io/evcc/api"
- "github.com/evcc-io/evcc/core/site"
- "github.com/evcc-io/evcc/hems/ocpp/profile"
- "github.com/evcc-io/evcc/util"
- "github.com/evcc-io/evcc/util/machine"
- ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6"
- ocppcore "github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
- "github.com/lorenzodonini/ocpp-go/ws"
-)
-
-// OCPP is an OCPP client
-type OCPP struct {
- log *util.Logger
- site site.API
- cp ocpp16.ChargePoint
-}
-
-const retryTimeout = 5 * time.Second
-
-// New generates OCPP chargepoint client
-func New(conf map[string]interface{}, site site.API) (*OCPP, error) {
- var cc struct {
- URI string
- StationID string
- }
-
- if err := util.DecodeOther(conf, &cc); err != nil {
- return nil, err
- }
-
- log := util.NewLogger("ocpp")
-
- if cc.StationID == "" {
- id, err := machine.ProtectedID("evcc-ocpp")
- if err == nil {
- cc.StationID = fmt.Sprintf("evcc-%s", strings.ToLower(id))
- } else {
- cc.StationID = fmt.Sprintf("evcc-%d", rand.Int31())
- }
- log.DEBUG.Println("station id:", cc.StationID)
- }
-
- ws := ws.NewClient()
- cp := ocpp16.NewChargePoint(cc.StationID, nil, ws)
-
- s := &OCPP{
- log: log,
- site: site,
- cp: cp,
- }
-
- err := cp.Start(cc.URI)
- if err == nil {
- cp.SetCoreHandler(profile.NewCore(log, profile.GetDefaultConfig()))
- cp.SetSmartChargingHandler(profile.NewSmartCharging(log))
-
- go s.errorHandler(ws.Errors())
- go s.errorHandler(cp.Errors())
- }
-
- return s, err
-}
-
-// errorHandler logs error channel
-func (s *OCPP) errorHandler(errC <-chan error) {
- for err := range errC {
- s.log.ERROR.Println(err)
- }
-}
-
-// Run executes the OCPP chargepoint client
-func (s *OCPP) Run() {
- for {
- for id, lp := range s.site.Loadpoints() {
- connector := id + 1
-
- status := ocppcore.ChargePointStatusAvailable
- if lp.GetStatus() == api.StatusC {
- status = ocppcore.ChargePointStatusCharging
- }
-
- s.log.DEBUG.Printf("send: lp-%d status: %+v", connector, status)
- if _, err := s.cp.StatusNotification(connector, ocppcore.NoError, status); err != nil {
- s.log.ERROR.Printf("lp-%d: %v", connector, err)
- }
- }
-
- time.Sleep(retryTimeout)
- }
-}
diff --git a/hems/ocpp/profile/configmap.go b/hems/ocpp/profile/configmap.go
deleted file mode 100644
index bd6943f66..000000000
--- a/hems/ocpp/profile/configmap.go
+++ /dev/null
@@ -1,135 +0,0 @@
-package profile
-
-import (
- "strconv"
-
- "github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
- "github.com/lorenzodonini/ocpp-go/ocpp1.6/types"
-)
-
-// modified from https://github.com/lorenzodonini/ocpp-go/tree/master/example/1.6/cp
-
-// configuration options
-const (
- AuthorizeRemoteTxRequests string = "AuthorizeRemoteTxRequests"
- ClockAlignedDataInterval string = "ClockAlignedDataInterval"
- ConnectionTimeOut string = "ConnectionTimeOut"
- ConnectorPhaseRotation string = "ConnectorPhaseRotation"
- GetConfigurationMaxKeys string = "GetConfigurationMaxKeys"
- HeartbeatInterval string = "HeartbeatInterval"
- LocalAuthorizeOffline string = "LocalAuthorizeOffline"
- LocalPreAuthorize string = "LocalPreAuthorize"
- MeterValuesAlignedData string = "MeterValuesAlignedData"
- MeterValuesSampledData string = "MeterValuesSampledData"
- MeterValueSampleInterval string = "MeterValueSampleInterval"
- NumberOfConnectors string = "NumberOfConnectors"
- ResetRetries string = "ResetRetries"
- StopTransactionOnEVSideDisconnect string = "StopTransactionOnEVSideDisconnect"
- StopTransactionOnInvalidID string = "StopTransactionOnInvalidId"
- StopTxnAlignedData string = "StopTxnAlignedData"
- StopTxnSampledData string = "StopTxnSampledData"
- SupportedFeatureProfiles string = "SupportedFeatureProfiles"
- TransactionMessageAttempts string = "TransactionMessageAttempts"
- TransactionMessageRetryInterval string = "TransactionMessageRetryInterval"
- UnlockConnectorOnEVSideDisconnect string = "UnlockConnectorOnEVSideDisconnect"
- WebSocketPingInterval string = "WebSocketPingInterval"
- LocalAuthListEnabled string = "LocalAuthListEnabled"
- LocalAuthListMaxLength string = "LocalAuthListMaxLength"
- SendLocalListMaxLength string = "SendLocalListMaxLength"
- ChargeProfileMaxStackLevel string = "ChargeProfileMaxStackLevel"
- ChargingScheduleAllowedChargingRateUnit string = "ChargingScheduleAllowedChargingRateUnit"
- ChargingScheduleMaxPeriods string = "ChargingScheduleMaxPeriods"
- MaxChargingProfilesInstalled string = "MaxChargingProfilesInstalled"
-)
-
-// ConfigMap defines the active configuration settings
-type ConfigMap map[string]core.ConfigurationKey
-
-// func (c ConfigMap) updateInt(key string, i int64) {
-// configKey, ok := c[key]
-// if ok {
-// configKey.Value = strconv.FormatInt(i, 10)
-// }
-// c[key] = configKey
-// }
-
-// func (c ConfigMap) updateBool(key string, b bool) {
-// configKey, ok := c[key]
-// if ok {
-// configKey.Value = strconv.FormatBool(b)
-// }
-// c[key] = configKey
-// }
-
-// func (c ConfigMap) getInt(key string) (int, bool) {
-// configKey, ok := c[key]
-// if !ok {
-// return 0, ok
-// }
-// result, err := strconv.ParseInt(configKey.Value, 10, 32)
-// if err != nil {
-// return 0, false
-// }
-// return int(result), true
-// }
-
-// func (c ConfigMap) getBool(key string) (bool, bool) {
-// configKey, ok := c[key]
-// if !ok {
-// return false, ok
-// }
-// result, err := strconv.ParseBool(configKey.Value)
-// if err != nil {
-// return false, false
-// }
-// return result, true
-// }
-
-func (c ConfigMap) set(key string, readonly bool, value string) {
- c[key] = core.ConfigurationKey{
- Key: key,
- Readonly: readonly,
- Value: &value,
- }
-}
-
-func GetDefaultConfig() ConfigMap {
- intBase := 10
-
- var cfg ConfigMap = make(map[string]core.ConfigurationKey)
-
- // readonly
- cfg.set(SupportedFeatureProfiles, true, core.ProfileName)
- cfg.set(AuthorizeRemoteTxRequests, true, strconv.FormatBool(false))
- cfg.set(GetConfigurationMaxKeys, true, strconv.FormatInt(50, intBase))
- cfg.set(NumberOfConnectors, true, strconv.FormatInt(1, intBase))
- cfg.set(LocalAuthListMaxLength, true, strconv.FormatInt(100, intBase))
- cfg.set(SendLocalListMaxLength, true, strconv.FormatInt(20, intBase))
- cfg.set(ChargeProfileMaxStackLevel, true, strconv.FormatInt(10, intBase))
- cfg.set(ChargingScheduleAllowedChargingRateUnit, true, "Power")
- cfg.set(ChargingScheduleMaxPeriods, true, strconv.FormatInt(5, intBase))
- cfg.set(MaxChargingProfilesInstalled, true, strconv.FormatInt(10, intBase))
-
- // read/write
- cfg.set(ClockAlignedDataInterval, false, strconv.FormatInt(0, intBase))
- cfg.set(ConnectionTimeOut, false, strconv.FormatInt(60, intBase))
- cfg.set(ConnectorPhaseRotation, false, "Unknown")
- cfg.set(HeartbeatInterval, false, strconv.FormatInt(86400, intBase))
- cfg.set(LocalAuthorizeOffline, false, strconv.FormatBool(true))
- cfg.set(LocalAuthListEnabled, false, strconv.FormatBool(true))
- cfg.set(LocalPreAuthorize, false, strconv.FormatBool(false))
- cfg.set(MeterValuesAlignedData, false, string(types.MeasurandEnergyActiveExportRegister))
- cfg.set(MeterValuesSampledData, false, string(types.MeasurandEnergyActiveExportRegister))
- cfg.set(MeterValueSampleInterval, false, strconv.FormatInt(5, intBase))
- cfg.set(ResetRetries, false, strconv.FormatInt(10, intBase))
- cfg.set(StopTransactionOnEVSideDisconnect, false, strconv.FormatBool(true))
- cfg.set(StopTransactionOnInvalidID, false, strconv.FormatBool(true))
- cfg.set(StopTxnAlignedData, false, strconv.FormatBool(true))
- cfg.set(StopTxnSampledData, false, string(types.MeasurandEnergyActiveExportRegister))
- cfg.set(TransactionMessageAttempts, false, strconv.FormatInt(5, intBase))
- cfg.set(TransactionMessageRetryInterval, false, strconv.FormatInt(60, intBase))
- cfg.set(UnlockConnectorOnEVSideDisconnect, false, strconv.FormatBool(true))
- cfg.set(WebSocketPingInterval, false, strconv.FormatInt(54, intBase))
-
- return cfg
-}
diff --git a/hems/ocpp/profile/core.go b/hems/ocpp/profile/core.go
deleted file mode 100644
index 6f1ff5479..000000000
--- a/hems/ocpp/profile/core.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package profile
-
-import (
- "github.com/evcc-io/evcc/util"
- "github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
- "github.com/lorenzodonini/ocpp-go/ocpp1.6/types"
-)
-
-type Core struct {
- log *util.Logger
- configuration ConfigMap
-}
-
-func NewCore(log *util.Logger, config ConfigMap) *Core {
- return &Core{
- log: log,
- configuration: config,
- }
-}
-
-// OnChangeAvailability handles the CS message
-func (s *Core) OnChangeAvailability(request *core.ChangeAvailabilityRequest) (confirmation *core.ChangeAvailabilityConfirmation, err error) {
- s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
- return core.NewChangeAvailabilityConfirmation(core.AvailabilityStatusRejected), nil
-}
-
-// OnUnlockConnector handles the CS message
-func (s *Core) OnUnlockConnector(request *core.UnlockConnectorRequest) (confirmation *core.UnlockConnectorConfirmation, err error) {
- s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
- return core.NewUnlockConnectorConfirmation(core.UnlockStatusUnlocked), nil
-}
-
-// OnRemoteStartTransaction handles the CS message
-func (s *Core) OnRemoteStartTransaction(request *core.RemoteStartTransactionRequest) (confirmation *core.RemoteStartTransactionConfirmation, err error) {
- s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
- return core.NewRemoteStartTransactionConfirmation(types.RemoteStartStopStatusRejected), nil
-}
-
-// OnRemoteStopTransaction handles the CS message
-func (s *Core) OnRemoteStopTransaction(request *core.RemoteStopTransactionRequest) (confirmation *core.RemoteStopTransactionConfirmation, err error) {
- s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
- return core.NewRemoteStopTransactionConfirmation(types.RemoteStartStopStatusRejected), nil
-}
diff --git a/hems/ocpp/profile/core_configuration.go b/hems/ocpp/profile/core_configuration.go
deleted file mode 100644
index 0b5953276..000000000
--- a/hems/ocpp/profile/core_configuration.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package profile
-
-import (
- "github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
- "golang.org/x/exp/maps"
-)
-
-// OnGetConfiguration handles the CS message
-func (s *Core) OnGetConfiguration(request *core.GetConfigurationRequest) (confirmation *core.GetConfigurationConfirmation, err error) {
- s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
-
- var resultKeys []core.ConfigurationKey
- var unknownKeys []string
-
- for _, key := range request.Key {
- configKey, ok := s.configuration[key]
- if !ok {
- unknownKeys = append(unknownKeys, *configKey.Value)
- } else {
- resultKeys = append(resultKeys, configKey)
- }
- }
-
- // return config for all keys
- if len(request.Key) == 0 {
- resultKeys = maps.Values(s.configuration)
- }
-
- s.log.TRACE.Printf("%s: configuration for requested keys: %v", request.GetFeatureName(), request.Key)
-
- conf := core.NewGetConfigurationConfirmation(resultKeys)
- conf.UnknownKey = unknownKeys
-
- return conf, nil
-}
-
-// OnChangeConfiguration handles the CS message
-func (s *Core) OnChangeConfiguration(request *core.ChangeConfigurationRequest) (confirmation *core.ChangeConfigurationConfirmation, err error) {
- s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
- return core.NewChangeConfigurationConfirmation(core.ConfigurationStatusAccepted), nil
-}
diff --git a/hems/ocpp/profile/core_other.go b/hems/ocpp/profile/core_other.go
deleted file mode 100644
index 8b7851a45..000000000
--- a/hems/ocpp/profile/core_other.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package profile
-
-import (
- "github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
-)
-
-// OnClearCache handles the CS message
-func (s *Core) OnClearCache(request *core.ClearCacheRequest) (confirmation *core.ClearCacheConfirmation, err error) {
- s.log.TRACE.Printf("recv: %s", request.GetFeatureName())
- return core.NewClearCacheConfirmation(core.ClearCacheStatusAccepted), nil
-}
-
-// OnDataTransfer handles the CS message
-func (s *Core) OnDataTransfer(request *core.DataTransferRequest) (confirmation *core.DataTransferConfirmation, err error) {
- s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
- return core.NewDataTransferConfirmation(core.DataTransferStatusAccepted), nil
-}
-
-// OnReset handles the CS message
-func (s *Core) OnReset(request *core.ResetRequest) (confirmation *core.ResetConfirmation, err error) {
- s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
- return core.NewResetConfirmation(core.ResetStatusAccepted), nil
-}
diff --git a/hems/ocpp/profile/smartcharging.go b/hems/ocpp/profile/smartcharging.go
deleted file mode 100644
index ce8d78acc..000000000
--- a/hems/ocpp/profile/smartcharging.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package profile
-
-import (
- "github.com/evcc-io/evcc/util"
- sc "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging"
-)
-
-type SmartCharging struct {
- log *util.Logger
-}
-
-func NewSmartCharging(log *util.Logger) *SmartCharging {
- return &SmartCharging{
- log: log,
- }
-}
-
-// OnSetChargingProfile handles the CS message
-func (s *SmartCharging) OnSetChargingProfile(request *sc.SetChargingProfileRequest) (confirmation *sc.SetChargingProfileConfirmation, err error) {
- s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
- return sc.NewSetChargingProfileConfirmation(sc.ChargingProfileStatusRejected), nil
-}
-
-// OnClearChargingProfile handles the CS message
-func (s *SmartCharging) OnClearChargingProfile(request *sc.ClearChargingProfileRequest) (confirmation *sc.ClearChargingProfileConfirmation, err error) {
- s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
- return sc.NewClearChargingProfileConfirmation(sc.ClearChargingProfileStatusUnknown), nil
-}
-
-// OnGetCompositeSchedule handles the CS message
-func (s *SmartCharging) OnGetCompositeSchedule(request *sc.GetCompositeScheduleRequest) (confirmation *sc.GetCompositeScheduleConfirmation, err error) {
- s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
- return sc.NewGetCompositeScheduleConfirmation(sc.GetCompositeScheduleStatusRejected), nil
-}
diff --git a/i18n/de.toml b/i18n/de.toml
index d7361b7f5..02000f4c7 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -26,13 +26,36 @@ never = "nur mit genug PV-Überschuss"
titleAdd = "Batterie Zähler hinzufügen"
titleEdit = "Batterie Zähler 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"
+
+[config.control]
+description = "Normalerweise sind die Standardwerte in Ordnung. Ändere nur etwas, wenn du weißt, was du tust."
+descriptionInterval = "Regelkreis Aktualisierungszyklus in Sekunden. Definiert, wie oft evcc Messdaten liest, die Ladeleistung anpasst und die Benutzeroberfläche aktualisiert. Kurze Intervalle (< 30s) können zu Schwingungen und unerwünschtem Verhalten führen."
+descriptionMaxGridSupply = "Nur relevant für Hybrid-Wechselrichter, die nicht in der Lage sind, die volle DC-Erzeugungsleistung über AC an das Haus auszugeben. Dieses Szenario kann zu unerwünschtem Netzbezug führen, da evcc davon ausgeht, dass die volle DC-Leistung verfügbar ist. Verwende einen Wert von mindestens 50 W, um dies zu verhindern."
+descriptionResidualPower = "Verschiebt den Betriebspunkt des Regelkreises. Wenn du eine Hausbatterie hast, wird empfohlen, einen Wert von 100 W einzustellen. Auf diese Weise erhält die Batterie eine leichte Priorität gegenüber dem Netzbezug."
+labelInterval = "Aktualisierungsintervall"
+labelMaxGridSupply = "Max. Netzbezug während des Ladens"
+labelResidualPower = "Residualleistung"
+title = "Regelverhalten"
+
[config.deviceValue]
capacity = "Kapazität"
chargeStatus = "Status"
+chargeStatusA = "nicht verbunden"
+chargeStatusB = "verbunden"
+chargeStatusC = "lädt"
+chargeStatusE = "keine Leistung"
+chargeStatusF = "Fehler"
chargedEnergy = "Geladen"
+co2 = "Netz-CO₂"
+currency = "Währung"
current = "Strom"
enabled = "Aktiviert"
energy = "Energie"
+feedinPrice = "Einspeisevergütung"
+gridPrice = "Netzpreis"
odometer = "Kilometerstand"
phaseCurrents = "Strom L1..L3"
phasePowers = "Leistung L1..L3"
@@ -41,6 +64,11 @@ power = "Leistung"
range = "Reichweite"
soc = "SoC"
socLimit = "SoC Begrenzung"
+temp = "Temperatur"
+
+[config.eebus]
+description = "Grundkonfiguration für die Kommunikation mit anderen EEBus-Geräten."
+title = "EEBus"
[config.form]
example = "Beispiel"
@@ -48,22 +76,54 @@ optional = "optional"
[config.general]
cancel = "Abbrechen"
+docsLink = "Siehe Dokumentation."
+experimental = "Experimentell"
+off = "aus"
+on = "an"
+password = "Passwort"
+remove = "Entfernen"
save = "Speichern"
+telemetry = "Telemetrie"
+title = "Titel"
[config.grid]
-titleAdd = "Stromzähler (Netz) hinzufügen"
-titleEdit = "Stromzähler (Netz) bearbeiten"
+title = "Netzzähler"
+titleAdd = "Netzzähler hinzufügen"
+titleEdit = "Netzzähler bearbeiten"
+
+[config.hems]
+description = "Connect evcc to another home energy management system."
+title = "HEMS"
+
+[config.influx]
+description = "Schreibt Ladedaten und andere Metriken in InfluxDB. Verwende Grafana oder andere Tools, um die Daten zu visualisieren."
+descriptionToken = "Siehe die InfluxDB-Dokumentation, um zu erfahren, wie du einen erstellst. https://docs.influxdata.com/influxdb/v2/admin/"
+labelBucket = "Bucket"
+labelDatabase = "Datenbank"
+labelOrg = "Organisation"
+labelPassword = "Passwort"
+labelToken = "API-Token"
+labelUrl = "URL"
+labelUser = "Benutzer"
+title = "InfluxDB"
+v1Support = "Unterstützung für InfluxDB 1.x?"
+v2Support = "Zurück zu InfluxDB 2.x"
[config.main]
addLoadpoint = "Ladepunkt hinzufügen"
addPvBattery = "PV oder Speicher hinzufügen"
addVehicle = "Fahrzeug hinzufügen"
+configured = "konfiguriert"
edit = "bearbeiten"
title = "Konfiguration"
unconfigured = "nicht konfiguriert"
vehicles = "Meine Fahrzeuge"
yaml = "Konfiguration in evcc.yaml gefunden. Nicht per UI editierbar"
+[config.messaging]
+description = "Erhalte Benachrichtigungen über Ladevorgänge und andere Ereignisse."
+title = "Benachrichtigungen"
+
[config.meter]
cancel = "Abbrechen"
delete = "Löschen"
@@ -72,6 +132,35 @@ template = "Hersteller"
titleChoice = "Was möchtest du hinzufügen?"
validateSave = "Überprüfen & speichern"
+[config.modbusproxy]
+description = "Erlaubt mehreren Clients den Zugriff auf ein einzelnes Modbus-Gerät."
+title = "Modbus-Proxy"
+
+[config.mqtt]
+authentication = "Authentifizierung"
+brokerLabel = "Server"
+description = "Verbinde evcc mit einem MQTT-Broker, um Daten mit anderen Systemen in deinem Netzwerk auszutauschen."
+descriptionClientId = "Autor der Nachrichten. Wenn leer, wird `evcc-[rand]` verwendet."
+descriptionTopic = "Leer lassen, um das Publizieren zu deaktivieren."
+labelBroker = "Broker"
+labelCheckInsecure = "Erlaube unsichere Verbindungen"
+labelClientId = "Client ID"
+labelInsecure = "Zertifikatüberprüfung"
+labelPassword = "Passwort"
+labelTopic = "Topic"
+labelUser = "Benutzer"
+publishing = "Veröffentlichen"
+title = "MQTT"
+
+[config.network]
+descriptionHost = "Verwende den .local-Suffix, um mDNS zu aktivieren. Wird zur Erkennung der mobilen App und einiger OCPP-Wallboxen benötigt."
+descriptionPort = "Port für die Web-Oberfläche und API. Du musst deine Browser-URL aktualisieren, wenn du dies änderst."
+descriptionSchema = "Beeinflusst nur die URL-Generierung. Die Auswahl von HTTPS aktiviert keine Verschlüsselung."
+labelHost = "Hostname"
+labelPort = "Port"
+labelSchema = "Schema"
+title = "Netzwerk"
+
[config.options]
[config.options.endianness]
@@ -88,7 +177,21 @@ titleEdit = "Zähler (PV) bearbeiten"
[config.section]
general = "Allgemein"
+grid = "Netzanschluss"
+integrations = "Integrationen"
+loadpoints = "Ladepunkte"
+meter = "PV & Batterie"
system = "System"
+vehicles = "Fahrzeuge"
+
+[config.sponsor]
+addToken = "Sponsortoken eingeben"
+changeToken = "Sponsortoken ä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"
[config.system]
logs = "Logs"
@@ -98,6 +201,10 @@ restartRequiredMessage = "Konfiguration geändert."
restartingDescription = "Bitte warten …"
restartingMessage = "evcc wird neu gestartet."
+[config.tariffs]
+description = "Hinterlege deine Stromtarife, um die Kosten zu berechnen."
+title = "Tarife"
+
[config.title]
description = "Wird in der Hauptansicht und im Browser-Tab angezeigt."
label = "Titel"
@@ -515,7 +622,7 @@ auto = "Automatisch"
label = "Sprache"
[settings.sponsorToken]
-expires = "Dein Sponsortoken läuft {inXDays} ab. {getNewToken} und aktualisiere deine Konfigurationsdatei."
+expires = "Dein Sponsortoken läuft {inXDays} ab. {getNewToken} und aktualisiere es hier."
getNew = "Hol dir ein Neues"
hint = "Hinweis: Wir werden das zukünftig automatisieren."
diff --git a/i18n/en.toml b/i18n/en.toml
index 6384c65f7..ad0a78108 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -26,14 +26,42 @@ never = "only with enough surplus"
titleAdd = "Add Battery Meter"
titleEdit = "Edit Battery Meter"
+[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"
+
+[config.control]
+description = "Usually the default values are fine. Only change them if you know what you are doing."
+descriptionInterval = "Control loop update cycle in seconds. Defines how often evcc reads meter data, adjusts charging power and updates the UI. Short intervals (< 30s) can cause oszillations and unwanted behavior."
+descriptionMaxGridSupply = "Only relevant for hybrid inverters that are not able to output the full DC generation power to the home via AC. This szenario can lead to unwanted grid use, since evcc assumes the full DC power is available. Use a value of at least 50 W to prevent this."
+descriptionResidualPower = "Shifts the operation point of the control loop. If you have a home battery it's recommended to set a value of 100 W. This way the battery will get slight priority over grid use."
+labelInterval = "Update interval"
+labelMaxGridSupply = "Max. grid supply while charging"
+labelResidualPower = "Residual power"
+title = "Control behavior"
+
[config.deviceValue]
+broker = "Broker"
+bucket = "Bucket"
capacity = "Capacity"
chargeStatus = "Status"
+chargeStatusA = "not connected"
+chargeStatusB = "connected"
+chargeStatusC = "charging"
+chargeStatusE = "no power"
+chargeStatusF = "error"
chargedEnergy = "Charged"
+co2 = "Grid CO₂"
+configured = "Configured"
+currency = "Currency"
current = "Current"
enabled = "Enabled"
energy = "Energy"
+feedinPrice = "Feed-in price"
+gridPrice = "Grid price"
+no = "no"
odometer = "Odometer"
+org = "Organization"
phaseCurrents = "Current L1..L3"
phasePowers = "Power L1..L3"
phaseVoltages = "Voltage L1..L3"
@@ -41,6 +69,14 @@ power = "Power"
range = "Range"
soc = "SoC"
socLimit = "Limit"
+temp = "Temperature"
+topic = "Topic"
+url = "URL"
+yes = "yes"
+
+[config.eebus]
+description = "Configuration that enables evcc to communicate with other EEBus devices."
+title = "EEBus"
[config.form]
example = "Example"
@@ -48,22 +84,54 @@ optional = "optional"
[config.general]
cancel = "Cancel"
+docsLink = "See documentation."
+experimental = "Experimental"
+off = "off"
+on = "on"
+password = "Password"
+remove = "Remove"
save = "Save"
+telemetry = "Telemetry"
+title = "Title"
[config.grid]
+title = "Grid meter"
titleAdd = "Add Grid Meter"
titleEdit = "Edit Grid Meter"
+[config.hems]
+description = "Connect evcc to another home energy management system."
+title = "HEMS"
+
+[config.influx]
+description = "Writes charging data and other metrics to InfluxDB. Use Grafana or other tools to visualize the data."
+descriptionToken = "Check the InfluxDB documentation to learn how to create one. https://docs.influxdata.com/influxdb/v2/admin/"
+labelBucket = "Bucket"
+labelDatabase = "Database"
+labelOrg = "Organization"
+labelPassword = "Password"
+labelToken = "API Token"
+labelUrl = "URL"
+labelUser = "Username"
+title = "InfluxDB"
+v1Support = "Need support for InfluxDB 1.x?"
+v2Support = "Back to InfluxDB 2.x"
+
[config.main]
addLoadpoint = "Add charge point"
addPvBattery = "Add solar or battery"
addVehicle = "Add vehicle"
+configured = "configured"
edit = "edit"
title = "Configuration"
unconfigured = "not configured"
vehicles = "My Vehicles"
yaml = "Configured in evcc.yaml. Not editable in the UI."
+[config.messaging]
+description = "Recieve messages about your charging sessions."
+title = "Notifications"
+
[config.meter]
cancel = "Cancel"
delete = "Delete"
@@ -72,6 +140,34 @@ template = "Manufacturer"
titleChoice = "What Do You Want To Add?"
validateSave = "Validate & save"
+[config.modbusproxy]
+description = "Allow multiple clients to access a single Modbus device."
+title = "Modbus Proxy"
+
+[config.mqtt]
+authentication = "Authentication"
+description = "Connect to an MQTT broker to exchange data with other systems on your network."
+descriptionClientId = "Author of the messages. If empty `evcc-[rand]` is used."
+descriptionTopic = "Leave empty to disable publishing."
+labelBroker = "Broker"
+labelCheckInsecure = "Allow self-signed certificates"
+labelClientId = "Client ID"
+labelInsecure = "Certificate validation"
+labelPassword = "Password"
+labelTopic = "Topic"
+labelUser = "Username"
+publishing = "Publishing"
+title = "MQTT"
+
+[config.network]
+descriptionHost = "Use .local suffix to enable mDNS. Relevant for discovery of the mobile app and some OCPP chargers."
+descriptionPort = "Port for the web interface and API. You'll need to update your browser URL if you change this."
+descriptionSchema = "Only affects how URLs are generated. Selecting HTTPS will not enable encryption."
+labelHost = "Hostname"
+labelPort = "Port"
+labelSchema = "Schema"
+title = "Network"
+
[config.options]
[config.options.endianness]
@@ -88,7 +184,21 @@ titleEdit = "Edit Solar Meter"
[config.section]
general = "General"
+grid = "Grid"
+integrations = "Integrations"
+loadpoints = "Charging points"
+meter = "Solar & Battery"
system = "System"
+vehicles = "Vehicles"
+
+[config.sponsor]
+addToken = "Enter sponsor token"
+changeToken = "Change sponsor 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"
[config.system]
logs = "Logs"
@@ -98,6 +208,10 @@ restartRequiredMessage = "Configuration changed."
restartingDescription = "Please wait…"
restartingMessage = "Restarting evcc."
+[config.tariffs]
+description = "Define your energy tariffs to calculate the costs of your charging sessions."
+title = "Tariffs"
+
[config.title]
description = "Displayed on main screen and browser tab."
label = "Title"
@@ -428,7 +542,9 @@ modalTitle = "Notifications"
[offline]
message = "Not connected to a server."
-reload = "Reload?"
+restart = "Restart"
+restartNeeded = "Required to apply changes."
+restarting = "Server will be back in a moment."
[passwordModal]
description = "Set a password to protect the configuration settings. Using the main screen is still possible without login."
@@ -514,7 +630,7 @@ auto = "Automatic"
label = "Language"
[settings.sponsorToken]
-expires = "You sponsor token expires {inXDays}. {getNewToken} and update your configuration file."
+expires = "You sponsor token expires {inXDays}. {getNewToken} and update it here."
getNew = "Grab a fresh one"
hint = "Note: We will automate this in the future."
diff --git a/package-lock.json b/package-lock.json
index 7cd6683e6..accb044ef 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"license": "MIT",
"dependencies": {
"@babel/core": "^7.17.2",
+ "@guolao/vue-monaco-editor": "^1.5.1",
"@h2d2/shopicons": "^1.2.0",
"@histoire/plugin-vue": "^0.17.1",
"@playwright/test": "^1.34.3",
@@ -2149,6 +2150,50 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
+ "node_modules/@guolao/vue-monaco-editor": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@guolao/vue-monaco-editor/-/vue-monaco-editor-1.5.1.tgz",
+ "integrity": "sha512-nhbQHDAwsxrdH/yitcrBgAkN8Cae0IEiYe/M3LWK8bSJRfapkbMyfTHE6Gcxsxa/6efSUZAPDo8dJWBDx5GZyA==",
+ "dependencies": {
+ "@monaco-editor/loader": "^1.4.0",
+ "vue-demi": "latest"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.7.1",
+ "monaco-editor": ">=0.43.0",
+ "vue": "^2.6.14 || >=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@guolao/vue-monaco-editor/node_modules/vue-demi": {
+ "version": "0.14.7",
+ "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.7.tgz",
+ "integrity": "sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==",
+ "hasInstallScript": true,
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@h2d2/shopicons": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@h2d2/shopicons/-/shopicons-1.9.0.tgz",
@@ -2462,6 +2507,17 @@
"@lezer/common": "^1.0.0"
}
},
+ "node_modules/@monaco-editor/loader": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz",
+ "integrity": "sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==",
+ "dependencies": {
+ "state-local": "^1.0.6"
+ },
+ "peerDependencies": {
+ "monaco-editor": ">= 0.21.0 < 1"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -6930,6 +6986,12 @@
"ufo": "^1.5.3"
}
},
+ "node_modules/monaco-editor": {
+ "version": "0.47.0",
+ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.47.0.tgz",
+ "integrity": "sha512-VabVvHvQ9QmMwXu4du008ZDuyLnHs9j7ThVFsiJoXSOQk18+LF89N4ADzPbFenm0W4V2bGHnFBztIRQTgBfxzw==",
+ "peer": true
+ },
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -8194,6 +8256,11 @@
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="
},
+ "node_modules/state-local": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
+ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="
+ },
"node_modules/statuses": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
diff --git a/package.json b/package.json
index 953509d95..a114046a4 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"main": "index.js",
"dependencies": {
"@babel/core": "^7.17.2",
+ "@guolao/vue-monaco-editor": "^1.5.1",
"@h2d2/shopicons": "^1.2.0",
"@histoire/plugin-vue": "^0.17.1",
"@playwright/test": "^1.34.3",
@@ -66,4 +67,4 @@
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^7.7.1"
}
-}
+}
\ No newline at end of file
diff --git a/playwright.config.js b/playwright.config.js
index c10ac944d..6ee55f81e 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -8,7 +8,6 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
timeout: 15000, // 15s (default 30s)
- expect: { timeout: 2500 }, // 2.5s (default 5s)
workers: process.env.CI ? 3 : 8,
reporter: "html",
use: {
diff --git a/provider/config.go b/provider/config.go
index 38a540e72..3935428a8 100644
--- a/provider/config.go
+++ b/provider/config.go
@@ -59,7 +59,7 @@ var registry providerRegistry = make(map[string]func(map[string]interface{}) (Pr
// Config is the general provider config
type Config struct {
Source string
- Other map[string]interface{} `mapstructure:",remain"`
+ Other map[string]any `mapstructure:",remain" yaml:",inline"`
}
// NewIntGetterFromConfig creates a IntGetter from config
diff --git a/provider/mqtt/client.go b/provider/mqtt/client.go
index ba3f9718c..69702b7d6 100644
--- a/provider/mqtt/client.go
+++ b/provider/mqtt/client.go
@@ -26,11 +26,11 @@ func ClientID() string {
// Config is the public configuration
type Config struct {
- Broker string
- User string
- Password string
- ClientID string
- Insecure bool
+ Broker string `json:"broker"`
+ User string `json:"user"`
+ Password string `json:"password"`
+ ClientID string `json:"clientID"`
+ Insecure bool `json:"insecure"`
}
// Client encapsulates mqtt publish/subscribe functions
diff --git a/push/hub.go b/push/hub.go
index 1640dc6d1..1b141da56 100644
--- a/push/hub.go
+++ b/push/hub.go
@@ -94,7 +94,7 @@ func (h *Hub) apply(ev Event, tmpl string) (string, error) {
}
// Run is the Hub's main publishing loop
-func (h *Hub) Run(events <-chan Event, valueChan chan util.Param) {
+func (h *Hub) Run(events <-chan Event, valueChan chan<- util.Param) {
log := util.NewLogger("push")
for ev := range events {
diff --git a/server/db/settings/setting.go b/server/db/settings/setting.go
index 26a4e6b65..d0d1d8129 100644
--- a/server/db/settings/setting.go
+++ b/server/db/settings/setting.go
@@ -1,16 +1,22 @@
package settings
import (
+ "bytes"
"cmp"
"encoding/json"
"errors"
+ "io"
+ "reflect"
"slices"
"strconv"
+ "strings"
"sync"
"sync/atomic"
"time"
"github.com/evcc-io/evcc/server/db"
+ "github.com/evcc-io/evcc/util"
+ "gopkg.in/yaml.v3"
)
var ErrNotFound = errors.New("not found")
@@ -56,13 +62,34 @@ func All() []setting {
return res
}
+func equal(key string) func(setting) bool {
+ return func(s setting) bool {
+ return s.Key == key
+ }
+}
+
+func Delete(key string) error {
+ mu.Lock()
+ defer mu.Unlock()
+
+ if idx := slices.IndexFunc(settings, equal(key)); idx >= 0 {
+ if err := db.Instance.Delete(setting{
+ Key: settings[idx].Key,
+ }).Error; err != nil {
+ return err
+ }
+
+ settings = slices.Delete(settings, idx, idx)
+ }
+
+ return nil
+}
+
func SetString(key string, val string) {
mu.Lock()
defer mu.Unlock()
- idx := slices.IndexFunc(settings, func(s setting) bool {
- return s.Key == key
- })
+ idx := slices.IndexFunc(settings, equal(key))
if idx < 0 {
settings = append(settings, setting{key, val})
@@ -97,13 +124,28 @@ func SetJson(key string, val any) error {
return err
}
+func SetYaml(key string, val any) error {
+ var b bytes.Buffer
+ err := yaml.NewEncoder(&b).Encode(val)
+ if err == nil {
+ SetString(key, strings.TrimSpace(b.String()))
+ }
+ return err
+}
+
+func Exists(key string) bool {
+ mu.RLock()
+ defer mu.RUnlock()
+
+ s, err := String(key)
+ return err == nil && len(s) > 0
+}
+
func String(key string) (string, error) {
mu.RLock()
defer mu.RUnlock()
- idx := slices.IndexFunc(settings, func(s setting) bool {
- return s.Key == key
- })
+ idx := slices.IndexFunc(settings, equal(key))
if idx < 0 {
return "", ErrNotFound
}
@@ -144,10 +186,44 @@ func Bool(key string) (bool, error) {
func Json(key string, res any) error {
s, err := String(key)
- if err == nil {
- err = json.Unmarshal([]byte(s), &res)
+ if err != nil {
+ return err
}
- return err
+ if s == "" {
+ return ErrNotFound
+ }
+ return json.Unmarshal([]byte(s), &res)
+}
+
+func DecodeOtherSliceOrMap(other, res any) error {
+ var len int
+ if typ := reflect.TypeOf(other); typ.Kind() == reflect.Slice || typ.Kind() == reflect.Map {
+ len = reflect.ValueOf(other).Len()
+ } else {
+ panic("invalid kind: " + typ.Kind().String())
+ }
+
+ if len == 0 {
+ return nil
+ }
+
+ return util.DecodeOther(other, &res)
+}
+
+func Yaml(key string, other, res any) error {
+ s, err := String(key)
+ if err != nil {
+ return err
+ }
+ if s == "" {
+ return ErrNotFound
+ }
+
+ if err := yaml.NewDecoder(bytes.NewBuffer([]byte(s))).Decode(&other); err != nil && err != io.EOF {
+ return err
+ }
+
+ return DecodeOtherSliceOrMap(other, res)
}
// wrapping Settings into a struct for better decoupling
diff --git a/server/http.go b/server/http.go
index f109715ca..c9b933250 100644
--- a/server/http.go
+++ b/server/http.go
@@ -6,10 +6,14 @@ import (
"time"
eapi "github.com/evcc-io/evcc/api"
+ "github.com/evcc-io/evcc/api/globalconfig"
+ "github.com/evcc-io/evcc/charger/eebus"
+ "github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/assets"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/auth"
+ "github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/telemetry"
"github.com/go-http-utils/etag"
"github.com/gorilla/handlers"
@@ -84,7 +88,7 @@ func (s *HTTPd) Router() *mux.Router {
}
// RegisterSiteHandlers connects the http handlers to the site
-func (s *HTTPd) RegisterSiteHandlers(site site.API, auth auth.Auth, cache *util.Cache) {
+func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param) {
router := s.Server.Handler.(*mux.Router)
// api
@@ -98,10 +102,10 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, auth auth.Auth, cache *util.
// site api
routes := map[string]route{
"health": {"GET", "/health", healthHandler(site)},
- "state": {"GET", "/state", stateHandler(cache)},
"buffersoc": {"POST", "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
"bufferstartsoc": {"POST", "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
"batterydischargecontrol": {"POST", "/batterydischargecontrol/{value:[a-z]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)},
+ "maxgridsupply": {"POST", "/maxgridsupply/{value:[0-9.]+}", floatHandler(site.SetMaxGridSupplyWhileBatteryCharging, site.GetMaxGridSupplyWhileBatteryCharging)},
"prioritysoc": {"POST", "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
"residualpower": {"POST", "/residualpower/{value:-?[0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
"smartcost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", updateSmartCostLimit(site)},
@@ -119,22 +123,11 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, auth auth.Auth, cache *util.
// config ui (secured)
configApi := api.PathPrefix("/config").Subrouter()
- configApi.Use(ensureAuthHandler(auth))
+ // TODO clarify location of site config
configRoutes := map[string]route{
- "templates": {"GET", "/templates/{class:[a-z]+}", templatesHandler},
- "products": {"GET", "/products/{class:[a-z]+}", productsHandler},
- "devices": {"GET", "/devices/{class:[a-z]+}", devicesHandler},
- "device": {"GET", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceConfigHandler},
- "devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
- "site": {"GET", "/site", siteHandler(site)},
- "dirty": {"GET", "/dirty", boolGetHandler(ConfigDirty)},
- "updatesite": {"PUT", "/site", updateSiteHandler(site)},
- "newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler},
- "updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
- "deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler},
- "testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler},
- "testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler},
+ "site": {"GET", "/site", siteHandler(site)},
+ "updatesite": {"PUT", "/site", updateSiteHandler(site)},
}
for _, r := range configRoutes {
@@ -190,55 +183,113 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, auth auth.Auth, cache *util.
}
}
-// RegisterAuthHandlers provides authentication handlers
-func (s *HTTPd) RegisterAuthHandlers(auth auth.Auth) {
- router := s.Server.Handler.(*mux.Router)
-
- // api
- api := router.PathPrefix("/api/auth").Subrouter()
- api.Use(jsonHandler)
- api.Use(handlers.CompressHandler)
- api.Use(handlers.CORS(
- handlers.AllowedHeaders([]string{"Content-Type"}),
- ))
-
- // auth api
- routes := map[string]route{
- "password": {"PUT", "/password", updatePasswordHandler(auth)},
- "auth": {"GET", "/status", authStatusHandler(auth)},
- "login": {"POST", "/login", loginHandler(auth)},
- "logout": {"POST", "/logout", logoutHandler},
- }
-
- for _, r := range routes {
- api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
- }
-}
-
// RegisterSystemHandler provides system level handlers
-func (s *HTTPd) RegisterSystemHandler(auth auth.Auth, shutdown func()) {
+func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.Cache, shutdown func()) {
router := s.Server.Handler.(*mux.Router)
+ auth := auth.New()
// api
- api := router.PathPrefix("/api/system").Subrouter()
+ api := router.PathPrefix("/api").Subrouter()
api.Use(jsonHandler)
- api.Use(ensureAuthHandler(auth))
api.Use(handlers.CompressHandler)
api.Use(handlers.CORS(
handlers.AllowedHeaders([]string{"Content-Type"}),
))
- // system api
- routes := map[string]route{
- "log": {"GET", "/log", logHandler},
- "logareas": {"GET", "/log/areas", logAreasHandler},
- "shutdown": {"POST", "/shutdown", func(w http.ResponseWriter, r *http.Request) {
- shutdown()
- w.WriteHeader(http.StatusNoContent)
- }},
+ { // /api
+ routes := map[string]route{
+ "state": {"GET", "/state", stateHandler(cache)},
+ }
+
+ for _, r := range routes {
+ api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
+ }
}
- for _, r := range routes {
- api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
+ {
+ // api/auth
+ api := api.PathPrefix("/auth").Subrouter()
+
+ routes := map[string]route{
+ "password": {"PUT", "/password", updatePasswordHandler(auth)},
+ "auth": {"GET", "/status", authStatusHandler(auth)},
+ "login": {"POST", "/login", loginHandler(auth)},
+ "logout": {"POST", "/logout", logoutHandler},
+ }
+
+ for _, r := range routes {
+ api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
+ }
+ }
+
+ { // api/config
+ api := api.PathPrefix("/config").Subrouter()
+ api.Use(ensureAuthHandler(auth))
+
+ 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},
+ "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", boolGetHandler(ConfigDirty)},
+ "newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler},
+ "updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
+ "deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler},
+ "testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler},
+ "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)},
+ }
+
+ // yaml handlers
+ for key, fun := range map[string]func() (any, any){
+ keys.EEBus: func() (any, any) { return new(map[string]any), eebus.Config{} },
+ keys.Hems: func() (any, any) { return new(map[string]any), config.Typed{} },
+ keys.Tariffs: func() (any, any) { return new(map[string]any), globalconfig.Tariffs{} },
+ keys.Messaging: func() (any, any) { return new(map[string]any), globalconfig.Messaging{} }, // has default
+ keys.ModbusProxy: func() (any, any) { return new([]map[string]any), []globalconfig.ModbusProxy{} }, // slice
+ keys.Circuits: func() (any, any) { return new([]map[string]any), []config.Named{} }, // slice
+ } {
+ other, struc := fun()
+ routes[key] = route{Method: "GET", Pattern: "/" + key, HandlerFunc: settingsGetStringHandler(key)}
+ routes["update"+key] = route{Method: "POST", Pattern: "/" + key, HandlerFunc: settingsSetYamlHandler(key, other, struc)}
+ routes["delete"+key] = route{Method: "DELETE", Pattern: "/" + key, HandlerFunc: settingsDeleteHandler(key)}
+ }
+
+ // json handlers
+ for key, fun := range map[string]func() any{
+ keys.Network: func() any { return new(globalconfig.Network) }, // has default
+ keys.Mqtt: func() any { return new(globalconfig.Mqtt) }, // has default
+ keys.Influx: func() any { return new(globalconfig.Influx) },
+ } {
+ // routes[key] = route{Method: "GET", Pattern: "/" + key, HandlerFunc: settingsGetJsonHandler(key, fun())}
+ routes["update"+key] = route{Method: "POST", Pattern: "/" + key, HandlerFunc: settingsSetJsonHandler(key, valueChan, fun())}
+ routes["delete"+key] = route{Method: "DELETE", Pattern: "/" + key, HandlerFunc: settingsDeleteJsonHandler(key, valueChan, fun())}
+ }
+
+ for _, r := range routes {
+ api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
+ }
+ }
+
+ { // api/system
+ api := api.PathPrefix("/system").Subrouter()
+ api.Use(ensureAuthHandler(auth))
+
+ // system api
+ routes := map[string]route{
+ "log": {"GET", "/log", logHandler},
+ "logareas": {"GET", "/log/areas", logAreasHandler},
+ "shutdown": {"POST", "/shutdown", func(w http.ResponseWriter, r *http.Request) {
+ shutdown()
+ w.WriteHeader(http.StatusNoContent)
+ }},
+ }
+
+ for _, r := range routes {
+ api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
+ }
}
}
diff --git a/server/http_config_helper.go b/server/http_config_helper.go
index bc87cc2cb..49020bc3b 100644
--- a/server/http_config_helper.go
+++ b/server/http_config_helper.go
@@ -2,6 +2,7 @@ package server
import (
"errors"
+ "slices"
"sync"
"github.com/evcc-io/evcc/api"
@@ -133,7 +134,11 @@ func testInstance(instance any) map[string]testResult {
if dev, ok := instance.(api.Battery); ok {
val, err := dev.Soc()
- res["soc"] = makeResult(val, err)
+ key := "soc"
+ if fd, ok := instance.(api.FeatureDescriber); ok && slices.Contains(fd.Features(), api.Heating) {
+ key = "temp"
+ }
+ res[key] = makeResult(val, err)
}
if dev, ok := instance.(api.VehicleOdometer); ok {
diff --git a/server/http_config_site_other_handler.go b/server/http_config_site_other_handler.go
new file mode 100644
index 000000000..6ee5a7b43
--- /dev/null
+++ b/server/http_config_site_other_handler.go
@@ -0,0 +1,34 @@
+package server
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/evcc-io/evcc/core/keys"
+ "github.com/evcc-io/evcc/server/db/settings"
+ "github.com/evcc-io/evcc/util/sponsor"
+)
+
+func updateSponsortokenHandler(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Token string `json:"token"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ if req.Token != "" {
+ if err := sponsor.ConfigureSponsorship(req.Token); err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+ }
+
+ // TODO find better place
+ settings.SetString(keys.SponsorToken, req.Token)
+ setConfigDirty()
+
+ jsonResult(w, sponsor.Status())
+}
diff --git a/server/http_global_settings_handler.go b/server/http_global_settings_handler.go
new file mode 100644
index 000000000..7c46a623e
--- /dev/null
+++ b/server/http_global_settings_handler.go
@@ -0,0 +1,113 @@
+package server
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/evcc-io/evcc/server/db/settings"
+ "github.com/evcc-io/evcc/util"
+ "github.com/gorilla/mux"
+ "gopkg.in/yaml.v3"
+)
+
+func settingsGetStringHandler(key string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ res, _ := settings.String(key)
+ jsonResult(w, res)
+ }
+}
+
+func settingsDeleteHandler(key string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ settings.SetString(key, "")
+ jsonResult(w, true)
+ }
+}
+
+func settingsSetDurationHandler(key string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+
+ val, err := strconv.Atoi(vars["value"])
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ settings.SetInt(key, int64(time.Second*time.Duration(val)))
+ setConfigDirty()
+
+ jsonResult(w, val)
+ }
+}
+
+func settingsSetYamlHandler(key string, other, struc any) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ b, err := io.ReadAll(r.Body)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ if err := yaml.NewDecoder(bytes.NewBuffer(b)).Decode(&other); err != nil && err != io.EOF {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ if err := settings.DecodeOtherSliceOrMap(other, &struc); err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ val := strings.TrimSpace(string(b))
+ settings.SetString(key, val)
+ setConfigDirty()
+
+ jsonResult(w, val)
+ }
+}
+
+// func settingsGetJsonHandler(key string, struc any) http.HandlerFunc {
+// return func(w http.ResponseWriter, r *http.Request) {
+// if err := settings.Json(key, &struc); err != nil && err != settings.ErrNotFound {
+// jsonError(w, http.StatusInternalServerError, err)
+// return
+// }
+
+// jsonResult(w, struc)
+// }
+// }
+
+func settingsSetJsonHandler(key string, valueChan chan<- util.Param, struc any) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ dec := json.NewDecoder(r.Body)
+ dec.DisallowUnknownFields()
+ if err := dec.Decode(&struc); err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ settings.SetJson(key, struc)
+ setConfigDirty()
+
+ valueChan <- util.Param{Key: key, Val: struc}
+
+ jsonResult(w, true)
+ }
+}
+
+func settingsDeleteJsonHandler(key string, valueChan chan<- util.Param, struc any) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ settings.SetString(key, "")
+ setConfigDirty()
+
+ valueChan <- util.Param{Key: key, Val: struc}
+
+ jsonResult(w, true)
+ }
+}
diff --git a/server/http_handler_test.go b/server/http_handler_test.go
deleted file mode 100644
index 9fd70b106..000000000
--- a/server/http_handler_test.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package server
-
-import (
- "math"
- "testing"
-
- "github.com/stretchr/testify/assert"
-)
-
-func TestNaNInf(t *testing.T) {
- c := map[string]any{
- "foo": math.NaN(),
- "bar": math.Inf(0),
- }
- encodeFloats(c)
- assert.Equal(t, map[string]any{"foo": nil, "bar": nil}, c, "NaN not encoded as nil")
-}
diff --git a/server/http_site_handler.go b/server/http_site_handler.go
index 58285f870..7f6e2f3b6 100644
--- a/server/http_site_handler.go
+++ b/server/http_site_handler.go
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"io/fs"
- "math"
"net/http"
"strconv"
"strings"
@@ -16,11 +15,13 @@ import (
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/assets"
"github.com/evcc-io/evcc/util"
+ "github.com/evcc-io/evcc/util/encode"
"github.com/evcc-io/evcc/util/jq"
"github.com/evcc-io/evcc/util/logstash"
"github.com/gorilla/mux"
"github.com/itchyny/gojq"
"golang.org/x/text/language"
+ "gopkg.in/yaml.v3"
)
var ignoreState = []string{"releaseNotes"} // excessive size
@@ -85,7 +86,26 @@ func jsonResult(w http.ResponseWriter, res interface{}) {
func jsonError(w http.ResponseWriter, status int, err error) {
w.WriteHeader(status)
- jsonWrite(w, map[string]interface{}{"error": err.Error()})
+
+ res := struct {
+ Error string `json:"error"`
+ Line int `json:"line,omitempty"`
+ }{
+ Error: err.Error(),
+ }
+
+ var (
+ ype *yaml.ParserError
+ yue yaml.UnmarshalError
+ )
+ switch {
+ case errors.As(err, &ype):
+ res.Line = ype.Line
+ case errors.As(err, &yue):
+ res.Line = yue.Line
+ }
+
+ jsonWrite(w, res)
}
func handler[T any](conv func(string) (T, error), set func(T) error, get func() T) http.HandlerFunc {
@@ -128,19 +148,6 @@ func boolGetHandler(get func() bool) http.HandlerFunc {
}
}
-// encodeFloats replaces NaN and Inf with nil
-// TODO handle hierarchical data
-func encodeFloats(data map[string]any) {
- for k, v := range data {
- switch v := v.(type) {
- case float64:
- if math.IsNaN(v) || math.IsInf(v, 0) {
- data[k] = nil
- }
- }
- }
-}
-
// updateSmartCostLimit sets the smart cost limit globally
func updateSmartCostLimit(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -163,13 +170,11 @@ func updateSmartCostLimit(site site.API) http.HandlerFunc {
// stateHandler returns the combined state
func stateHandler(cache *util.Cache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- res := cache.State()
+ res := cache.State(encode.NewEncoder(encode.WithDuration()))
for _, k := range ignoreState {
delete(res, k)
}
- encodeFloats(res)
-
if q := r.URL.Query().Get("jq"); q != "" {
q = strings.TrimPrefix(q, ".result")
diff --git a/server/influxdb.go b/server/influxdb.go
index 4ccb18c4e..64edd9188 100644
--- a/server/influxdb.go
+++ b/server/influxdb.go
@@ -16,17 +16,6 @@ import (
influxlog "github.com/influxdata/influxdb-client-go/v2/log"
)
-// InfluxConfig is the influx db configuration
-type InfluxConfig struct {
- URL string
- Database string
- Token string
- Org string
- User string
- Password string
- Interval time.Duration
-}
-
// Influx is a influx publisher
type Influx struct {
sync.Mutex
diff --git a/server/mqtt.go b/server/mqtt.go
index 199d8d635..f0f399321 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -62,7 +62,7 @@ func (m *MQTT) encode(v interface{}) string {
return strconv.FormatInt(val.Unix(), 10)
case time.Duration:
// must be before stringer to convert to seconds instead of string
- return fmt.Sprintf("%d", int64(val.Seconds()))
+ return strconv.Itoa(int(val.Seconds()))
case fmt.Stringer:
return val.String()
default:
diff --git a/server/socket_helper.go b/server/socket_helper.go
index a8a0e6413..c512da66e 100644
--- a/server/socket_helper.go
+++ b/server/socket_helper.go
@@ -3,49 +3,27 @@ package server
import (
"encoding/json"
"fmt"
- "math"
"reflect"
"strings"
- "time"
"github.com/evcc-io/evcc/util"
+ "github.com/evcc-io/evcc/util/encode"
)
-func encode(v interface{}) (string, error) {
- var s string
- switch val := v.(type) {
- case time.Time:
- if val.IsZero() {
- s = "null"
- } else {
- s = fmt.Sprintf(`"%s"`, val.Format(time.RFC3339))
- }
- case time.Duration:
- // must be before stringer to convert to seconds instead of string
- s = fmt.Sprintf("%d", int64(val.Seconds()))
- case float64:
- if math.IsNaN(val) {
- s = "null"
- } else {
- s = fmt.Sprintf("%.5g", val)
- }
- default:
- if b, err := json.Marshal(v); err == nil {
- s = string(b)
- } else {
- return "", err
- }
- }
- return s, nil
+var enc = encode.NewEncoder(encode.WithDuration())
+
+func encodeAsString(v any) (string, error) {
+ b, err := json.Marshal(enc.Encode(v))
+ return string(b), err
}
-func encodeSlice(v interface{}) (string, error) {
+func encodeSliceAsString(v any) (string, error) {
rv := reflect.ValueOf(v)
res := make([]string, rv.Len())
for i := 0; i < rv.Len(); i++ {
var err error
- if res[i], err = encode(rv.Index(i).Interface()); err != nil {
+ if res[i], err = encodeAsString(rv.Index(i).Interface()); err != nil {
return "", err
}
}
@@ -61,9 +39,9 @@ func kv(p util.Param) string {
// unwrap slices
if p.Val != nil && reflect.TypeOf(p.Val).Kind() == reflect.Slice {
- val, err = encodeSlice(p.Val)
+ val, err = encodeSliceAsString(p.Val)
} else {
- val, err = encode(p.Val)
+ val, err = encodeAsString(p.Val)
}
if err != nil {
diff --git a/server/socket_test.go b/server/socket_test.go
index 03288eb91..5817c1284 100644
--- a/server/socket_test.go
+++ b/server/socket_test.go
@@ -18,16 +18,16 @@ func TestEncode(t *testing.T) {
}{
{int64(1), "1"},
{math.NaN(), "null"},
- {float64(1.23456), "1.2346"},
+ {float64(1.23456), "1.235"},
{"1.2345", "\"1.2345\""},
{time.Hour, "3600"},
{"minpv", "\"minpv\""},
{time.Time{}, "null"},
- {now, "\"" + now.Format(time.RFC3339) + "\""},
+ {now, `"` + now.Format(time.RFC3339) + `"`},
}
for _, tc := range tc {
- out, err := encode(tc.in)
+ out, err := encodeAsString(tc.in)
require.NoError(t, err)
assert.Equal(t, tc.out, out)
}
@@ -44,7 +44,7 @@ func TestEncodeSlice(t *testing.T) {
}
for _, tc := range tc {
- out, err := encodeSlice(tc.in)
+ out, err := encodeSliceAsString(tc.in)
require.NoError(t, err)
assert.Equal(t, tc.out, out)
}
diff --git a/tests/auth.spec.js b/tests/auth.spec.js
index 32233cb24..1092137f1 100644
--- a/tests/auth.spec.js
+++ b/tests/auth.spec.js
@@ -109,7 +109,7 @@ test("update password", async ({ page }) => {
await loginOld.getByRole("button", { name: "Login" }).click();
// update password
- await page.getByTestId("generalconfig-password").getByRole("link", { name: "edit" }).click();
+ await page.getByTestId("generalconfig-password").getByRole("button", { name: "edit" }).click();
await expect(modal.getByRole("heading", { name: "Update Administrator Password" })).toBeVisible();
await modal.getByLabel("Current password").fill(oldPassword);
await modal.getByLabel("New password").fill(newPassword);
@@ -131,7 +131,7 @@ test("update password", async ({ page }) => {
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
// revert password
- await page.getByTestId("generalconfig-password").getByRole("link", { name: "edit" }).click();
+ await page.getByTestId("generalconfig-password").getByRole("button", { name: "edit" }).click();
await modal.getByLabel("Current password").fill(newPassword);
await modal.getByLabel("New password").fill(oldPassword);
await modal.getByLabel("Repeat password").fill(oldPassword);
diff --git a/tests/config-meters.spec.js b/tests/config-meters.spec.js
index fa3823429..24d3b1701 100644
--- a/tests/config-meters.spec.js
+++ b/tests/config-meters.spec.js
@@ -23,7 +23,7 @@ async function login(page) {
async function enableExperimental(page) {
await page
.getByTestId("generalconfig-experimental")
- .getByRole("link", { name: "change" })
+ .getByRole("button", { name: "edit" })
.click();
await page.getByLabel("Experimental 🧪").click();
await page.getByRole("button", { name: "Close" }).click();
@@ -52,8 +52,8 @@ test.describe("meters", async () => {
await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible();
await meterModal.getByRole("link", { name: "validate" }).click();
- await expect(meterModal.getByText("SoC: 75.0%")).toBeVisible();
- await expect(meterModal.getByText("Power: -2.5 kW")).toBeVisible();
+ 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(page.getByTestId("battery")).toBeVisible(1);
await expect(page.getByTestId("battery")).toContainText("openems");
@@ -63,11 +63,12 @@ test.describe("meters", async () => {
await meterModal.getByLabel("Battery capacity in kWh").fill("20");
await meterModal.getByRole("button", { name: "Validate & save" }).click();
- await expect(page.getByTestId("battery")).toBeVisible(1);
- await expect(page.getByTestId("battery")).toContainText("openems");
- await expect(page.getByTestId("battery").getByText("SoC: 75.0%")).toBeVisible();
- await expect(page.getByTestId("battery").getByText("Power: -2.5 kW")).toBeVisible();
- await expect(page.getByTestId("battery").getByText("Capacity: 20.0 kWh")).toBeVisible();
+ const battery = page.getByTestId("battery");
+ await expect(battery).toBeVisible(1);
+ await expect(battery).toContainText("openems");
+ await expect(battery.getByTestId("device-tag-soc")).toContainText("75.0%");
+ await expect(battery.getByTestId("device-tag-power")).toContainText("-2.5 kW");
+ await expect(battery.getByTestId("device-tag-capacity")).toContainText("20.0 kWh");
// restart and check in main ui
await restart(CONFIG_EMPTY);
diff --git a/tests/config-mqtt.spec.js b/tests/config-mqtt.spec.js
new file mode 100644
index 000000000..9d253f49e
--- /dev/null
+++ b/tests/config-mqtt.spec.js
@@ -0,0 +1,76 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, restart, baseUrl } from "./evcc";
+
+const CONFIG = "config-empty.evcc.yaml";
+
+test.use({ baseURL: baseUrl() });
+
+test.beforeEach(async ({ page }) => {
+ await start(CONFIG, "password.sql");
+ await page.goto("/#/config");
+ await login(page);
+ await enableExperimental(page);
+});
+
+test.afterEach(async () => {
+ await stop();
+});
+
+async function login(page) {
+ await page.locator("#loginPassword").fill("secret");
+ await page.getByRole("button", { name: "Login" }).click();
+}
+
+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();
+}
+
+test.describe("mqtt", async () => {
+ test("mqtt not configured", async ({ page }) => {
+ await expect(page.getByTestId("mqtt")).toBeVisible();
+ await expect(page.getByTestId("mqtt")).toContainText(["Configured", "no"].join(""));
+ });
+
+ test("mqtt via ui", async ({ page }) => {
+ await page.getByTestId("mqtt").getByRole("button", { name: "edit" }).click();
+ const modal = await page.getByTestId("mqtt-modal");
+
+ await modal.getByLabel("Broker").fill("unknown.example.org");
+ await modal.getByLabel("Topic").fill("my-topic");
+ await modal.getByLabel("Client ID").fill("my-client-id");
+
+ await page.getByRole("button", { name: "Save" }).click();
+ await expect(modal.getByTestId("error")).not.toBeVisible();
+ await expect(modal).not.toBeVisible();
+
+ // restart button appears
+ const restartButton = await page
+ .getByTestId("bottom-banner")
+ .getByRole("button", { name: "Restart" });
+ await expect(restartButton).toBeVisible();
+
+ await restart(CONFIG);
+
+ // config error
+ await expect(restartButton).not.toBeVisible();
+ await expect(page.getByTestId("mqtt")).toHaveClass(/round-box--error/);
+ await expect(page.getByTestId("mqtt")).toContainText(
+ ["Broker", "unknown.example.org", "Topic", "my-topic"].join("")
+ );
+ await expect(page.getByTestId("bottom-banner")).toContainText("failed configuring mqtt");
+
+ await page.getByTestId("mqtt").getByRole("button", { name: "edit" }).click();
+ await page.getByRole("button", { name: "Remove" }).click();
+ await expect(page.getByTestId("mqtt")).toContainText(["Configured", "no"].join(""));
+ await expect(restartButton).toBeVisible();
+ await restart(CONFIG);
+ await expect(restartButton).not.toBeVisible();
+ await expect(page.getByTestId("mqtt")).not.toHaveClass(/round-box--error/);
+ await expect(page.getByTestId("mqtt")).toContainText(["Configured", "no"].join(""));
+ });
+});
diff --git a/tests/config-tariffs.spec.js b/tests/config-tariffs.spec.js
new file mode 100644
index 000000000..853c5f98e
--- /dev/null
+++ b/tests/config-tariffs.spec.js
@@ -0,0 +1,117 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, restart, baseUrl } from "./evcc";
+
+const CONFIG_EMPTY = "config-empty.evcc.yaml";
+const CONFIG_WITH_TARIFFS = "config-with-tariffs.evcc.yaml";
+
+test.use({ baseURL: baseUrl() });
+
+test.afterEach(async () => {
+ await stop();
+});
+
+const SELECT_ALL = "ControlOrMeta+KeyA";
+
+async function login(page) {
+ await page.locator("#loginPassword").fill("secret");
+ await page.getByRole("button", { name: "Login" }).click();
+}
+
+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 goToConfig(page) {
+ await page.goto("/#/config");
+ await login(page);
+ await enableExperimental(page);
+}
+
+test.describe("tariffs", async () => {
+ test("tariffs not configured", async ({ page }) => {
+ await start(CONFIG_EMPTY, "password.sql");
+ await goToConfig(page);
+
+ await expect(page.getByTestId("tariffs")).toBeVisible();
+ await expect(page.getByTestId("tariffs")).toContainText(
+ ["Tariffs", "Currency", "EUR"].join("")
+ );
+ });
+
+ test("tariffs via ui", async ({ page }) => {
+ await start(CONFIG_EMPTY, "password.sql");
+ await goToConfig(page);
+
+ await page.getByTestId("tariffs").getByRole("button", { name: "edit" }).click();
+ const modal = await page.getByTestId("tariffs-modal");
+ await expect(modal).toBeVisible();
+
+ // default content
+ await expect(modal).toContainText("# currency: EUR");
+
+ // clear and enter invalid yaml
+ await modal.locator(".monaco-editor .view-line").nth(0).click();
+ await page.keyboard.press(SELECT_ALL);
+ await page.keyboard.press("Backspace");
+ await page.keyboard.press(SELECT_ALL);
+ await page.keyboard.press("Backspace");
+ await page.keyboard.type("foo: bar\n");
+ await page.getByRole("button", { name: "Save" }).click();
+ await expect(modal.getByTestId("error")).toContainText("invalid keys: foo");
+
+ // clear and enter valid yaml
+ await modal.locator(".monaco-editor .view-line").nth(0).click();
+ await page.keyboard.press(SELECT_ALL);
+ await page.keyboard.press("Backspace");
+ await page.keyboard.type("currency: CHF\n");
+ await page.keyboard.type("grid:\n");
+ await page.keyboard.type(" type: fixed\n");
+ await page.keyboard.type("price: 0.123\n");
+
+ await page.getByRole("button", { name: "Save" }).click();
+ await expect(modal.getByTestId("error")).not.toBeVisible();
+
+ // modal closes
+ await expect(modal).not.toBeVisible();
+
+ // restart button appears
+ const restartButton = await page
+ .getByTestId("bottom-banner")
+ .getByRole("button", { name: "Restart" });
+ await expect(restartButton).toBeVisible();
+
+ await restart(CONFIG_EMPTY);
+
+ // restart done
+ await expect(restartButton).not.toBeVisible();
+
+ await expect(page.getByTestId("tariffs")).toContainText(
+ ["Currency", "CHF", "Grid price", "12.3 rp"].join("")
+ );
+ });
+
+ test("tariffs from evcc.yaml", async ({ page }) => {
+ await start(CONFIG_WITH_TARIFFS, "password.sql");
+ await goToConfig(page);
+
+ await expect(page.getByTestId("tariffs")).toBeVisible();
+ await expect(page.getByTestId("tariffs")).toContainText(
+ [
+ "Tariffs",
+ "Currency",
+ "SEK",
+ "Grid price",
+ "0.3 SEK",
+ "Feed-in price",
+ "-0.1 SEK",
+ "Grid CO₂",
+ "300 g",
+ ].join("")
+ );
+ });
+});
diff --git a/tests/config-vehicles.spec.js b/tests/config-vehicles.spec.js
index 5e7e4c5e4..c35821e0f 100644
--- a/tests/config-vehicles.spec.js
+++ b/tests/config-vehicles.spec.js
@@ -21,7 +21,7 @@ async function login(page) {
async function enableExperimental(page) {
await page
.getByTestId("generalconfig-experimental")
- .getByRole("link", { name: "change" })
+ .getByRole("button", { name: "edit" })
.click();
await page.getByLabel("Experimental 🧪").click();
await page.getByRole("button", { name: "Close" }).click();
diff --git a/tests/config-with-tariffs.evcc.yaml b/tests/config-with-tariffs.evcc.yaml
new file mode 100755
index 000000000..aa05642c0
--- /dev/null
+++ b/tests/config-with-tariffs.evcc.yaml
@@ -0,0 +1,53 @@
+site:
+ title: Hello World
+ meters:
+ grid: grid
+
+meters:
+ - name: grid
+ type: custom
+ power:
+ source: js
+ script: |
+ 1000
+
+loadpoints:
+ - title: Carport
+ charger: charger
+
+vehicles:
+ - name: my_bike
+ type: template
+ template: offline
+ title: YAML Bike
+ icon: bike
+
+chargers:
+ - name: charger
+ type: custom
+ enable:
+ source: js
+ script:
+ enabled:
+ source: js
+ script: |
+ false
+ status:
+ source: js
+ script: |
+ "B"
+ maxcurrent:
+ source: js
+ script:
+
+tariffs:
+ currency: SEK
+ grid:
+ type: fixed
+ price: 0.30
+ feedin:
+ type: fixed
+ price: 0.10
+ co2:
+ type: fixed
+ price: 300
diff --git a/tests/config.spec.js b/tests/config.spec.js
index 15a9da81a..ba326787f 100644
--- a/tests/config.spec.js
+++ b/tests/config.spec.js
@@ -20,7 +20,7 @@ async function login(page) {
async function enableExperimental(page) {
await page
.getByTestId("generalconfig-experimental")
- .getByRole("link", { name: "change" })
+ .getByRole("button", { name: "edit" })
.click();
await page.getByLabel("Experimental 🧪").click();
await page.getByRole("button", { name: "Close" }).click();
@@ -34,7 +34,7 @@ test.describe("basics", async () => {
await login(page);
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
});
- test("alert box should always be visible", async ({ page }) => {
+ test.skip("alert box should always be visible", async ({ page }) => {
await page.goto("/#/config");
await login(page);
await enableExperimental(page);
@@ -54,7 +54,7 @@ test.describe("general", async () => {
await enableExperimental(page);
await expect(page.getByTestId("generalconfig-title")).toContainText("Hello World");
- await page.getByTestId("generalconfig-title").getByRole("link", { name: "edit" }).click();
+ await page.getByTestId("generalconfig-title").getByRole("button", { name: "edit" }).click();
const modal = page.getByTestId("title-modal");
await expect(modal).toBeVisible();
await modal.getByLabel("Title").fill("Whoops World");
@@ -65,7 +65,7 @@ test.describe("general", async () => {
await expect(page.getByTestId("generalconfig-title")).toContainText("Hello World");
// change and save value
- await page.getByTestId("generalconfig-title").getByRole("link", { name: "edit" }).click();
+ await page.getByTestId("generalconfig-title").getByRole("button", { name: "edit" }).click();
await modal.getByLabel("Title").fill("Ahoy World");
await modal.getByRole("button", { name: "Save" }).click();
await expect(modal).not.toBeVisible();
diff --git a/tests/evcc.js b/tests/evcc.js
index b8ef6dd40..81309e745 100644
--- a/tests/evcc.js
+++ b/tests/evcc.js
@@ -65,9 +65,7 @@ async function _start(config) {
instance.stdout.pipe(process.stdout);
instance.stderr.pipe(process.stderr);
instance.on("exit", (code) => {
- if (code !== 0) {
- throw new Error("evcc terminated", code);
- }
+ console.log("evcc terminated", code);
});
await waitOn({ resources: [baseUrl()] });
}
@@ -78,6 +76,9 @@ async function _stop() {
console.log(res.status, res.statusText);
const cookie = res.headers["set-cookie"];
await axios.post(`${baseUrl()}/api/system/shutdown`, {}, { headers: { cookie } });
+ console.log("wait until network port is closed");
+ await waitOn({ resources: [`tcp:localhost:${port()}`], reverse: true });
+ console.log("evcc is down");
}
async function _clean() {
diff --git a/tests/logs.spec.js b/tests/logs.spec.js
index 867ec493d..7df2a289e 100644
--- a/tests/logs.spec.js
+++ b/tests/logs.spec.js
@@ -46,6 +46,6 @@ test.describe("features", async () => {
test("content", async ({ page }) => {
await page.goto("/#/log");
await login(page);
- await expect(page.getByTestId("log-content")).toContainText("starting ui and api at");
+ await expect(page.getByTestId("log-content")).toContainText("listening at");
});
});
diff --git a/util/cache.go b/util/cache.go
index 2818d4c34..a2746dc32 100644
--- a/util/cache.go
+++ b/util/cache.go
@@ -3,11 +3,14 @@ package util
import (
"fmt"
"sync"
+
+ "github.com/evcc-io/evcc/util/encode"
+ "golang.org/x/exp/maps"
)
// Cache is a data store
type Cache struct {
- sync.Mutex
+ mu sync.RWMutex
val map[string]Param
}
@@ -48,30 +51,31 @@ func (c *Cache) Run(in <-chan Param) {
}
}
-// State provides a structured copy of the cached values
-// Loadpoints are aggregated as loadpoints array
-func (c *Cache) State() map[string]interface{} {
- c.Lock()
- defer c.Unlock()
+// State provides a structured copy of the cached values.
+// Loadpoints are aggregated as loadpoints array.
+// Result values are formatted using encoder.
+func (c *Cache) State(enc encode.Encoder) map[string]any {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
- res := map[string]interface{}{}
- lps := make(map[int]map[string]interface{})
+ res := make(map[string]any)
+ lps := make(map[int]map[string]any)
for _, param := range c.val {
if param.Loadpoint == nil {
- res[param.Key] = param.Val
+ res[param.Key] = enc.Encode(param.Val)
} else {
lp, ok := lps[*param.Loadpoint]
if !ok {
- lp = make(map[string]interface{})
+ lp = make(map[string]any)
lps[*param.Loadpoint] = lp
}
- lp[param.Key] = param.Val
+ lp[param.Key] = enc.Encode(param.Val)
}
}
// convert map to array
- loadpoints := make([]map[string]interface{}, len(lps))
+ loadpoints := make([]map[string]any, len(lps))
for id, lp := range lps {
loadpoints[id] = lp
}
@@ -82,29 +86,24 @@ func (c *Cache) State() map[string]interface{} {
// All provides a copy of the cached values
func (c *Cache) All() []Param {
- c.Lock()
- defer c.Unlock()
+ c.mu.RLock()
+ defer c.mu.RUnlock()
- copy := make([]Param, 0, len(c.val))
- for _, val := range c.val {
- copy = append(copy, val)
- }
-
- return copy
+ return maps.Values(c.val)
}
// Add entry to cache
func (c *Cache) Add(key string, param Param) {
- c.Lock()
- defer c.Unlock()
+ c.mu.Lock()
+ defer c.mu.Unlock()
c.val[key] = param
}
// Get entry from cache
func (c *Cache) Get(key string) Param {
- c.Lock()
- defer c.Unlock()
+ c.mu.RLock()
+ defer c.mu.RUnlock()
if val, ok := c.val[key]; ok {
return val
diff --git a/util/config/types.go b/util/config/types.go
index 9e7334b9c..4af9e13b7 100644
--- a/util/config/types.go
+++ b/util/config/types.go
@@ -5,14 +5,14 @@ import (
)
type Typed struct {
- Type string `json:"type"`
- Other map[string]interface{} `mapstructure:",remain"`
+ Type string `json:"type"`
+ Other map[string]any `mapstructure:",remain" yaml:",inline"`
}
type Named struct {
- Name string `json:"name"`
- Type string `json:"type"`
- Other map[string]interface{} `mapstructure:",remain"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Other map[string]any `mapstructure:",remain" yaml:",inline"`
}
// Property returns the value of the named property
diff --git a/util/encode/encode.go b/util/encode/encode.go
new file mode 100644
index 000000000..88776c4d1
--- /dev/null
+++ b/util/encode/encode.go
@@ -0,0 +1,67 @@
+package encode
+
+import (
+ "fmt"
+ "math"
+ "time"
+)
+
+type encoder struct {
+ withDuration bool
+}
+
+type Encoder interface {
+ Encode(v any) any
+}
+
+type EncoderOption func(*encoder)
+
+// NewEncoder creates a new encoder with the following default conversions:
+// - float NaN/Inf are converted to nil
+// - zero time.Time are converted to nil
+// - durations are converted to seconds using WithDuration()
+// - fmt.Stringer are converted to string
+// - time.Time are converted to RFC3339 string
+func NewEncoder(opt ...EncoderOption) Encoder {
+ res := new(encoder)
+ for _, o := range opt {
+ o(res)
+ }
+ return res
+}
+
+func WithDuration() EncoderOption {
+ return func(e *encoder) {
+ e.withDuration = true
+ }
+}
+
+// Encode provides a consumer-friendly default encoding for any type
+func (e encoder) Encode(v any) any {
+ switch val := v.(type) {
+ case time.Time:
+ if val.IsZero() {
+ return nil
+ }
+ return val.Format(time.RFC3339)
+
+ case time.Duration:
+ // must be before stringer to convert to seconds instead of string
+ if e.withDuration {
+ return int(val.Seconds())
+ }
+ return val
+
+ case float64:
+ if math.IsNaN(val) || math.IsInf(val, 0) {
+ return nil
+ }
+ return math.Round(val*1e3) / 1e3
+
+ case fmt.Stringer:
+ return val.String()
+
+ default:
+ return val
+ }
+}
diff --git a/util/encode/encode_test.go b/util/encode/encode_test.go
new file mode 100644
index 000000000..74fb3eaed
--- /dev/null
+++ b/util/encode/encode_test.go
@@ -0,0 +1,18 @@
+package encode
+
+import (
+ "math"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestEncode(t *testing.T) {
+ enc := NewEncoder(WithDuration())
+ assert.Equal(t, nil, enc.Encode(time.Time{}))
+ assert.Equal(t, nil, enc.Encode(math.NaN()))
+ assert.Equal(t, nil, enc.Encode(math.Inf(0)))
+ assert.Equal(t, 30, enc.Encode(30*time.Second))
+ assert.Equal(t, 3.142, enc.Encode(math.Pi))
+}
diff --git a/util/providerauth.go b/util/providerauth.go
index 248c0d7c5..bd44ad469 100644
--- a/util/providerauth.go
+++ b/util/providerauth.go
@@ -34,11 +34,12 @@ func (ac *AuthCollection) Publish() {
defer ac.mu.Unlock()
val := struct {
- Vehicles map[string]*AuthProvider `json:"vehicles"`
+ Vehicles map[string]*AuthProvider `json:"vehicles,omitempty"`
}{
Vehicles: ac.vehicles,
}
+ // TODO registered global key name
ac.paramC <- Param{Key: "auth", Val: val}
}
diff --git a/util/sponsor/auth.go b/util/sponsor/auth.go
index 56243153e..26bf1331d 100644
--- a/util/sponsor/auth.go
+++ b/util/sponsor/auth.go
@@ -3,6 +3,7 @@ package sponsor
import (
"context"
"fmt"
+ "sync"
"time"
"github.com/evcc-io/evcc/api/proto/pb"
@@ -12,25 +13,33 @@ import (
)
var (
+ mu sync.Mutex
Subject, Token string
ExpiresAt time.Time
)
const (
unavailable = "sponsorship unavailable"
- victron = "victron-device"
+ victron = "victron"
)
func IsAuthorized() bool {
+ mu.Lock()
+ defer mu.Unlock()
return len(Subject) > 0
}
func IsAuthorizedForApi() bool {
- return IsAuthorized() && Token != ""
+ mu.Lock()
+ defer mu.Unlock()
+ return IsAuthorized() && Subject != unavailable && Token != ""
}
// check and set sponsorship token
func ConfigureSponsorship(token string) error {
+ mu.Lock()
+ defer mu.Unlock()
+
if token == "" {
if sub := checkVictron(); sub != "" {
Subject = sub
@@ -71,3 +80,23 @@ func ConfigureSponsorship(token string) error {
return err
}
+
+type sponsorStatus struct {
+ Name string `json:"name"`
+ ExpiresAt time.Time `json:"expiresAt,omitempty"`
+ ExpiresSoon bool `json:"expiresSoon,omitempty"`
+}
+
+// Status returns the sponsorship status
+func Status() sponsorStatus {
+ var expiresSoon bool
+ if d := time.Until(ExpiresAt); d < 30*24*time.Hour && d > 0 {
+ expiresSoon = true
+ }
+
+ return sponsorStatus{
+ Name: Subject,
+ ExpiresAt: ExpiresAt,
+ ExpiresSoon: expiresSoon,
+ }
+}
diff --git a/util/tee.go b/util/tee.go
index 6fb4e8736..dae3bcafa 100644
--- a/util/tee.go
+++ b/util/tee.go
@@ -32,6 +32,10 @@ func (t *Tee) add(out chan<- Param) {
t.recv = append(t.recv, out)
}
+type redactor interface {
+ Redacted() any
+}
+
// Run starts parameter distribution
func (t *Tee) Run(in <-chan Param) {
for msg := range in {
@@ -41,6 +45,10 @@ func (t *Tee) Run(in <-chan Param) {
}
}
+ if val, ok := (msg.Val).(redactor); ok {
+ msg.Val = val.Redacted()
+ }
+
t.mu.Lock()
for _, recv := range t.recv {
recv <- msg