Heating: add time-based energy statistics (today, 24h, 7d) to loadpoint tile (#31781)

This commit is contained in:
Michael Geers 2026-07-18 12:06:24 +02:00 • committed by GitHub
parent 31cf9748aa
commit b02a40353f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 236 additions and 26 deletions

View file

@ -192,4 +192,7 @@ Heating.args = {
vehicleSoc: 48, vehicleSoc: 48,
effectiveLimitSoc: 60, effectiveLimitSoc: 60,
vehicleLimitSoc: 65, vehicleLimitSoc: 65,
todayEnergy: 3200,
last24hEnergy: 4100,
last7dEnergy: 26500,
}; };

View file

@ -71,6 +71,14 @@
/> />
</div> </div>
<LabelAndValue <LabelAndValue
v-if="integratedDevice"
:label="$t('main.loadpoint.todayEnergy')"
:value="todayEnergy"
:valueFmt="fmtEnergy"
align="center"
/>
<LabelAndValue
v-else
v-show="socBasedCharging" v-show="socBasedCharging"
:label="$t('main.loadpoint.charged')" :label="$t('main.loadpoint.charged')"
:value="chargedEnergy" :value="chargedEnergy"
@ -205,6 +213,9 @@ export default defineComponent({
vehicleWelcomeActive: Boolean, vehicleWelcomeActive: Boolean,
chargePower: { type: Number, default: 0 }, chargePower: { type: Number, default: 0 },
chargedEnergy: { type: Number, default: 0 }, chargedEnergy: { type: Number, default: 0 },
todayEnergy: { type: Number, default: 0 },
last24hEnergy: Number,
last7dEnergy: Number,
chargeRemainingDuration: { type: Number, default: 0 }, chargeRemainingDuration: { type: Number, default: 0 },
// other information // other information

View file

@ -30,7 +30,7 @@
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import LabelAndValue from "../Helper/LabelAndValue.vue"; import LabelAndValue from "../Helper/LabelAndValue.vue";
import CustomSelect from "../Helper/CustomSelect.vue"; import CustomSelect from "../Helper/CustomSelect.vue";
import formatter from "@/mixins/formatter"; import formatter, { POWER_UNIT } from "@/mixins/formatter";
import { getLoadpointSessionInfo, setLoadpointSessionInfo } from "@/uiLoadpoints"; import { getLoadpointSessionInfo, setLoadpointSessionInfo } from "@/uiLoadpoints";
import type { CURRENCY, SelectOption, SessionInfoKey } from "@/types/evcc"; import type { CURRENCY, SelectOption, SessionInfoKey } from "@/types/evcc";
@ -51,6 +51,8 @@ export default defineComponent({
chargeRemainingDurationInterpolated: { type: Number, default: 0 }, chargeRemainingDurationInterpolated: { type: Number, default: 0 },
chargeDurationInterpolated: Number, chargeDurationInterpolated: Number,
sessionEnergy: { type: Number, default: 0 }, sessionEnergy: { type: Number, default: 0 },
last24hEnergy: Number,
last7dEnergy: Number,
tariffCo2: Number, tariffCo2: Number,
tariffGrid: Number, tariffGrid: Number,
}, },
@ -107,6 +109,16 @@ export default defineComponent({
value: this.emissionFormatted, value: this.emissionFormatted,
available: this.tariffCo2 !== undefined, available: this.tariffCo2 !== undefined,
}, },
{
key: "last24hEnergy" as const,
value: this.fmtWh(this.last24hEnergy ?? 0, POWER_UNIT.AUTO),
available: this.last24hEnergy !== undefined,
},
{
key: "last7dEnergy" as const,
value: this.fmtWh(this.last7dEnergy ?? 0, POWER_UNIT.AUTO),
available: this.last7dEnergy !== undefined,
},
]; ];
// only show options that are available // only show options that are available
return result.filter(({ available }) => available === undefined || available); return result.filter(({ available }) => available === undefined || available);

View file

