Repository: evcc-io/evcc
Pull request #4751 (2022-10-07 16:32:41)

Add persistent settings
This PR adds a simple interface for storing/retrieving settings from database. Settings provide additional abstractor over the plain `db.Instance`.

Settings are only saved when `settings.Persist()` is called. A corresponding shutdown hook is registered during setup.

`store.Provider` and `store.Store` provide abstraction for creating a store for a single value and load/save against that store. Using these, individual devices do not need to depend on the settings implementation. See https://github.com/evcc-io/evcc/pull/4838 for example usage.


Patch:
diff --git a/api/api.go b/api/api.go
index 895775e4..88d7a3a8 100644
--- a/api/api.go
+++ b/api/api.go
@@ -164,7 +164,7 @@ type Authorizer interface {
 type Vehicle interface {
 	Battery
 	Title() string
-	Capacity() int64
+	Capacity() float64
 	Phases() int
 	Identifiers() []string
 	OnIdentified() ActionConfig
diff --git a/api/store/types.go b/api/store/types.go
new file mode 100644
index 00000000..33b652a6
--- /dev/null
+++ b/api/store/types.go
@@ -0,0 +1,10 @@
+package store
+
+// Provider creates a Persister for given string key
+type Provider func(string) Store
+
+// Store can load and store data
+type Store interface {
+	Load(any) error
+	Save(any) error
+}
diff --git a/cmd/dumper.go b/cmd/dumper.go
index 90d376cc..3350f77b 100644
--- a/cmd/dumper.go
+++ b/cmd/dumper.go
@@ -178,7 +178,7 @@ func (d *dumper) Dump(name string, v interface{}) {
 	}
 
 	if v, ok := v.(api.Vehicle); ok {
-		fmt.Fprintf(w, "Capacity:\t%dkWh\n", v.Capacity())
+		fmt.Fprintf(w, "Capacity:\t%.1fkWh\n", v.Capacity())
 		if len(v.Identifiers()) > 0 {
 			fmt.Fprintf(w, "Identifiers:\t%v\n", v.Identifiers())
 		}
diff --git a/cmd/helper.go b/cmd/helper.go
index 863475ab..39dfda3b 100644
--- a/cmd/helper.go
+++ b/cmd/helper.go
@@ -50,11 +50,11 @@ func unwrap(err error) (res []string) {
 // redact redacts a configuration string
 func redact(src string) string {
 	secrets := []string{
-		"url", "uri", "host", "broker", "mac", // infrastructure
+		"mac",                   // infrastructure
 		"sponsortoken", "plant", // global settings
 		"user", "password", "pin", // users
 		"token", "access", "refresh", // tokens
-		"ain", "id", "secret", "serial", "deviceid", "machineid", // devices
+		"ain", "secret", "serial", "deviceid", "machineid", // devices
 		"vin"} // vehicles
 	return regexp.
 		MustCompile(fmt.Sprintf(`\b(%s)\b.*?:.*`, strings.Join(secrets, "|"))).
diff --git a/cmd/setup.go b/cmd/setup.go
index 71402298..c4fc052a 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -19,6 +19,7 @@ import (
 	"github.com/evcc-io/evcc/push"
 	"github.com/evcc-io/evcc/server"
 	"github.com/evcc-io/evcc/server/db"
+	"github.com/evcc-io/evcc/server/db/settings"
 	"github.com/evcc-io/evcc/tariff"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/locale"
@@ -74,7 +75,7 @@ func configureEnvironment(cmd *cobra.Command, conf config) (err error) {
 	}
 
 	// setup sponsorship
-	if conf.SponsorToken != "" {
+	if err == nil && conf.SponsorToken != "" {
 		err = sponsor.ConfigureSponsorship(conf.SponsorToken)
 	}
 
@@ -117,7 +118,17 @@ func configureEnvironment(cmd *cobra.Command, conf config) (err error) {
 
 // configureDatabase configures session database
 func configureDatabase(conf dbConfig) error {
-	return db.NewInstance(conf.Type, conf.Dsn)
+	err := db.NewInstance(conf.Type, conf.Dsn)
+	if err == nil {
+		if err = settings.Init(); err == nil {
+			shutdown.Register(func() {
+				if err := settings.Persist(); err != nil {
+					log.ERROR.Println("cannot save settings:", err)
+				}
+			})
+		}
+	}
+	return err
 }
 
 // configureInflux configures influx database
diff --git a/docker/bin/entrypoint.sh b/docker/bin/entrypoint.sh
index 98ac5e42..280929f3 100755
--- a/docker/bin/entrypoint.sh
+++ b/docker/bin/entrypoint.sh
@@ -5,15 +5,22 @@ set -e
 HASSIO_OPTIONSFILE=/data/options.json
 
 if [ -f ${HASSIO_OPTIONSFILE} ]; then
-    CONFIG=$(grep config_file ${HASSIO_OPTIONSFILE}| cut -d ':' -f 2 | sed s/[\"}]//g )
+    CONFIG=$(grep -o '"config_file": "[^"]*' ${HASSIO_OPTIONSFILE} | grep -o '[^"]*$')
     echo "Using config file: ${CONFIG}"
 
-    if [ ! -f ${CONFIG} ]; then
+    SQLITE_FILE=$(grep -o '"sqlite_file": "[^"]*' ${HASSIO_OPTIONSFILE} | grep -o '[^"]*$')
+
+    if [ ! -f "${CONFIG}" ]; then
         echo "Config not found. Please create a config under ${CONFIG}."
         echo "For details see evcc documentation at https://github.com/evcc-io/evcc#readme."
     else
-        echo "starting evcc: 'evcc --config ${CONFIG}'"
-        exec evcc --config ${CONFIG}
+        if [ "${SQLITE_FILE}" ]; then
+            echo "starting evcc: 'evcc --config ${CONFIG} --sqlite ${SQLITE_FILE}'"
+            exec evcc --config "${CONFIG}" --sqlite "${SQLITE_FILE}"
+        else
+            echo "starting evcc: 'evcc --config ${CONFIG}'"
+            exec evcc --config "${CONFIG}"
+        fi
     fi
 else
     if [ "$1" == '"evcc"' ] || expr "$1" : '-*' > /dev/null; then
diff --git a/go.mod b/go.mod
index 50e18a49..4262f5a3 100644
--- a/go.mod
+++ b/go.mod
@@ -9,7 +9,7 @@ require (
 	github.com/PuerkitoBio/goquery v1.8.0
 	github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef
 	github.com/avast/retry-go/v3 v3.1.1
-	github.com/aws/aws-sdk-go v1.44.118
+	github.com/aws/aws-sdk-go v1.44.119
 	github.com/basgys/goxml2json v1.1.0
 	github.com/basvdlei/gotsmart v0.0.3
 	github.com/benbjohnson/clock v1.3.0
@@ -82,9 +82,9 @@ require (
 	github.com/volkszaehler/mbmd v0.0.0-20220916220750-3b12dcc33299
 	github.com/writeas/go-strip-markdown v2.0.1+incompatible
 	gitlab.com/bboehmke/sunny v0.15.1-0.20211022160056-2fba1c86ade6
-	golang.org/x/exp v0.0.0-20221012211006-4de253d81b95
-	golang.org/x/net v0.0.0-20221017152216-f25eb7ecb193
-	golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783
+	golang.org/x/exp v0.0.0-20221019170559-20944726eadf
+	golang.org/x/net v0.1.0
+	golang.org/x/oauth2 v0.1.0
 	golang.org/x/text v0.4.0
 	google.golang.org/api v0.100.0
 	google.golang.org/grpc v1.50.1
@@ -165,11 +165,11 @@ require (
 	github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
 	github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
 	go.opencensus.io v0.23.0 // indirect
-	golang.org/x/crypto v0.0.0-20221012134737-56aed061732a // indirect
-	golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
+	golang.org/x/crypto v0.1.0 // indirect
+	golang.org/x/mod v0.6.0 // indirect
 	golang.org/x/sync v0.1.0 // indirect
 	golang.org/x/sys v0.1.0 // indirect
-	golang.org/x/term v0.0.0-20221017184919-83659145692c // indirect
+	golang.org/x/term v0.1.0 // indirect
 	golang.org/x/tools v0.1.12 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/genproto v0.0.0-20221018160656-63c7b68cfc55 // indirect
diff --git a/go.sum b/go.sum
index 314da0c8..d6cbf60e 100644
--- a/go.sum
+++ b/go.sum
@@ -113,8 +113,8 @@ github.com/avast/retry-go/v3 v3.1.1 h1:49Scxf4v8PmiQ/nY0aY3p0hDueqSmc7++cBbtiDGu
 github.com/avast/retry-go/v3 v3.1.1/go.mod h1:6cXRK369RpzFL3UQGqIUp9Q7GDrams+KsYWrfNA1/nQ=
 github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
 github.com/aws/aws-sdk-go v1.27.0/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
-github.com/aws/aws-sdk-go v1.44.118 h1:FJOqIRTukf7+Ulp047/k7JB6eqMXNnj7eb+coORThHQ=
-github.com/aws/aws-sdk-go v1.44.118/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
+github.com/aws/aws-sdk-go v1.44.119 h1:TPkpDsanBMcZaF5wHwpKhjkapRV/b7d2qdC+a+IPbmY=
+github.com/aws/aws-sdk-go v1.44.119/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
 github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
 github.com/basgys/goxml2json v1.1.0 h1:4ln5i4rseYfXNd86lGEB+Vi652IsIXIvggKM/BhUKVw=
 github.com/basgys/goxml2json v1.1.0/go.mod h1:wH7a5Np/Q4QoECFIU8zTQlZwZkrilY0itPfecMw41Dw=
@@ -944,8 +944,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.0.0-20221012134737-56aed061732a h1:NmSIgad6KjE6VvHciPZuNRTKxGhlPfD6OA87W/PLkqg=
-golang.org/x/crypto v0.0.0-20221012134737-56aed061732a/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
+golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -956,8 +956,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
 golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
 golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
-golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 h1:sBdrWpxhGDdTAYNqbgBLAR+ULAPPhfgncLr1X0lyWtg=
-golang.org/x/exp v0.0.0-20221012211006-4de253d81b95/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/exp v0.0.0-20221019170559-20944726eadf h1:nFVjjKDgNY37+ZSYCJmtYf7tOlfQswHqplG2eosjOMg=
+golang.org/x/exp v0.0.0-20221019170559-20944726eadf/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
 golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
 golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -985,8 +985,8 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.6.0 h1:b9gGHsz9/HhJ3HF5DHQytPpuwocVTChQJK3AvoLRD5I=
+golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
 golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1054,8 +1054,8 @@ golang.org/x/net v0.0.0-20220513224357-95641704303c/go.mod h1:CfG3xpIq0wQ8r1q4Su
 golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
-golang.org/x/net v0.0.0-20221017152216-f25eb7ecb193 h1:3Moaxt4TfzNcQH6DWvlYKraN1ozhBXQHcgvXjRGeim0=
-golang.org/x/net v0.0.0-20221017152216-f25eb7ecb193/go.mod h1:RpDiru2p0u2F0lLpEoqnP2+7xs0ifAuOcJ442g6GU2s=
+golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
+golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1077,8 +1077,8 @@ golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j
 golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
 golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
 golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
-golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 h1:nt+Q6cXKz4MosCSpnbMtqiQ8Oz0pxTef2B4Vca2lvfk=
-golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
+golang.org/x/oauth2 v0.1.0 h1:isLCZuhj4v+tYv7eskaN4v/TM+A1begWWgyVJDdl1+Y=
+golang.org/x/oauth2 v0.1.0/go.mod h1:G9FE4dLTsbXUu90h/Pf85g4w1D+SSAgR+q46nJZ8M4A=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1200,8 +1200,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/term v0.0.0-20221017184919-83659145692c h1:dveknrit5futqEmXAvd2I1BbZIDhxRijsyWHM86NlcA=
-golang.org/x/term v0.0.0-20221017184919-83659145692c/go.mod h1:VTIZ7TEbF0BS9Sv9lPTvGbtW8i4z6GGbJBCM37uMCzY=
+golang.org/x/term v0.1.0 h1:g6Z6vPFA9dYBAF7DWcH6sCcOntplXsDKcliusYijMlw=
+golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/mock/mock_api.go b/mock/mock_api.go
index ac54f4e3..7d720556 100644
--- a/mock/mock_api.go
+++ b/mock/mock_api.go
@@ -305,10 +305,10 @@ func (m *MockVehicle) EXPECT() *MockVehicleMockRecorder {
 }
 
 // Capacity mocks base method.
-func (m *MockVehicle) Capacity() int64 {
+func (m *MockVehicle) Capacity() float64 {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "Capacity")
-	ret0, _ := ret[0].(int64)
+	ret0, _ := ret[0].(float64)
 	return ret0
 }
 
diff --git a/server/db/settings/setting.go b/server/db/settings/setting.go
new file mode 100644
index 00000000..f21f8903
--- /dev/null
+++ b/server/db/settings/setting.go
@@ -0,0 +1,105 @@
+package settings
+
+import (
+	"encoding/json"
+	"errors"
+	"strconv"
+	"sync/atomic"
+
+	"github.com/evcc-io/evcc/server/db"
+	"golang.org/x/exp/slices"
+)
+
+var ErrNotFound = errors.New("not found")
+
+// setting is a settings entry
+type setting struct {
+	Key   string `json:"key" gorm:"primarykey"`
+	Value string `json:"value"`
+}
+
+var (
+	settings []setting
+	dirty    int32
+)
+
+func Init() error {
+	err := db.Instance.AutoMigrate(new(setting))
+	if err == nil {
+		err = db.Instance.Find(&settings).Error
+	}
+	return err
+}
+
+func Persist() error {
+	dirty := atomic.CompareAndSwapInt32(&dirty, 1, 0)
+	if !dirty || len(settings) == 0 {
+		// avoid "empty slice found"
+		return nil
+	}
+	return db.Instance.Save(settings).Error
+}
+
+func SetString(key string, val string) {
+	idx := slices.IndexFunc(settings, func(s setting) bool {
+		return s.Key == key
+	})
+
+	if idx < 0 {
+		settings = append(settings, setting{key, val})
+		atomic.StoreInt32(&dirty, 1)
+	} else if settings[idx].Value != val {
+		settings[idx].Value = val
+		atomic.StoreInt32(&dirty, 1)
+	}
+}
+
+func SetInt(key string, val int64) {
+	SetString(key, strconv.FormatInt(val, 10))
+}
+
+func SetFloat(key string, val float64) {
+	SetString(key, strconv.FormatFloat(val, 'f', -1, 64))
+}
+
+func SetJson(key string, val any) error {
+	b, err := json.Marshal(val)
+	if err == nil {
+		SetString(key, string(b))
+	}
+	return err
+}
+
+func String(key string) (string, error) {
+	idx := slices.IndexFunc(settings, func(s setting) bool {
+		return s.Key == key
+	})
+	if idx < 0 {
+		return "", ErrNotFound
+	}
+	return settings[idx].Value, nil
+}
+
+func Int(key string) (int64, error) {
+	s, err := String(key)
+	if err != nil {
+		return 0, err
+	}
+	return strconv.ParseInt(s, 10, 64)
+}
+
+func Float(key string) (float64, error) {
+	s, err := String(key)
+	if err != nil {
+		return 0, err
+	}
+	return strconv.ParseFloat(s, 64)
+}
+
+func Json(key string, res any) error {
+	s, err := String(key)
+	if err == nil {
+		err = json.Unmarshal([]byte(s), &res)
+	}
+	return err
+}
diff --git a/server/db/settings/store.go b/server/db/settings/store.go
new file mode 100644
index 00000000..4c0dea76
--- /dev/null
+++ b/server/db/settings/store.go
@@ -0,0 +1,21 @@
+package settings
+
+import "github.com/evcc-io/evcc/api/store"
+
+type storer struct {
+	key string
+}
+
+var _ store.Store = (*storer)(nil)
+
+func NewStore(key string) store.Store {
+	return &storer{key: key}
+}
+
+func (s *storer) Load(res any) error {
+	return Json(s.key, &res)
+}
+
+func (s *storer) Save(val any) error {
+	return SetJson(s.key, val)
+}
diff --git a/templates/definition/meter/huawei-sun2000-dongle-powersensor.yaml b/templates/definition/meter/huawei-sun2000-dongle-powersensor.yaml
index cfc6fd98..342e1ed5 100644
--- a/templates/definition/meter/huawei-sun2000-dongle-powersensor.yaml
+++ b/templates/definition/meter/huawei-sun2000-dongle-powersensor.yaml
@@ -84,7 +84,7 @@ render: |
     register:
       address: 37001
       type: holding
-      decode: int32
+      decode: int32nan
     scale: -1
   soc:
     source: modbus
diff --git a/util/modbus/functions.go b/util/modbus/functions.go
index e3c212b6..bcb98e89 100644
--- a/util/modbus/functions.go
+++ b/util/modbus/functions.go
@@ -53,28 +53,37 @@ func decodeBool16(mask uint64) func(b []byte) float64 {
 	}
 }
 
-func decodeNaN16(nan uint16, f func(b []byte) float64) func(b []byte) float64 {
+func decodeNaN16(f func(b []byte) float64, nan ...uint16) func(b []byte) float64 {
 	return func(b []byte) float64 {
-		if binary.BigEndian.Uint16(b) == nan {
-			return 0
+		u := binary.BigEndian.Uint16(b)
+		for _, nan := range nan {
+			if u == nan {
+				return 0
+			}
 		}
 		return f(b)
 	}
 }
 
-func decodeNaN32(nan uint32, f func(b []byte) float64) func(b []byte) float64 {
+func decodeNaN32(f func(b []byte) float64, nan ...uint32) func(b []byte) float64 {
 	return func(b []byte) float64 {
-		if binary.BigEndian.Uint32(b) == nan {
-			return 0
+		u := binary.BigEndian.Uint32(b)
+		for _, nan := range nan {
+			if u == nan {
+				return 0
+			}
 		}
 		return f(b)
 	}
 }
 
-func decodeNaN64(nan uint64, f func(b []byte) float64) func(b []byte) float64 {
+func decodeNaN64(f func(b []byte) float64, nan ...uint64) func(b []byte) float64 {
 	return func(b []byte) float64 {
-		if binary.BigEndian.Uint64(b) == nan {
-			return 0
+		u := binary.BigEndian.Uint64(b)
+		for _, nan := range nan {
+			if u == nan {
+				return 0
+			}
 		}
 		return f(b)
 	}
diff --git a/util/modbus/modbus.go b/util/modbus/modbus.go
index b872c4f4..c9e19b1c 100644
--- a/util/modbus/modbus.go
+++ b/util/modbus/modbus.go
@@ -309,13 +309,13 @@ func RegisterOperation(r Register) (rs485.Operation, error) {
 		op.Transform = asFloat64(encoding.Int16)
 		op.ReadLen = 1
 	case "int16nan":
-		op.Transform = decodeNaN16(1<<15, asFloat64(encoding.Int16))
+		op.Transform = decodeNaN16(asFloat64(encoding.Int16), 1<<15, 1<<15-1)
 		op.ReadLen = 1
 	case "uint16":
 		op.Transform = asFloat64(encoding.Uint16)
 		op.ReadLen = 1
 	case "uint16nan":
-		op.Transform = decodeNaN16(0xFFFF, asFloat64(encoding.Uint16))
+		op.Transform = decodeNaN16(asFloat64(encoding.Uint16), 1<<16-1)
 		op.ReadLen = 1
 	case "bool16":
 		mask, err := decodeMask(r.BitMask)
@@ -329,7 +329,7 @@ func RegisterOperation(r Register) (rs485.Operation, error) {
 	case "int32":
 		op.Transform = asFloat64(encoding.Int32)
 	case "int32nan":
-		op.Transform = decodeNaN32(1<<31, asFloat64(encoding.Int32))
+		op.Transform = decodeNaN32(asFloat64(encoding.Int32), 1<<31, 1<<31-1)
 	case "int32s":
 		op.Transform = asFloat64(encoding.Int32LswFirst)
 	case "uint32":
@@ -337,7 +337,7 @@ func RegisterOperation(r Register) (rs485.Operation, error) {
 	case "uint32s":
 		op.Transform = asFloat64(encoding.Uint32LswFirst)
 	case "uint32nan":
-		op.Transform = decodeNaN32(0xFFFFFFFF, asFloat64(encoding.Uint32))
+		op.Transform = decodeNaN32(asFloat64(encoding.Uint32), 1<<32-1)
 	case "float32", "ieee754":
 		op.Transform = asFloat64(encoding.Float32)
 	case "float32s", "ieee754s":
@@ -348,7 +348,7 @@ func RegisterOperation(r Register) (rs485.Operation, error) {
 		op.Transform = asFloat64(encoding.Uint64)
 		op.ReadLen = 4
 	case "uint64nan":
-		op.Transform = decodeNaN64(0xFFFFFFFFFFFFFFFF, asFloat64(encoding.Uint64))
+		op.Transform = decodeNaN64(asFloat64(encoding.Uint64), 1<<64-1)
 		op.ReadLen = 4
 	case "float64":
 		op.Transform = encoding.Float64
diff --git a/vehicle/embed.go b/vehicle/embed.go
index 633def33..abcd4873 100644
--- a/vehicle/embed.go
+++ b/vehicle/embed.go
@@ -7,7 +7,7 @@ import (
 
 type embed struct {
 	Title_       string           `mapstructure:"title"`
-	Capacity_    int64            `mapstructure:"capacity"`
+	Capacity_    float64          `mapstructure:"capacity"`
 	Phases_      int              `mapstructure:"phases"`
 	Identifiers_ []string         `mapstructure:"identifiers"`
 	Features_    []api.Feature    `mapstructure:"features"`
@@ -20,7 +20,7 @@ func (v *embed) Title() string {
 }
 
 // Capacity implements the api.Vehicle interface
-func (v *embed) Capacity() int64 {
+func (v *embed) Capacity() float64 {
 	return v.Capacity_
 }
 
diff --git a/vehicle/nissan/api.go b/vehicle/nissan/api.go
index b9b279fa..92ddab85 100644
--- a/vehicle/nissan/api.go
+++ b/vehicle/nissan/api.go
@@ -14,7 +14,7 @@ import (
 const (
 	CarAdapterBaseURL  = "https://alliance-platform-caradapter-prod.apps.eu2.kamereon.io/car-adapter"
 	UserAdapterBaseURL = "https://alliance-platform-usersadapter-prod.apps.eu2.kamereon.io/user-adapter"
-	UserBaseURL        = "https://nci-bff-web-prod.apps.eu.kamereon.io/bff-web"
+	UserBaseURL        = "https://nci-bff-web-prod.apps.eu2.kamereon.io/bff-web"
 )
 
 type API struct {
@@ -45,7 +45,7 @@ func (v *API) Vehicles() ([]string, error) {
 
 	var res Vehicles
 	if err == nil {
-		uri := fmt.Sprintf("%s/v4/users/%s/cars", UserBaseURL, user.UserID)
+		uri := fmt.Sprintf("%s/v5/users/%s/cars", UserBaseURL, user.UserID)
 		err = v.GetJSON(uri, &res)
 	}
 
diff --git a/vehicle/skoda-enyaq.go b/vehicle/skoda-enyaq.go
index 495a5b41..6af50c35 100644
--- a/vehicle/skoda-enyaq.go
+++ b/vehicle/skoda-enyaq.go
@@ -65,7 +65,7 @@ func NewEnyaqFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		v.Title_ = vehicle.Name
 	}
 	if v.Capacity_ == 0 {
-		v.Capacity_ = int64(vehicle.Specification.Battery.CapacityInKWh)
+		v.Capacity_ = float64(vehicle.Specification.Battery.CapacityInKWh)
 	}
 
 	// use Connect credentials to build provider
diff --git a/vehicle/wrapper/wrapper.go b/vehicle/wrapper/wrapper.go
index 44e4fbc1..2b6e1082 100644
--- a/vehicle/wrapper/wrapper.go
+++ b/vehicle/wrapper/wrapper.go
@@ -28,7 +28,7 @@ func (v *Wrapper) Title() string {
 }
 
 // Capacity implements the api.Vehicle interface
-func (v *Wrapper) Capacity() int64 {
+func (v *Wrapper) Capacity() float64 {
 	return 0
 }
 


Test patch:
diff --git a/cmd/root_test.go b/cmd/root_test.go
index adf28764..b17dafe7 100644
--- a/cmd/root_test.go
+++ b/cmd/root_test.go
@@ -27,8 +27,8 @@ func TestRedact(t *testing.T) {
 	token:
 		access: geheim
 		refresh: geheim
-	host: geheim
-	url: geheim
+	pin: geheim
+	mac: geheim
 	secret: geheim # comment
 	secret : geheim
 	`
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index 47a8ccae..c15c5f3d 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -385,7 +385,7 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) {
 	vehicle := mock.NewMockVehicle(ctrl)
 
 	// wrap vehicle with estimator
-	vehicle.EXPECT().Capacity().Return(int64(10))
+	vehicle.EXPECT().Capacity().Return(float64(10))
 	vehicle.EXPECT().Phases().Return(0).AnyTimes()
 	socEstimator := soc.NewEstimator(util.NewLogger("foo"), charger, vehicle, false)
 
diff --git a/core/soc/estimator_test.go b/core/soc/estimator_test.go
index ed8c23f9..a03cfa4b 100644
--- a/core/soc/estimator_test.go
+++ b/core/soc/estimator_test.go
@@ -16,7 +16,7 @@ func TestRemainingChargeDuration(t *testing.T) {
 	charger := mock.NewMockCharger(ctrl)
 	vehicle := mock.NewMockVehicle(ctrl)
 	// 9 kWh userBatCap => 10 kWh virtualBatCap
-	vehicle.EXPECT().Capacity().Return(int64(9))
+	vehicle.EXPECT().Capacity().Return(float64(9))
 
 	ce := NewEstimator(util.NewLogger("foo"), charger, vehicle, false)
 	ce.vehicleSoc = 20.0
@@ -40,7 +40,7 @@ func TestSoCEstimation(t *testing.T) {
 	charger := &chargerStruct{mock.NewMockCharger(ctrl), mock.NewMockBattery(ctrl)}
 
 	// 9 kWh user battery capacity is converted to initial value of 10 kWh virtual capacity
-	var capacity int64 = 9
+	var capacity float64 = 9
 	vehicle.EXPECT().Capacity().Return(capacity)
 
 	ce := NewEstimator(util.NewLogger("foo"), charger, vehicle, true)
@@ -124,7 +124,7 @@ func TestSoCFromChargerAndVehicleWithErrors(t *testing.T) {
 	charger := &chargerStruct{mock.NewMockCharger(ctrl), mock.NewMockBattery(ctrl)}
 
 	// 9 kWh user battery capacity is converted to initial value of 10 kWh virtual capacity
-	var capacity int64 = 9
+	var capacity float64 = 9
 	vehicle.EXPECT().Capacity().Return(capacity)
 
 	ce := NewEstimator(util.NewLogger("foo"), charger, vehicle, true)
diff --git a/server/db/settings/settings_test.go b/server/db/settings/settings_test.go
new file mode 100644
index 00000000..770e5b7c
--- /dev/null
+++ b/server/db/settings/settings_test.go
@@ -0,0 +1,32 @@
+package settings
+
+import (
+	"math"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestString(t *testing.T) {
+	v := "foo"
+	SetString("string", v)
+	res, err := String("string")
+	assert.Nil(t, err)
+	assert.Equal(t, v, res)
+}
+
+func TestInt(t *testing.T) {
+	v := int64(math.MaxInt64)
+	SetInt("int64", v)
+	res, err := Int("int64")
+	assert.Nil(t, err)
+	assert.Equal(t, v, res)
+}
+
+func TestFloat(t *testing.T) {
+	v := 3.141
+	SetFloat("float64", v)
+	res, err := Float("float64")
+	assert.Nil(t, err)
+	assert.Equal(t, v, res)
+}