Repository: evcc-io/evcc
Pull request #19031 (2025-02-21 11:34:33)

Config: prevent mixing yaml and db data
fixes https://github.com/evcc-io/evcc/issues/18945

- prevent merging data from yaml and db entries
- UI configuration always takes precedence over `evcc.yaml` values (top level)

VolkerK62: >UI configuration always takes precedence over evcc.yaml values (top level)

Was heißt "top level"?
Ich habe bei `meters` und bei `vehicles` noch ein Mix wegen custom.
Oder gilt der PR nur für Tarife?
naltatis: Ja korrekt.
Ladepunkte, Fahrzeuge, Meter, Charger und Site werden weiterhin zusammengeführt (mischen ist möglich). Bei allem anderen übersteuert der UI Wert (falls gesetzt) die `evcc.yaml`.

Patch:
diff --git a/.gitignore b/.gitignore
index 0e9fef08..f9a1e6bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
 __debug_bin*
 .vscode
+!.vscode/extensions.json
 .cache
 .DS_store
 *.gz
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 00000000..dc9cabb6
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,8 @@
+{
+  "recommendations": [
+    "Vue.volar",
+    "vitest.explorer",
+    "golang.go",
+    "esbenp.prettier-vscode",
+  ]
+}
diff --git a/assets/js/colors.js b/assets/js/colors.js
index 5bc721b5..25f2be07 100644
--- a/assets/js/colors.js
+++ b/assets/js/colors.js
@@ -65,7 +65,9 @@ function updateCssColors() {
 
 // update colors on theme change
 const darkModeMatcher = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
-darkModeMatcher?.addEventListener("change", updateCssColors);
+if (darkModeMatcher && darkModeMatcher.addEventListener) {
+  darkModeMatcher.addEventListener("change", updateCssColors);
+}
 // initialize colors
 updateCssColors();
 window.requestAnimationFrame(updateCssColors);
diff --git a/assets/js/components/Sessions/SessionDetailsModal.vue b/assets/js/components/Sessions/SessionDetailsModal.vue
index f49c1a00..b2d0aee2 100644
--- a/assets/js/components/Sessions/SessionDetailsModal.vue
+++ b/assets/js/components/Sessions/SessionDetailsModal.vue
@@ -26,7 +26,21 @@
 										{{ $t("sessions.loadpoint") }}
 									</th>
 									<td>
-										{{ session.loadpoint }}
+										<CustomSelect
+											id="session.vehicle"
+											class="options"
+											:options="loadpointOptions"
+											:selected="session.loadpoint"
+											@change="changeLoadpoint($event.target.value)"
+										>
+											<span class="flex-grow-1 text-truncate loadpoint-name">
+												{{
+													session.loadpoint
+														? session.loadpoint
+														: $t("main.loadpoint.fallbackName")
+												}}
+											</span>
+										</CustomSelect>
 									</td>
 								</tr>
 								<tr>
@@ -185,17 +199,19 @@ import "@h2d2/shopicons/es/regular/checkmark";
 import Modal from "bootstrap/js/dist/modal";
 import formatter from "../../mixins/formatter";
 import VehicleOptions from "../VehicleOptions.vue";
+import CustomSelect from "../CustomSelect.vue";
 import { distanceUnit, distanceValue } from "../../units";
 import api from "../../api";
 
 export default {
 	name: "SessionDetailsModal",
-	components: { VehicleOptions },
+	components: { VehicleOptions, CustomSelect },
 	mixins: [formatter],
 	props: {
 		session: Object,
 		currency: String,
 		vehicles: Array,
+		loadpoints: Array,
 	},
 	emits: ["session-changed"],
 	computed: {
@@ -215,6 +231,12 @@ export default {
 				title: v.title,
 			}));
 		},
