From 649f12731952272ff82a4c0621a87f4ea6d37352 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sat, 26 Sep 2026 10:54:44 +0200 Subject: [PATCH 1/3] Feed-in: EEG split on the energy page With an EEG counter set, the grid card of the energy page splits the export: the EEG part is stacked as its own lighter bar next to the standard feed-in, the legend lists both, and the revenue tiles show the standard feed-in and the EEG revenue separately, each priced slot by slot by /api/feedinsplit, also without a grid price. Without a counter the page is unchanged and makes no extra request. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit eb491973ce8caa9e85c09705e72f06b2d460ec7e) --- assets/js/components/Energy/GridStats.vue | 58 +++++++++++ assets/js/components/Energy/GroupChart.vue | 3 + assets/js/components/Energy/feedInEeg.ts | 106 +++++++++++++++++++++ assets/js/views/Energy.vue | 64 ++++++++++++- i18n/de.json | 10 +- i18n/en.json | 10 +- 6 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 assets/js/components/Energy/feedInEeg.ts diff --git a/assets/js/components/Energy/GridStats.vue b/assets/js/components/Energy/GridStats.vue index ace0c10d15..85b48c777b 100644 --- a/assets/js/components/Energy/GridStats.vue +++ b/assets/js/components/Energy/GridStats.vue @@ -8,6 +8,7 @@ import StatCards from "./StatCards.vue"; import formatter from "@/mixins/formatter"; import { CURRENCY } from "@/types/evcc"; import type { FlowCost, StatItem } from "./types"; +import type { FeedInSplitTotals } from "./feedInEeg"; // what import cost and export earned, pointing at the tariff settings without one export default defineComponent({ @@ -17,9 +18,12 @@ export default defineComponent({ props: { cost: { type: Object as PropType }, currency: { type: String as PropType, default: CURRENCY.EUR }, + // custom: export split by feed-in tariff, see feedInEeg.ts + feedInEeg: { type: Object as PropType, default: null }, }, computed: { stats(): StatItem[] { + if (this.feedInEeg) return this.feedInEegStats(this.feedInEeg); // custom const cost = this.cost; if (!cost) { const empty = this.$t("energy.stat.noPrice"); @@ -49,5 +53,59 @@ export default defineComponent({ ]; }, }, + methods: { + // custom: revenue per feed-in tariff, priced slot by slot on its own and so + // also shown without a grid price, see feedInEeg.ts + feedInEegStats(e: FeedInSplitTotals): StatItem[] { + const empty = this.$t("energy.stat.noPrice"); + const money = ( + key: string, + label: string, + amount: number, + energy: number + ): StatItem => ({ + key, + label, + number: amount, + format: (v: number) => this.fmtMoneyWithSymbol(v, this.currency), + sub: energy ? `ø ${this.fmtPricePerKWh(amount / energy, this.currency)}` : "", + }); + const revenue = ( + key: string, + label: string, + amount: number, + energy: number, + priced: number + ): StatItem => + energy > 0 && !priced + ? { key, label, empty } + : { ...money(key, label, amount, priced), accent: "text-accent1" }; + const cost = this.cost; + return [ + cost + ? money( + "gridImport", + this.$t("energy.grid.cost"), + cost.import, + cost.importEnergy + ) + : { key: "gridImport", label: this.$t("energy.grid.cost"), empty }, + revenue( + "gridExport", + this.$t("energy.grid.revenue"), + e.standardRevenue, + e.standard, + e.standardPriced + ), + revenue( + "gridExportEeg", + this.$t("energy.feedInEeg.revenue"), + e.eegRevenue, + e.eeg, + e.eegPriced + ), + ]; + }, + }, }); diff --git a/assets/js/components/Energy/GroupChart.vue b/assets/js/components/Energy/GroupChart.vue index a4fc769107..8f699c1309 100644 --- a/assets/js/components/Energy/GroupChart.vue +++ b/assets/js/components/Energy/GroupChart.vue @@ -49,6 +49,8 @@ export interface HistorySeries { virtual?: boolean; // explicit color, skips palette resolution color?: string; + // custom: export color of a split export, see components/Energy/feedInEeg.ts + returnColor?: string; // socTemp holds a temperature, the entity heats instead of charging isTemp?: boolean; // Stable index into the palette, preserved across navigations even when the @@ -563,6 +565,7 @@ export default defineComponent({ this.series.forEach((s, i) => { const c = this.entryColors[i] || this.color; const returnEnergyColor = + s.returnColor || // custom: split export, see feedInEeg.ts (s.group === "grid" && colors.export) || (s.group === "battery" ? setAlpha(c, "cc") || c : c); const energyValues = energyByEntity[i]!; diff --git a/assets/js/components/Energy/feedInEeg.ts b/assets/js/components/Energy/feedInEeg.ts new file mode 100644 index 0000000000..b861ddf8e6 --- /dev/null +++ b/assets/js/components/Energy/feedInEeg.ts @@ -0,0 +1,106 @@ +// Custom extension: export sold under two feed-in tariffs, see +// core/site_feedin_eeg.go. The energy page splits the grid export into the +// standard feed-in tariff and the EEG part metered by a Home Assistant counter, +// in the chart, the legend and the revenue tiles. Without a counter nothing +// changes. +import api from "@/api"; +import colors, { lighten } from "@/colors"; +import type { HistorySeries, HistorySlot } from "./GroupChart.vue"; + +export interface FeedInSplit { + start: string; + end: string; + export: number; // kWh, grid meter + eeg: number; // kWh, EEG counter + standard: number; // kWh, export minus EEG + eegRevenue: number; + standardRevenue: number; + eegPriced: number; // kWh with a known EEG price + standardPriced: number; +} + +export type FeedInSplitTotals = Omit; + +export async function fetchFeedInSplit( + from: Date, + to: Date, + aggregate: string +): Promise { + const { data } = await api.get("feedinsplit", { + params: { from: from.toISOString(), to: to.toISOString(), aggregate }, + }); + return data || []; +} + +export function feedInSplitTotals(split: FeedInSplit[]): FeedInSplitTotals { + const sum = (key: keyof FeedInSplitTotals) => split.reduce((acc, b) => acc + b[key], 0); + return { + export: sum("export"), + eeg: sum("eeg"), + standard: sum("standard"), + eegRevenue: sum("eegRevenue"), + standardRevenue: sum("standardRevenue"), + eegPriced: sum("eegPriced"), + standardPriced: sum("standardPriced"), + }; +} + +// the EEG part next to the standard export, a lighter shade of the export color +export function eegColor(): string { + return lighten(colors.export || "", 0.5); +} + +// bucket key independent of the first slot a history bucket starts with +function bucketKey(start: string, aggregate: string): string { + const d = new Date(start); + const pad = (n: number) => String(n).padStart(2, "0"); + const day = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + switch (aggregate) { + case "month": + return day.slice(0, 7); + case "day": + return day; + case "hour": + return `${day} ${pad(d.getHours())}`; + default: + return String(d.getTime()); + } +} + +// Grid series with the EEG part taken out of the export, plus the EEG part as a +// series of its own, stacked below it in the export direction. +export function splitGridSeries( + grid: HistorySeries[], + split: FeedInSplit[], + aggregate: string, + gridTitle: string +): HistorySeries[] { + const eeg = new Map(split.map((b) => [bucketKey(b.start, aggregate), b.eeg])); + + const standard = grid.map((s) => ({ + ...s, + title: gridTitle, + data: s.data.map( + (slot): HistorySlot => ({ + ...slot, + returnEnergy: Math.max( + 0, + slot.returnEnergy - (eeg.get(bucketKey(slot.start, aggregate)) || 0) + ), + }) + ), + })); + + const color = eegColor(); + const eegSeries: HistorySeries = { + title: "EEG", + group: "eeg", + color, + returnColor: color, + data: split + .filter((b) => b.eeg > 0) + .map((b) => ({ start: b.start, end: b.end, energy: 0, returnEnergy: b.eeg })), + }; + + return [eegSeries, ...standard]; +} diff --git a/assets/js/views/Energy.vue b/assets/js/views/Energy.vue index 5400263d6e..1a74b52759 100644 --- a/assets/js/views/Energy.vue +++ b/assets/js/views/Energy.vue @@ -127,7 +127,8 @@
- +
@@ -395,6 +400,15 @@ import { import type { DeviceColors } from "@/types/evcc"; import { CURRENCY } from "@/types/evcc"; import api from "../api"; +// custom: export split by feed-in tariff, see core/site_feedin_eeg.go +import { + eegColor, + feedInSplitTotals, + fetchFeedInSplit, + splitGridSeries, + type FeedInSplit, + type FeedInSplitTotals, +} from "../components/Energy/feedInEeg"; import settings from "../settings"; import formatter from "../mixins/formatter"; import store from "../store"; @@ -465,6 +479,7 @@ export default defineComponent({ loading: false, focusedPv: null as number | null, startDate: new Date(2020, 0, 1), + feedInSplit: [] as FeedInSplit[], // custom: see feedInEeg.ts }; }, head() { @@ -725,6 +740,20 @@ export default defineComponent({ gridSeries(): HistorySeries[] { return this.withData("grid"); }, + // custom: export split by feed-in tariff, see feedInEeg.ts + feedInEeg(): FeedInSplitTotals | null { + if (!this.feedInSplit.length) return null; + return feedInSplitTotals(this.feedInSplit); + }, + gridChartSeries(): HistorySeries[] { + if (!this.feedInEeg) return this.gridSeries; + return splitGridSeries( + this.gridSeries, + this.feedInSplit, + this.aggregate, + this.$t("energy.group.grid") + ); + }, // with the price overlay on, both prices as lines with their range over the period gridLegends(): Legend[] { const list: Legend[] = [ @@ -739,6 +768,23 @@ export default defineComponent({ value: this.fmtKWh(this.gridExport), }, ]; + // custom: export split by feed-in tariff, see feedInEeg.ts + if (this.feedInEeg) { + list.splice( + 1, + 1, + { + label: this.$t("energy.grid.revenue"), + color: colors.export || "", + value: this.fmtKWh(this.feedInEeg.standard), + }, + { + label: this.$t("energy.feedInEeg.eeg"), + color: eegColor(), + value: this.fmtKWh(this.feedInEeg.eeg), + } + ); + } const prices = settings.energyGridPrices ? this.gridPrices : null; const range = (band: PriceBand) => { const known = (v: (number | null)[]) => v.filter((x): x is number => x !== null); @@ -1132,6 +1178,7 @@ export default defineComponent({ async fetchData() { this.loading = true; const requestKey = this.fetchKey; + this.loadFeedInSplit(requestKey); // custom: see feedInEeg.ts try { const [flow, energy, tariffs] = await Promise.all([ this.fetchFlow(this.from, this.to), @@ -1155,6 +1202,19 @@ export default defineComponent({ if (requestKey === this.fetchKey) this.loading = false; } }, + // custom: export split by feed-in tariff, only with an EEG counter + async loadFeedInSplit(requestKey: string) { + if (!store.state.feedInEegEntity) { + this.feedInSplit = []; + return; + } + try { + const split = await fetchFeedInSplit(this.from, this.to, this.aggregate); + if (requestKey === this.fetchKey) this.feedInSplit = split; + } catch (e) { + console.error("Failed to load feed-in split", e); + } + }, fetchFlow(from: Date, to: Date) { return api.get("history/flow", { params: { from: from.toISOString(), to: to.toISOString() }, diff --git a/i18n/de.json b/i18n/de.json index a73597071d..a38172f4e2 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1480,6 +1480,10 @@ "meter": { "energy": "Energie", "returnEnergy": "Energie (rückwärts)" + }, + "eeg": { + "energy": "", + "returnEnergy": "eingespeist" } }, "empty": "Keine Energiedaten für diesen Zeitraum verfügbar.", @@ -1552,7 +1556,11 @@ "savings": "Ersparnis", "savingsTooltip": "Im Vergleich zum Bezug der verbrauchten {energy} aus dem Netz." }, - "title": "Energie" + "title": "Energie", + "feedInEeg": { + "eeg": "EEG", + "revenue": "EEG-Vergütung" + } }, "issue": { "additional": { diff --git a/i18n/en.json b/i18n/en.json index c8b0bebcaf..c1ea1e88c4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1480,6 +1480,10 @@ "meter": { "energy": "Energy", "returnEnergy": "Energy (reverse)" + }, + "eeg": { + "energy": "", + "returnEnergy": "exported" } }, "empty": "No energy data available for this time range.", @@ -1552,7 +1556,11 @@ "savings": "Savings", "savingsTooltip": "Compared to buying the consumed {energy} from the grid." }, - "title": "Energy" + "title": "Energy", + "feedInEeg": { + "eeg": "EEG", + "revenue": "EEG revenue" + } }, "issue": { "additional": { From d32b86d99391329c7f27a6f84e40ecb4d0ab0b67 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sat, 26 Sep 2026 11:30:44 +0200 Subject: [PATCH 2/3] Feed-in: EEG counter not listed again among the meters The energy page's grid card already shows the EEG part, so its counter is left out of the additional meters card. The collector is titled with its name for that; the CSV download still contains it. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit c6bd35e3f99efddf5c20d170916ee2cfb50c510c) --- assets/js/components/Energy/feedInEeg.ts | 8 ++++++++ assets/js/views/Energy.vue | 3 ++- core/metrics/feedin_eeg_custom.go | 2 +- core/site_feedin_eeg.go | 3 ++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/assets/js/components/Energy/feedInEeg.ts b/assets/js/components/Energy/feedInEeg.ts index b861ddf8e6..e9a09e961b 100644 --- a/assets/js/components/Energy/feedInEeg.ts +++ b/assets/js/components/Energy/feedInEeg.ts @@ -21,6 +21,14 @@ export interface FeedInSplit { export type FeedInSplitTotals = Omit; +// title of the EEG counter's own series, see metrics.FeedInEeg +const FEED_IN_EEG = "feedin-eeg"; + +// the EEG counter is shown in the grid card, not again among the meters +export function withoutFeedInEeg(meters: HistorySeries[]): HistorySeries[] { + return meters.filter((s) => s.title !== FEED_IN_EEG); +} + export async function fetchFeedInSplit( from: Date, to: Date, diff --git a/assets/js/views/Energy.vue b/assets/js/views/Energy.vue index 1a74b52759..ccc593a9c5 100644 --- a/assets/js/views/Energy.vue +++ b/assets/js/views/Energy.vue @@ -406,6 +406,7 @@ import { feedInSplitTotals, fetchFeedInSplit, splitGridSeries, + withoutFeedInEeg, type FeedInSplit, type FeedInSplitTotals, } from "../components/Energy/feedInEeg"; @@ -707,7 +708,7 @@ export default defineComponent({ return this.withData("battery"); }, meters(): HistorySeries[] { - return this.withData("meter"); + return withoutFeedInEeg(this.withData("meter")); // custom: see feedInEeg.ts }, meterColors(): Record { return this.entityColors(this.meters); diff --git a/core/metrics/feedin_eeg_custom.go b/core/metrics/feedin_eeg_custom.go index a2ad2000b7..97611b42d9 100644 --- a/core/metrics/feedin_eeg_custom.go +++ b/core/metrics/feedin_eeg_custom.go @@ -16,7 +16,7 @@ import ( "gorm.io/gorm/clause" ) -// FeedInEeg is the name of the EEG counter's collector in group Meter +// FeedInEeg is the name and title of the EEG counter's collector in group Meter const FeedInEeg = "feedin-eeg" type eegPrice struct { diff --git a/core/site_feedin_eeg.go b/core/site_feedin_eeg.go index d9bfcd5578..a63d4c81ae 100644 --- a/core/site_feedin_eeg.go +++ b/core/site_feedin_eeg.go @@ -137,7 +137,8 @@ func (site *Site) applyFeedInEegEntity(entity string, changed bool) error { opt = append(opt, metrics.WithClock(s.clock)) } - c, err := metrics.NewCollector(metrics.Meter, metrics.FeedInEeg, "EEG", opt...) + // titled with its name, so the energy page can leave it out of its meters + c, err := metrics.NewCollector(metrics.Meter, metrics.FeedInEeg, metrics.FeedInEeg, opt...) if err != nil { return err } From a7544f04927c145ebd7e55eaa98dee805f56a7b2 Mon Sep 17 00:00:00 2001 From: SolarPower2024 Date: Sat, 26 Sep 2026 20:55:50 +0200 Subject: [PATCH 3/3] docs: EEG split on the energy page Co-Authored-By: Claude Opus 5.5 --- core/lm/README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/core/lm/README.md b/core/lm/README.md index 1fe38f5057..04c2a34834 100644 --- a/core/lm/README.md +++ b/core/lm/README.md @@ -128,6 +128,7 @@ Keep these in mind when merging a new evcc version: | `core/site/api.go` | embeds `CustomAPI`, one line | | `api/globalconfig/types.go`, `tariff/tariffs.go`, `cmd/setup.go`, `server/http_config_device_handler.go` | `feedInEeg` tariff role: ref field, `Used`/`IsConfigured`, one `configureTariff` call, cleared on delete | | `assets/js/components/Config/TariffModal.vue` | `feedInEeg` offers the price templates | +| `assets/js/views/Energy.vue`, `assets/js/components/Energy/GroupChart.vue`, `assets/js/components/Energy/GridStats.vue` | EEG split of the grid card: series, legend, `returnColor`, revenue tiles, meters without the EEG counter | | `core/site_optimizer.go` | `applyLmOptimizerInputs` where the optimizer request is assembled | | `server/http.go` | merges `customSiteRoutes`, one loop | | `assets/js/views/Battery.vue` | mounts the new cards, profile selection at the bottom | @@ -239,9 +240,12 @@ Home Assistant counter of the EEG export (kWh, Wh or MWh). - Only counters are used. The grid meter power that drives PV control, load management and peak shaving is untouched; self-consumption and the solar share of sessions stay valued at the standard feed-in tariff. -- The display on the new energy page (evcc PR 33989, not released yet) is - prepared separately; until then the data is recorded and available via the - api. +- The energy page (evcc PR 33989, not released yet; until then this builds on + the branch `preview/energy-page`) shows the split in its grid card: EEG as + its own lighter export bar, both amounts in the legend, and the revenue of + the standard feed-in and of EEG as separate tiles, also without a grid + price. The counter is not listed again among the additional meters (its + collector is titled `feedin-eeg` for that). Without a counter nothing runs and evcc behaves as upstream.