@ -360,6 +360,8 @@ export interface Loadpoint {
priority: number; priority: number;
pvAction: PV_ACTION; pvAction: PV_ACTION;
pvRemaining: number; pvRemaining: number;
last24hEnergy?: number;
last7dEnergy?: number;
sessionCo2PerKWh: number | null; sessionCo2PerKWh: number | null;
sessionEnergy: number; sessionEnergy: number;
sessionPrice: number | null; sessionPrice: number | null;
@ -373,6 +375,7 @@ export interface Loadpoint {
smartFeedInPriorityNextStart: string | null; smartFeedInPriorityNextStart: string | null;
suggestion?: LoadpointSuggestion | null; suggestion?: LoadpointSuggestion | null;
title: string; title: string;
todayEnergy?: number;
vehicleClimaterActive: boolean | null; vehicleClimaterActive: boolean | null;
vehicleDetectionActive: boolean; vehicleDetectionActive: boolean;
vehicleLimitSoc: number; vehicleLimitSoc: number;
@ -503,7 +506,9 @@ export type SessionInfoKey =
| "avgPrice" | "avgPrice"
| "price" | "price"
| "emission" | "emission"
| "co2"; | "co2"
| "last24hEnergy"
| "last7dEnergy";
export interface SponsorStatus { export interface SponsorStatus {
name?: string; name?: string;

View file

@ -17,7 +17,8 @@ javascript:
loadpoints: [ loadpoints: [
{ enabled: true, vehicleSoc: 62, maxcurrent: 6, phases: 1, chargepower: 0 }, { enabled: true, vehicleSoc: 62, maxcurrent: 6, phases: 1, chargepower: 0 },
{ enabled: false, vehicleSoc: 22, maxcurrent: 0, phases: 3, chargepower: 0 } { enabled: false, vehicleSoc: 22, maxcurrent: 0, phases: 3, chargepower: 0 }
] ],
heatpump: { mode: 2, power: 0, temp: 48 }
}; };
function logState() { function logState() {
console.log("state:", JSON.stringify(state)); console.log("state:", JSON.stringify(state));
@ -30,7 +31,7 @@ meters:
source: js source: js
vm: shared vm: shared
script: | 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; state.gridpower;
in: in:
- name: pvpower - name: pvpower
@ -167,9 +168,42 @@ chargers:
if (phases === 1) lp.phases = 1; else lp.phases = 3; if (phases === 1) lp.phases = 1; else lp.phases = 3;
lp.phases; 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: vehicles:
- name: vehicle_1 - name: vehicle_1
title: blauer e-Golf title: blue e-Golf
type: custom type: custom
soc: soc:
source: js source: js
@ -189,7 +223,7 @@ vehicles:
range range
capacity: 44 capacity: 44
- name: vehicle_2 - name: vehicle_2
title: weißes Model 3 title: white Model 3
type: custom type: custom
soc: soc:
source: js source: js
@ -219,19 +253,14 @@ vehicles:
- name: vehicle_3 - name: vehicle_3
type: template type: template
template: offline template: offline
title: grüner Honda e title: green Honda e
capacity: 8 capacity: 8
- name: vehicle_4 - name: vehicle_4
type: template type: template
template: offline template: offline
title: schwarzes VanMoof title: black VanMoof
icon: bike icon: bike
capacity: 0.46 capacity: 0.46
- name: vehicle_5
type: template
template: offline
title: Wärmepumpe
icon: waterheater
site: site:
title: Demo Mode title: Demo Mode
@ -251,6 +280,12 @@ loadpoints:
mode: "off" mode: "off"
meter: meter_charger_2 meter: meter_charger_2
vehicle: vehicle_2 vehicle: vehicle_2
- title: Heat pump
charger: charger_3
mode: pv
ui:
minTemp: 30
maxTemp: 70
tariffs: tariffs:
currency: EUR # three letter ISO-4217 currency code (default EUR) currency: EUR # three letter ISO-4217 currency code (default EUR)

View file

@ -76,6 +76,9 @@ const (
ChargedEnergy = "chargedEnergy" // charged energy ChargedEnergy = "chargedEnergy" // charged energy
ChargeDuration = "chargeDuration" // charge duration ChargeDuration = "chargeDuration" // charge duration
ChargeTotalImport = "chargeTotalImport" // charge meter total import ChargeTotalImport = "chargeTotalImport" // charge meter total import
TodayEnergy = "todayEnergy" // energy since midnight
Last24hEnergy = "last24hEnergy" // energy, rolling 24h
Last7dEnergy = "last7dEnergy" // energy, rolling 7 days
// session // session
ConnectedDuration = "connectedDuration" // connected duration ConnectedDuration = "connectedDuration" // connected duration

View file

@ -97,7 +97,7 @@ type Loadpoint struct {
Soc loadpoint.SocConfig Soc loadpoint.SocConfig
Enable, Disable loadpoint.ThresholdConfig 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 // from yaml
DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect
@ -389,7 +389,7 @@ func (lp *Loadpoint) restoreSettings() {
var ui loadpoint.UIConfig var ui loadpoint.UIConfig
if err := lp.settings.Json(keys.UI, &ui); err == nil { if err := lp.settings.Json(keys.UI, &ui); err == nil {
lp.ui = ui lp.Ui = ui
} }
t, err1 := lp.settings.Time(keys.PlanTime) 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.EnableDelay, lp.Enable.Delay)
lp.publish(keys.DisableDelay, lp.Disable.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 phases := lp.getChargerPhysicalPhases(); phases != 0 {
if lp.phasesConfigured != phases && lp.phasesConfigured != 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.publish(keys.SmartFeedInPriorityLimit, lp.smartFeedInPriorityLimit)
lp.publishTimer(phaseTimer, 0, timerInactive) lp.publishTimer(phaseTimer, 0, timerInactive)
lp.publishTimer(pvTimer, 0, timerInactive) lp.publishTimer(pvTimer, 0, timerInactive)
lp.publishEnergyStats()
// charger features // charger features
for _, f := range api.FeatureValues() { for _, f := range api.FeatureValues() {
@ -1851,6 +1852,25 @@ func (lp *Loadpoint) publishChargeProgress() {
lp.chargeEnergy.SetSocTemp(v, lp.chargerHasFeature(api.Heating)) 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 // publish state of charge, remaining charge duration and range

View file

@ -498,11 +498,11 @@ func (lp *Loadpoint) SetSocConfig(soc loadpoint.SocConfig) {
func (lp *Loadpoint) GetUI() loadpoint.UIConfig { func (lp *Loadpoint) GetUI() loadpoint.UIConfig {
lp.RLock() lp.RLock()
defer lp.RUnlock() defer lp.RUnlock()
return lp.ui return lp.Ui
} }
func (lp *Loadpoint) setUI(ui loadpoint.UIConfig) { func (lp *Loadpoint) setUI(ui loadpoint.UIConfig) {
lp.ui = ui lp.Ui = ui
lp.publish(keys.UI, ui) lp.publish(keys.UI, ui)
lp.settings.SetJson(keys.UI, ui) lp.settings.SetJson(keys.UI, ui)
} }

View file

@ -21,9 +21,10 @@ const (
) )
type Collector struct { type Collector struct {
entity entity entity entity
accu *Accumulator accu *Accumulator
started time.Time started time.Time
statsCache EnergyStats
} }
func NewCollector(group, name, title string, opt ...func(*Accumulator)) (*Collector, error) { func NewCollector(group, name, title string, opt ...func(*Accumulator)) (*Collector, error) {

57
core/metrics/stats.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -1368,12 +1368,15 @@
"emission": "Emission", "emission": "Emission",
"fallbackName": "Ladepunkt", "fallbackName": "Ladepunkt",
"finished": "Ladeende", "finished": "Ladeende",
"last24hEnergy": "Letzte 24h",
"last7dEnergy": "Letzte 7 Tage",
"power": "Leistung", "power": "Leistung",
"price": "Kosten", "price": "Kosten",
"remaining": "Restzeit", "remaining": "Restzeit",
"remoteDisabledHard": "{source}: Deaktiviert", "remoteDisabledHard": "{source}: Deaktiviert",
"remoteDisabledSoft": "{source}: Adaptives PV-Laden deaktiviert", "remoteDisabledSoft": "{source}: Adaptives PV-Laden deaktiviert",
"solar": "Sonne" "solar": "Sonne",
"todayEnergy": "Heute"
}, },
"loadpointSettings": { "loadpointSettings": {
"batteryBoost": { "batteryBoost": {

View file

@ -1368,12 +1368,15 @@
"emission": "Emission", "emission": "Emission",
"fallbackName": "Charging point", "fallbackName": "Charging point",
"finished": "Finish time", "finished": "Finish time",
"last24hEnergy": "Last 24h",
"last7dEnergy": "Last 7 days",
"power": "Power", "power": "Power",
"price": "Cost", "price": "Cost",
"remaining": "Remaining", "remaining": "Remaining",
"remoteDisabledHard": "{source}: turned off", "remoteDisabledHard": "{source}: turned off",
"remoteDisabledSoft": "{source}: turned off adaptive solar-charging", "remoteDisabledSoft": "{source}: turned off adaptive solar-charging",
"solar": "Solar" "solar": "Solar",
"todayEnergy": "Today"
}, },
"loadpointSettings": { "loadpointSettings": {
"batteryBoost": { "batteryBoost": {

View file

@ -52,7 +52,7 @@ test.describe("session info", async () => {
await expect(page.getByTestId("sessionInfoLabel").first()).toContainText("Solar"); await expect(page.getByTestId("sessionInfoLabel").first()).toContainText("Solar");
// by click on value // by click on value
await page.getByTestId("sessionInfoValue").first().click(); 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 }) => { test("keep selection on reload", async ({ page }) => {
await expect(page.getByTestId("sessionInfoLabel").first()).toContainText("Duration"); await expect(page.getByTestId("sessionInfoLabel").first()).toContainText("Duration");

View file

@ -28,10 +28,11 @@ test.describe("demo mode", async () => {
await expect(page.getByRole("heading", { name: "Demo Mode" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Demo Mode" })).toBeVisible();
}); });
test("two loadpoints", async ({ page }) => { test("three loadpoints", async ({ page }) => {
await expect(page.getByTestId("loadpoint")).toHaveCount(2); await expect(page.getByTestId("loadpoint")).toHaveCount(3);
await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport"); await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport");
await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Garage"); 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 }) => { test("auth is locked", async ({ page }) => {