Repository: evcc-io/evcc
Pull request #19028 (2025-02-21 10:05:45)

Suggest VSCode extensions
Hi,
ich möchte (insbesondere neuen) VSCode-Entwicklern helfen, sich in evcc schneller zurechtzufinden, [indem hilfreiche VSCode-Extensions zum Installieren vorgeschlagen werden](https://code.visualstudio.com/docs/editor/extension-marketplace#_workspace-recommended-extensions).

Folgende Extensions schlage ich vor:
- [Go](https://marketplace.visualstudio.com/items?itemName=golang.go)
- [Vue - Official](https://marketplace.visualstudio.com/items?itemName=vue.volar)
- [Vue 3 Support - All In One](https://marketplace.visualstudio.com/items?itemName=wscats.vue)
- [Prettier - Code formatter](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
- [Dependi](https://marketplace.visualstudio.com/items?itemName=fill-labs.dependi)

Sofern dieses Vorgehen bei euch Anklang findet, wäre es super, wenn ihr auch noch hilfreiche Extensions hinzufügt (entweder als Kommentar, dann füge ich sie hinzu oder direkt mit einem Commit).

\cc @naltatis 


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/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/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)
+	}
+}