From b02a40353f351535c81b705c2e9547d7eeb7cf66 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sat, 18 Jul 2026 12:06:24 +0200 Subject: [PATCH] Heating: add time-based energy statistics (today, 24h, 7d) to loadpoint tile (#31781) --- .../Loadpoints/Loadpoint.stories.ts | 3 + assets/js/components/Loadpoints/Loadpoint.vue | 11 ++++ .../js/components/Loadpoints/SessionInfo.vue | 14 ++++- assets/js/types/evcc.ts | 7 ++- cmd/demo.yaml | 57 +++++++++++++++---- core/keys/loadpoint.go | 3 + core/loadpoint.go | 26 ++++++++- core/loadpoint_api.go | 4 +- core/metrics/collector.go | 7 ++- core/metrics/stats.go | 57 +++++++++++++++++++ core/metrics/stats_test.go | 56 ++++++++++++++++++ i18n/de.json | 5 +- i18n/en.json | 5 +- tests/basics.spec.ts | 2 +- tests/demo.spec.ts | 5 +- 15 files changed, 236 insertions(+), 26 deletions(-) create mode 100644 core/metrics/stats.go create mode 100644 core/metrics/stats_test.go diff --git a/assets/js/components/Loadpoints/Loadpoint.stories.ts b/assets/js/components/Loadpoints/Loadpoint.stories.ts index 92a62c124..5e0f5e398 100644 --- a/assets/js/components/Loadpoints/Loadpoint.stories.ts +++ b/assets/js/components/Loadpoints/Loadpoint.stories.ts @@ -192,4 +192,7 @@ Heating.args = { vehicleSoc: 48, effectiveLimitSoc: 60, vehicleLimitSoc: 65, + todayEnergy: 3200, + last24hEnergy: 4100, + last7dEnergy: 26500, }; diff --git a/assets/js/components/Loadpoints/Loadpoint.vue b/assets/js/components/Loadpoints/Loadpoint.vue index b3e280062..5dbb0f050 100644 --- a/assets/js/components/Loadpoints/Loadpoint.vue +++ b/assets/js/components/Loadpoints/Loadpoint.vue @@ -71,6 +71,14 @@ /> + available === undefined || available); diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 63be2ff29..bab00a197 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -360,6 +360,8 @@ export interface Loadpoint { priority: number; pvAction: PV_ACTION; pvRemaining: number; + last24hEnergy?: number; + last7dEnergy?: number; sessionCo2PerKWh: number | null; sessionEnergy: number; sessionPrice: number | null; @@ -373,6 +375,7 @@ export interface Loadpoint { smartFeedInPriorityNextStart: string | null; suggestion?: LoadpointSuggestion | null; title: string; + todayEnergy?: number; vehicleClimaterActive: boolean | null; vehicleDetectionActive: boolean; vehicleLimitSoc: number; @@ -503,7 +506,9 @@ export type SessionInfoKey = | "avgPrice" | "price" | "emission" - | "co2"; + | "co2" + | "last24hEnergy" + | "last7dEnergy"; export interface SponsorStatus { name?: string; diff --git a/cmd/demo.yaml b/cmd/demo.yaml index fdcfc58e6..e822aab80 100644 --- a/cmd/demo.yaml +++ b/cmd/demo.yaml @@ -17,7 +17,8 @@ javascript: loadpoints: [ { enabled: true, vehicleSoc: 62, maxcurrent: 6, phases: 1, chargepower: 0 }, { enabled: false, vehicleSoc: 22, maxcurrent: 0, phases: 3, chargepower: 0 } - ] + ], + heatpump: { mode: 2, power: 0, temp: 48 } }; function logState() { console.log("state:", JSON.stringify(state)); @@ -30,7 +31,7 @@ meters: source: js vm: shared script: | - state.gridpower = state.loadpoints[0].chargepower + state.loadpoints[1].chargepower + state.residualpower - batterypower - pvpower; + state.gridpower = state.loadpoints[0].chargepower + state.loadpoints[1].chargepower + state.heatpump.power + state.residualpower - batterypower - pvpower; state.gridpower; in: - name: pvpower @@ -167,9 +168,42 @@ chargers: if (phases === 1) lp.phases = 1; else lp.phases = 3; lp.phases; + - name: charger_3 + type: sgready + features: [heating, integrateddevice, continuous, switchdevice] + setmode: + source: js + vm: shared + script: | + state.heatpump.mode = mode; + mode; + getmode: + source: js + vm: shared + script: state.heatpump.mode; + power: + source: js + vm: shared + script: | + var hp = state.heatpump; + hp.power = hp.mode === 3 ? 3800 + 400 * Math.random() : 300 + 300 * Math.random(); + hp.power; + temp: + source: js + vm: shared + script: | + var hp = state.heatpump; + if (hp.mode === 3) hp.temp += 0.2; else hp.temp -= 0.1; + if (hp.temp > 65) hp.temp = 65; + if (hp.temp < 35) hp.temp = 35; + hp.temp; + limittemp: + source: const + value: 65 + vehicles: - name: vehicle_1 - title: blauer e-Golf + title: blue e-Golf type: custom soc: source: js @@ -189,7 +223,7 @@ vehicles: range capacity: 44 - name: vehicle_2 - title: weißes Model 3 + title: white Model 3 type: custom soc: source: js @@ -219,19 +253,14 @@ vehicles: - name: vehicle_3 type: template template: offline - title: grüner Honda e + title: green Honda e capacity: 8 - name: vehicle_4 type: template template: offline - title: schwarzes VanMoof + title: black VanMoof icon: bike capacity: 0.46 - - name: vehicle_5 - type: template - template: offline - title: Wärmepumpe - icon: waterheater site: title: Demo Mode @@ -251,6 +280,12 @@ loadpoints: mode: "off" meter: meter_charger_2 vehicle: vehicle_2 + - title: Heat pump + charger: charger_3 + mode: pv + ui: + minTemp: 30 + maxTemp: 70 tariffs: currency: EUR # three letter ISO-4217 currency code (default EUR) diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go index e6ebd5f5a..7441055ee 100644 --- a/core/keys/loadpoint.go +++ b/core/keys/loadpoint.go @@ -76,6 +76,9 @@ const ( ChargedEnergy = "chargedEnergy" // charged energy ChargeDuration = "chargeDuration" // charge duration ChargeTotalImport = "chargeTotalImport" // charge meter total import + TodayEnergy = "todayEnergy" // energy since midnight + Last24hEnergy = "last24hEnergy" // energy, rolling 24h + Last7dEnergy = "last7dEnergy" // energy, rolling 7 days // session ConnectedDuration = "connectedDuration" // connected duration diff --git a/core/loadpoint.go b/core/loadpoint.go index 81a3d12f8..cdee2532b 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -97,7 +97,7 @@ type Loadpoint struct { Soc loadpoint.SocConfig Enable, Disable loadpoint.ThresholdConfig - ui loadpoint.UIConfig // display-only, not used in control logic + Ui loadpoint.UIConfig // display-only, not used in control logic // from yaml DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect @@ -389,7 +389,7 @@ func (lp *Loadpoint) restoreSettings() { var ui loadpoint.UIConfig if err := lp.settings.Json(keys.UI, &ui); err == nil { - lp.ui = ui + lp.Ui = ui } t, err1 := lp.settings.Time(keys.PlanTime) @@ -705,7 +705,7 @@ func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan c lp.publish(keys.EnableDelay, lp.Enable.Delay) lp.publish(keys.DisableDelay, lp.Disable.Delay) - lp.publish(keys.UI, lp.ui) + lp.publish(keys.UI, lp.Ui) if phases := lp.getChargerPhysicalPhases(); phases != 0 { if lp.phasesConfigured != phases && lp.phasesConfigured != 0 { @@ -723,6 +723,7 @@ func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan c lp.publish(keys.SmartFeedInPriorityLimit, lp.smartFeedInPriorityLimit) lp.publishTimer(phaseTimer, 0, timerInactive) lp.publishTimer(pvTimer, 0, timerInactive) + lp.publishEnergyStats() // charger features for _, f := range api.FeatureValues() { @@ -1851,6 +1852,25 @@ func (lp *Loadpoint) publishChargeProgress() { lp.chargeEnergy.SetSocTemp(v, lp.chargerHasFeature(api.Heating)) } } + + lp.publishEnergyStats() +} + +// publishEnergyStats publishes time-based energy statistics in Wh +func (lp *Loadpoint) publishEnergyStats() { + if lp.chargeEnergy == nil { + return + } + + stats, err := lp.chargeEnergy.EnergyStats() + if err != nil { + lp.log.ERROR.Printf("energy stats: %v", err) + return + } + + lp.publish(keys.TodayEnergy, math.Round(stats.Today*1e3)) + lp.publish(keys.Last24hEnergy, math.Round(stats.Last24h*1e3)) + lp.publish(keys.Last7dEnergy, math.Round(stats.Last7d*1e3)) } // publish state of charge, remaining charge duration and range diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 97d3c05f2..ae60ea6f5 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -498,11 +498,11 @@ func (lp *Loadpoint) SetSocConfig(soc loadpoint.SocConfig) { func (lp *Loadpoint) GetUI() loadpoint.UIConfig { lp.RLock() defer lp.RUnlock() - return lp.ui + return lp.Ui } func (lp *Loadpoint) setUI(ui loadpoint.UIConfig) { - lp.ui = ui + lp.Ui = ui lp.publish(keys.UI, ui) lp.settings.SetJson(keys.UI, ui) } diff --git a/core/metrics/collector.go b/core/metrics/collector.go index 1c3f68c4a..3c86aff79 100644 --- a/core/metrics/collector.go +++ b/core/metrics/collector.go @@ -21,9 +21,10 @@ const ( ) type Collector struct { - entity entity - accu *Accumulator - started time.Time + entity entity + accu *Accumulator + started time.Time + statsCache EnergyStats } func NewCollector(group, name, title string, opt ...func(*Accumulator)) (*Collector, error) { diff --git a/core/metrics/stats.go b/core/metrics/stats.go new file mode 100644 index 000000000..a993f6f1c --- /dev/null +++ b/core/metrics/stats.go @@ -0,0 +1,57 @@ +package metrics + +import ( + "time" + + "github.com/evcc-io/evcc/server/db" + "github.com/evcc-io/evcc/tariff" + "github.com/jinzhu/now" +) + +// EnergyStats holds time-based energy sums in kWh +type EnergyStats struct { + slot time.Time // cache key + Today float64 + Last24h float64 + Last7d float64 +} + +// energyStats sums persisted slot energy for the periods ending at slotStart +func energyStats(entity entity, slotStart, midnight time.Time) (EnergyStats, error) { + res := EnergyStats{slot: slotStart} + + sqlDB, err := db.Instance.DB() + if err != nil { + return res, err + } + + err = sqlDB.QueryRow(`SELECT + COALESCE(SUM(CASE WHEN ts >= ? THEN energy END), 0), + COALESCE(SUM(CASE WHEN ts >= ? THEN energy END), 0), + COALESCE(SUM(energy), 0) + FROM meters WHERE meter = ? AND ts >= ? AND ts < ?`, + midnight.Unix(), slotStart.Add(-24*time.Hour).Unix(), + entity.Id, slotStart.AddDate(0, 0, -7).Unix(), slotStart.Unix(), + ).Scan(&res.Today, &res.Last24h, &res.Last7d) + + return res, err +} + +// EnergyStats returns time-based energy sums. Persisted sums are cached per +// slot, the in-flight accumulator is added to today only. +func (c *Collector) EnergyStats() (EnergyStats, error) { + slotStart := c.accu.clock.Now().Truncate(tariff.SlotDuration) + + if !slotStart.Equal(c.statsCache.slot) { + stats, err := energyStats(c.entity, slotStart, now.With(c.accu.clock.Now()).BeginningOfDay()) + if err != nil { + return EnergyStats{}, err + } + + c.statsCache = stats + } + + res := c.statsCache + res.Today += c.accu.Energy + return res, nil +} diff --git a/core/metrics/stats_test.go b/core/metrics/stats_test.go new file mode 100644 index 000000000..97848797b --- /dev/null +++ b/core/metrics/stats_test.go @@ -0,0 +1,56 @@ +package metrics + +import ( + "testing" + "time" + + "github.com/benbjohnson/clock" + "github.com/evcc-io/evcc/server/db" + "github.com/stretchr/testify/require" +) + +func TestCollectorEnergyStats(t *testing.T) { + clock := clock.NewMock() + + loc := time.Now().Location() + midnight := time.Date(2026, 4, 15, 0, 0, 0, 0, loc) + clock.Set(midnight.Add(2*time.Hour + 5*time.Minute)) + + slotStart := clock.Now().Truncate(15 * time.Minute) + + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + require.NoError(t, SetupSchema()) + + col, err := NewCollector("stats", "stats", "", WithClock(clock)) + require.NoError(t, err) + + e := col.entity + require.NoError(t, persist(e, slotStart.AddDate(0, 0, -8), 100, 0, nil)) // outside 7d + require.NoError(t, persist(e, slotStart.Add(-24*time.Hour-15*time.Minute), 5, 0, nil)) // 7d only + require.NoError(t, persist(e, slotStart.Add(-24*time.Hour), 7, 0, nil)) // 24h window start + require.NoError(t, persist(e, slotStart.Add(-12*time.Hour), 3, 0, nil)) // yesterday, within 24h + require.NoError(t, persist(e, midnight, 1, 0, nil)) // first slot today + require.NoError(t, persist(e, slotStart.Add(-15*time.Minute), 2, 0, nil)) // last completed slot + require.NoError(t, persist(e, slotStart, 4, 0, nil)) // current slot, excluded + + stats, err := col.EnergyStats() + require.NoError(t, err) + require.InDelta(t, 3, stats.Today, 1e-9) + require.InDelta(t, 13, stats.Last24h, 1e-9) + require.InDelta(t, 18, stats.Last7d, 1e-9) + + // in-flight accumulator counts towards today only + col.accu.Energy = 0.5 + stats, err = col.EnergyStats() + require.NoError(t, err) + require.InDelta(t, 3.5, stats.Today, 1e-9) + require.InDelta(t, 13, stats.Last24h, 1e-9) + + // next slot: cache refreshes, windows shift by one slot + clock.Add(15 * time.Minute) + stats, err = col.EnergyStats() + require.NoError(t, err) + require.InDelta(t, 7.5, stats.Today, 1e-9) // 1+2+4 + in-flight + require.InDelta(t, 10, stats.Last24h, 1e-9) // drops 7, gains 4 + require.InDelta(t, 22, stats.Last7d, 1e-9) // gains 4, 8d slot stays out +} diff --git a/i18n/de.json b/i18n/de.json index 36619fedb..21478827a 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1368,12 +1368,15 @@ "emission": "Emission", "fallbackName": "Ladepunkt", "finished": "Ladeende", + "last24hEnergy": "Letzte 24h", + "last7dEnergy": "Letzte 7 Tage", "power": "Leistung", "price": "Kosten", "remaining": "Restzeit", "remoteDisabledHard": "{source}: Deaktiviert", "remoteDisabledSoft": "{source}: Adaptives PV-Laden deaktiviert", - "solar": "Sonne" + "solar": "Sonne", + "todayEnergy": "Heute" }, "loadpointSettings": { "batteryBoost": { diff --git a/i18n/en.json b/i18n/en.json index b13f91013..7f1488d0e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1368,12 +1368,15 @@ "emission": "Emission", "fallbackName": "Charging point", "finished": "Finish time", + "last24hEnergy": "Last 24h", + "last7dEnergy": "Last 7 days", "power": "Power", "price": "Cost", "remaining": "Remaining", "remoteDisabledHard": "{source}: turned off", "remoteDisabledSoft": "{source}: turned off adaptive solar-charging", - "solar": "Solar" + "solar": "Solar", + "todayEnergy": "Today" }, "loadpointSettings": { "batteryBoost": { diff --git a/tests/basics.spec.ts b/tests/basics.spec.ts index b3bb856f5..3085285b5 100644 --- a/tests/basics.spec.ts +++ b/tests/basics.spec.ts @@ -52,7 +52,7 @@ test.describe("session info", async () => { await expect(page.getByTestId("sessionInfoLabel").first()).toContainText("Solar"); // by click on value await page.getByTestId("sessionInfoValue").first().click(); - await expect(page.getByTestId("sessionInfoLabel").first()).toContainText("Duration"); + await expect(page.getByTestId("sessionInfoLabel").first()).toContainText("Last 24h"); }); test("keep selection on reload", async ({ page }) => { await expect(page.getByTestId("sessionInfoLabel").first()).toContainText("Duration"); diff --git a/tests/demo.spec.ts b/tests/demo.spec.ts index ae085d78d..2350c6132 100644 --- a/tests/demo.spec.ts +++ b/tests/demo.spec.ts @@ -28,10 +28,11 @@ test.describe("demo mode", async () => { await expect(page.getByRole("heading", { name: "Demo Mode" })).toBeVisible(); }); - test("two loadpoints", async ({ page }) => { - await expect(page.getByTestId("loadpoint")).toHaveCount(2); + test("three loadpoints", async ({ page }) => { + await expect(page.getByTestId("loadpoint")).toHaveCount(3); await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport"); await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Garage"); + await expect(page.getByTestId("loadpoint").nth(2)).toContainText("Heat pump"); }); test("auth is locked", async ({ page }) => {