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 }) => {