+		loadpointOptions: function () {
+			return this.loadpoints.map((loadpoint) => ({
+				value: loadpoint,
+				name: loadpoint,
+			}));
+		},
 	},
 	methods: {
 		openSessionDetailsModal() {
@@ -236,6 +258,9 @@ export default {
 		async removeVehicle() {
 			await this.updateSession({ vehicle: null });
 		},
+		async changeLoadpoint(title) {
+			await this.updateSession({ loadpoint: title });
+		},
 		async updateSession(data) {
 			try {
 				await api.put("session/" + this.session.id, data);
@@ -260,4 +285,8 @@ export default {
 .options .vehicle-name {
 	text-decoration: underline;
 }
+
+.options .loadpoint-name {
+	text-decoration: underline;
+}
 </style>
diff --git a/assets/js/theme.js b/assets/js/theme.js
index 130532d2..a4f42fc8 100644
--- a/assets/js/theme.js
+++ b/assets/js/theme.js
@@ -62,7 +62,9 @@ function updateMetaThemeForBackdrop() {
 }
 
 export function watchThemeChanges() {
-  darkModeMatcher?.addEventListener("change", updateTheme);
+  if (darkModeMatcher && darkModeMatcher.addEventListener) {
+    darkModeMatcher.addEventListener("change", updateTheme);
+  }
   updateTheme();
 
   // listen for modal backdrops
diff --git a/assets/js/views/Sessions.vue b/assets/js/views/Sessions.vue
index 8e07ae84..be4260fd 100644
--- a/assets/js/views/Sessions.vue
+++ b/assets/js/views/Sessions.vue
@@ -182,6 +182,7 @@
 			<SessionDetailsModal
 				:session="selectedSession"
 				:vehicles="vehicleList"
+				:loadpoints="loadpointList"
 				:currency="currency"
 				@session-changed="loadSessions"
 			/>
@@ -470,6 +471,10 @@ export default {
 			const vehicles = store.state.vehicles || {};
 			return Object.entries(vehicles).map(([name, vehicle]) => ({ name, ...vehicle }));
 		},
+		loadpointList() {
+			const loadpoints = store.state.loadpoints || [];
+			return loadpoints.map(({ title }) => title);
+		},
 		selectedSession() {
 			return this.sessions.find((s) => s.id == this.selectedSessionId);
 		},
diff --git a/cmd/root.go b/cmd/root.go
index b232ec79..a905d6aa 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -205,9 +205,18 @@ func runRoot(cmd *cobra.Command, args []string) {
 		if err == nil && influx != nil {
 			// eliminate duplicate values
 			dedupe := pipe.NewDeduplicator(30*time.Minute,
-				keys.VehicleSoc, keys.VehicleRange, keys.VehicleOdometer,
-				keys.TariffCo2, keys.TariffFeedIn, keys.TariffGrid,
-				keys.ChargedEnergy, keys.ChargeRemainingEnergy)
+				keys.VehicleSoc,
+				keys.VehicleRange,
+				keys.VehicleOdometer,
+				keys.TariffCo2,
+				keys.TariffCo2Home,
+				keys.TariffCo2Loadpoints,
+				keys.TariffFeedIn,
+				keys.TariffGrid,
+				keys.TariffPriceHome,
+				keys.TariffPriceLoadpoints,
+				keys.ChargedEnergy,
+				keys.ChargeRemainingEnergy)
 			go influx.Run(site, dedupe.Pipe(
 				pipe.NewDropper(append(ignoreLogs, ignoreEmpty, keys.Forecast)...).Pipe(tee.Attach()),
 			))
diff --git a/cmd/setup.go b/cmd/setup.go
index 147718fd..35b1f229 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -142,15 +142,16 @@ func isWritable(filePath string) bool {
 	return true
 }
 
-func configureCircuits(conf []config.Named) error {
+func configureCircuits(conf *[]config.Named) error {
 	// migrate settings
 	if settings.Exists(keys.Circuits) {
+		conf = &[]config.Named{}
 		if err := settings.Yaml(keys.Circuits, new([]map[string]any), &conf); err != nil {
 			return err
 		}
 	}
 
-	children := slices.Clone(conf)
+	children := slices.Clone(*conf)
 
 	// TODO: check for circular references
 NEXT:
@@ -641,6 +642,7 @@ func configureGo(conf []globalconfig.Go) error {
 func configureHEMS(conf *globalconfig.Hems, site *core.Site, httpd *server.HTTPd) error {
 	// migrate settings
 	if settings.Exists(keys.Hems) {
+		conf = new(globalconfig.Hems)
 		if err := settings.Yaml(keys.Hems, new(map[string]any), &conf); err != nil {
 			return err
 		}
@@ -687,6 +689,7 @@ func configureMDNS(conf globalconfig.Network) error {
 func configureEEBus(conf *eebus.Config) error {
 	// migrate settings
 	if settings.Exists(keys.EEBus) {
+		conf = new(eebus.Config)
 		if err := settings.Yaml(keys.EEBus, new(map[string]any), &conf); err != nil {
 			return err
 		}
@@ -711,6 +714,7 @@ func configureEEBus(conf *eebus.Config) error {
 func configureMessengers(conf *globalconfig.Messaging, vehicles push.Vehicles, valueChan chan<- util.Param, cache *util.ParamCache) (chan push.Event, error) {
 	// migrate settings
 	if settings.Exists(keys.Messaging) {
+		conf = new(globalconfig.Messaging)
 		if err := settings.Yaml(keys.Messaging, new(map[string]any), &conf); err != nil {
 			return nil, err
 		}
@@ -797,9 +801,10 @@ func configureSolarTariff(conf []config.Typed, t *api.Tariff) error {
 	return nil
 }
 
-func configureTariffs(conf globalconfig.Tariffs) (*tariff.Tariffs, error) {
+func configureTariffs(conf *globalconfig.Tariffs) (*tariff.Tariffs, error) {
 	// migrate settings
 	if settings.Exists(keys.Tariffs) {
+		conf = new(globalconfig.Tariffs)
 		if err := settings.Yaml(keys.Tariffs, new(map[string]any), &conf); err != nil {
 			return nil, err
 		}
@@ -847,7 +852,7 @@ func configureDevices(conf globalconfig.All) error {
 	if err := configureVehicles(conf.Vehicles); err != nil {
 		return &ClassError{ClassVehicle, err}
 	}
-	if err := configureCircuits(conf.Circuits); err != nil {
+	if err := configureCircuits(&conf.Circuits); err != nil {
 		return &ClassError{ClassCircuit, err}
 	}
 	return nil
@@ -856,7 +861,8 @@ func configureDevices(conf globalconfig.All) error {
 func configureModbusProxy(conf *[]globalconfig.ModbusProxy) error {
 	// migrate settings
 	if settings.Exists(keys.ModbusProxy) {
-		if err := settings.Yaml(keys.ModbusProxy, new([]map[string]any), conf); err != nil {
+		conf = &[]globalconfig.ModbusProxy{}
+		if err := settings.Yaml(keys.ModbusProxy, new([]map[string]any), &conf); err != nil {
 			return err
 		}
 	}
@@ -898,7 +904,7 @@ func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) {
 		return nil, &ClassError{ClassLoadpoint, err}
 	}
 
-	tariffs, err := configureTariffs(conf.Tariffs)
+	tariffs, err := configureTariffs(&conf.Tariffs)
 	if err != nil {
 		return nil, &ClassError{ClassTariff, err}
 	}
diff --git a/cmd/tariff.go b/cmd/tariff.go
index 082713d2..8a7b67ad 100644
--- a/cmd/tariff.go
+++ b/cmd/tariff.go
@@ -32,7 +32,7 @@ func runTariff(cmd *cobra.Command, args []string) {
 		fatal(err)
 	}
 
-	tariffs, err := configureTariffs(conf.Tariffs)
+	tariffs, err := configureTariffs(&conf.Tariffs)
 	if err != nil {
 		fatal(err)
 	}
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 415ae985..5efd2422 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -303,7 +303,7 @@ func (lp *Loadpoint) restoreSettings() {
 	}
 
 	// restore runtime configuration (database & yaml LPs)
-	if v, err := lp.settings.String(keys.Mode); err == nil && v != "" {
+	if v, err := lp.settings.String(keys.Mode); err == nil && v != "" && lp.DefaultMode == api.ModeEmpty {
 		lp.setMode(api.ChargeMode(v))
 	}
 	if v, err := lp.settings.Int(keys.Priority); err == nil && v > 0 {
diff --git a/evcc.dist.yaml b/evcc.dist.yaml
index ef51b0f2..0c1927a6 100644
--- a/evcc.dist.yaml
+++ b/evcc.dist.yaml
@@ -163,7 +163,7 @@ tariffs:
     # type: template
     # template: grünstromindex # GrünStromIndex (Germany only)
     # zip: <zip>
-    # see: https://docs.evcc.io/en/docs/devices/tariffs#co-forecast
+    # see: https://docs.evcc.io/en/docs/tariffs#co-forecast
 
 # mqtt message broker
 mqtt:
diff --git a/server/http_session_handler.go b/server/http_session_handler.go
index 2f6190a9..6102a56c 100644
--- a/server/http_session_handler.go
+++ b/server/http_session_handler.go
@@ -117,20 +117,28 @@ func updateSessionHandler(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	vars := mux.Vars(r)
-	id := vars["id"]
+	id := mux.Vars(r)["id"]
+
+	var data map[string]interface{}
+	if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+		jsonError(w, http.StatusBadRequest, errors.New("invalid JSON"))
+		return
+	}
 
-	var session struct {
-		Vehicle string
+	updates := map[string]interface{}{}
+	for _, field := range []string{"vehicle", "loadpoint"} {
+		if val, ok := data[field]; ok {
+			updates[field] = val
+		}
 	}
 
-	if err := json.NewDecoder(r.Body).Decode(&session); err != nil {
-		jsonError(w, http.StatusBadRequest, err)
+	if len(updates) == 0 {
+		jsonError(w, http.StatusBadRequest, errors.New("no valid fields to update"))
 		return
 	}
 
 	// https://github.com/evcc-io/evcc/issues/13738#issuecomment-2094070362
-	if txn := db.Instance.Table("sessions").Where("id = ?", id).Select("vehicle").Updates(&session); txn.Error != nil {
+	if txn := db.Instance.Table("sessions").Where("id = ?", id).Updates(updates); txn.Error != nil {
 		jsonError(w, http.StatusBadRequest, txn.Error)
 		return
 	}
diff --git a/tariff/helper.go b/tariff/helper.go
index 9cbb8cc4..85d4f54e 100644
--- a/tariff/helper.go
+++ b/tariff/helper.go
@@ -10,6 +10,7 @@ import (
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/config"
 	"github.com/evcc-io/evcc/util/request"
+	"github.com/jinzhu/now"
 )
 
 // Name returns the tariff type name
@@ -40,9 +41,13 @@ func backoffPermanentError(err error) error {
 	return err
 }
 
-// mergeRates merges new rates into existing rates,
-// keeping current slots from the existing rates.
+// mergeRates blends new and existing rates, keeping existing rates after current hour
 func mergeRates(data *util.Monitor[api.Rates], new api.Rates) {
+	mergeRatesAfter(data, new, now.With(time.Now()).BeginningOfHour())
+}
+
+// mergeRatesAfter blends new and existing rates, keeping existing rates after timestamp
+func mergeRatesAfter(data *util.Monitor[api.Rates], new api.Rates, now time.Time) {
 	new.Sort()
 
 	var newStart time.Time
@@ -51,11 +56,9 @@ func mergeRates(data *util.Monitor[api.Rates], new api.Rates) {
 	}
 
 	data.SetFunc(func(old api.Rates) api.Rates {
-		now := time.Now()
-
 		var between api.Rates
 		for _, r := range old {
-			if (r.Start.Before(newStart) || newStart.IsZero()) && r.End.After(now) {
+			if (r.Start.Before(newStart) && !r.Start.Before(now) || newStart.IsZero()) && r.End.After(now) {
 				between = append(between, r)
 			}
 		}
@@ -63,3 +66,8 @@ func mergeRates(data *util.Monitor[api.Rates], new api.Rates) {
 		return append(between, new...)
 	})
 }
+
+// BeginningOfDay returns the beginning of the current day
+func BeginningOfDay() time.Time {
+	return now.With(time.Now()).BeginningOfDay()
+}
diff --git a/tariff/solcast.go b/tariff/solcast.go
index ec6885e1..9dcf2004 100644
--- a/tariff/solcast.go
+++ b/tariff/solcast.go
@@ -108,7 +108,7 @@ func (t *Solcast) run(interval time.Duration, done chan error) {
 			data = append(data, rr)
 		}
 
-		mergeRates(t.data, data)
+		mergeRatesAfter(t.data, data, BeginningOfDay())
 		once.Do(func() { close(done) })
 	}
 }
diff --git a/tariff/tariff.go b/tariff/tariff.go
index 7a119a91..3b297df2 100644
--- a/tariff/tariff.go
+++ b/tariff/tariff.go
@@ -112,7 +112,13 @@ func (t *Tariff) run(forecastG func() (string, error), done chan error, interval
 			continue
 		}
 
-		mergeRates(t.data, data)
+		// only prune rates older than current period
+		periodStart := now.With(time.Now()).BeginningOfHour()
+		if t.typ == api.TariffTypeSolar {
+			periodStart = BeginningOfDay()
+		}
+		mergeRatesAfter(t.data, data, periodStart)
+
 		once.Do(func() { close(done) })
 	}
 }
diff --git a/util/monitor.go b/util/monitor.go
index 499dbc62..ef1f16fa 100644
--- a/util/monitor.go
+++ b/util/monitor.go
@@ -4,12 +4,14 @@ import (
 	"sync"
 	"time"
 
+	"github.com/benbjohnson/clock"
 	"github.com/evcc-io/evcc/api"
 )
 
 // Monitor monitors values for regular updates
 type Monitor[T any] struct {
 	val     T
+	clock   clock.Clock
 	mu      sync.RWMutex
 	once    sync.Once
 	done    chan struct{}
@@ -19,10 +21,18 @@ type Monitor[T any] struct {
 
 // NewMonitor created a new monitor with given timeout
 func NewMonitor[T any](timeout time.Duration) *Monitor[T] {
-	return &Monitor[T]{
+	res := &Monitor[T]{
+		clock:   clock.New(),
 		done:    make(chan struct{}),
 		timeout: timeout,
 	}
+
+	return res
+}
+
+// WithClock sets the a clock for debugging
+func (m *Monitor[T]) WithClock(clock clock.Clock) {
+	m.clock = clock
 }
 
 // Set updates the current value and timestamp
@@ -36,7 +46,7 @@ func (m *Monitor[T]) SetFunc(set func(T) T) {
 	defer m.mu.Unlock()
 
 	m.val = set(m.val)
-	m.updated = time.Now()
+	m.updated = m.clock.Now()
 
 	m.once.Do(func() { close(m.done) })
 }
@@ -66,7 +76,7 @@ func (m *Monitor[T]) GetFunc(get func(T)) error {
 		}
 	}
 
-	if time.Since(m.updated) > m.timeout {
+	if m.clock.Since(m.updated) > m.timeout {
 		err := api.ErrOutdated
 
 		// wait once on very first call
@@ -83,7 +93,7 @@ func (m *Monitor[T]) GetFunc(get func(T)) error {
 			case <-m.done:
 				// got value and updated timestamp
 				err = nil
-			case <-time.After(m.timeout):
+			case <-m.clock.After(m.timeout):
 			}
 
 			m.mu.RLock()


Test patch:
diff --git a/cmd/setup_circuits_test.go b/cmd/setup_circuits_test.go
index 5728ace6..146efb0c 100644
--- a/cmd/setup_circuits_test.go
+++ b/cmd/setup_circuits_test.go
@@ -51,7 +51,7 @@ loadpoints:
 
 	suite.Require().NoError(viper.UnmarshalExact(&conf))
 
-	suite.Require().NoError(configureCircuits(conf.Circuits))
+	suite.Require().NoError(configureCircuits(&conf.Circuits))
 	suite.Require().Len(config.Circuits().Devices(), 2)
 	suite.Require().False(config.Circuits().Devices()[0].Instance().HasMeter())
 
@@ -82,7 +82,7 @@ loadpoints:
 
 	suite.Require().NoError(viper.UnmarshalExact(&conf))
 
-	suite.Require().NoError(configureCircuits(conf.Circuits))
+	suite.Require().NoError(configureCircuits(&conf.Circuits))
 	suite.Require().Len(config.Circuits().Devices(), 2)
 	suite.Require().False(config.Circuits().Devices()[0].Instance().HasMeter())
 
@@ -141,7 +141,7 @@ loadpoints:
 
 	suite.Require().NoError(viper.UnmarshalExact(&conf))
 
-	suite.Require().NoError(configureCircuits(conf.Circuits))
+	suite.Require().NoError(configureCircuits(&conf.Circuits))
 	suite.Require().Len(config.Circuits().Devices(), 1)
 
 	// mock charger
diff --git a/tariff/helper_test.go b/tariff/helper_test.go
new file mode 100644
index 00000000..8605a643
--- /dev/null
+++ b/tariff/helper_test.go
@@ -0,0 +1,55 @@
+package tariff
+
+import (
+	"testing"
+	"time"
+
+	"github.com/benbjohnson/clock"
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/util"
+	"github.com/jinzhu/now"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestMergeRatesAfter(t *testing.T) {
+	clock := clock.NewMock()
+	rate := func(start int, val float64) api.Rate {
+		return api.Rate{
+			Start: clock.Now().Add(time.Duration(start) * time.Hour),
+			End:   clock.Now().Add(time.Duration(start+1) * time.Hour),
+			Price: val,
+		}
+	}
+
+	old := api.Rates{rate(1, 1), rate(2, 2)}
+	new := api.Rates{rate(2, 2), rate(3, 3)}
+	combined := api.Rates{rate(1, 1), rate(2, 2), rate(3, 3)}
+
+	data := util.NewMonitor[api.Rates](time.Hour)
+	data.WithClock(clock)
+
+	data.Set(old)
+	res, err := data.Get()
+	require.NoError(t, err)
+	assert.Equal(t, old, res)
+
+	for i, tc := range []struct {
+		new, expected api.Rates
+		ts            time.Time
+	}{
+		{new, combined, clock.Now()},
+		{nil, combined, clock.Now()},
+		{new, combined, clock.Now().Add(time.Hour)},
+		{new, combined, now.With(clock.Now().Add(time.Hour + 30*time.Minute)).BeginningOfHour()},
+		{new, new, clock.Now().Add(2 * time.Hour)},
+	} {
+		t.Logf("%d. %+v", i+1, tc)
+
+		mergeRatesAfter(data, tc.new, tc.ts)
+
+		res, err := data.Get()
+		require.NoError(t, err)
+		assert.Equal(t, tc.expected, res)
+	}
+}