Battery UI: soc history and future, new layout (#31383)

This commit is contained in:
Michael Geers 2026-07-01 22:39:46 +02:00 • committed by GitHub
parent 3482e79f14
commit d0a4617144
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 2509 additions and 52 deletions

View file

@ -29,6 +29,7 @@ const colors: {
light: string | null; light: string | null;
selfPalette: string[]; selfPalette: string[];
palette: string[]; palette: string[];
batteryPalette: string[];
} = reactive({ } = reactive({
text: null, text: null,
muted: null, muted: null,
@ -69,10 +70,11 @@ const colors: {
"#6D28D9", // violet "#6D28D9", // violet
"#334155", // gray "#334155", // gray
], ],
batteryPalette: ["#0BA631", "#7FC41B", "#0A6E63", "#34D399", "#0E9E8F"],
}); });
// normalize 6-digit hex to 8-digit, then replace alpha // normalize 6-digit hex to 8-digit, then replace alpha
const setAlpha = (color: string | null, alpha: string): string | undefined => { export const setAlpha = (color: string | null, alpha: string): string | undefined => {
if (!color) return undefined; if (!color) return undefined;
const c = color.trim().toLowerCase(); const c = color.trim().toLowerCase();
// #rrggbb → append alpha, #rrggbbaa → replace alpha // #rrggbb → append alpha, #rrggbbaa → replace alpha
@ -113,6 +115,12 @@ export function resolveColors(ids: string[], overrides: DeviceColors = {}): Devi
return result; return result;
} }
// dedicated battery palette, assigned by index (battery page + history battery group)
export function batteryColor(index: number): string {
const p = colors.batteryPalette;
return p[index % p.length] || "";
}
export const dimColor = (color: string | null) => setAlpha(color, "20"); export const dimColor = (color: string | null) => setAlpha(color, "20");
export const lighterColor = (color: string | null) => setAlpha(color, "aa"); export const lighterColor = (color: string | null) => setAlpha(color, "aa");

View file

@ -0,0 +1,242 @@
<template>
<Card :title="$t('batterySettings.usageTab')" :subtitle="chargeSubtitle">
<div class="d-flex gap-3 mb-4" data-testid="battery-priority">
<shopicon-regular-sun
size="s"
class="text-primary flex-shrink-0 mt-1"
></shopicon-regular-sun>
<div>
<div class="fw-bold mb-1">{{ $t("battery.config.priorityTitle") }}</div>
<i18n-t
:keypath="
selectedPrioritySoc > 0
? 'battery.config.priority'
: 'battery.config.priorityNone'
"
tag="p"
class="mb-0"
scope="global"
>
<template #soc>
<InlineSocSelect
id="batteryExpPriority"
:options="priorityOptions"
:selected="selectedPrioritySoc"
:label="fmtSoc(selectedPrioritySoc)"
nowrap
@change="changePrioritySoc"
/>
</template>
</i18n-t>
</div>
</div>
<div class="d-flex gap-3 mb-4" data-testid="battery-buffer">
<shopicon-regular-lightning
size="s"
class="text-primary flex-shrink-0 mt-1"
></shopicon-regular-lightning>
<div>
<div class="fw-bold mb-1">{{ $t("battery.config.bufferTitle") }}</div>
<i18n-t
:keypath="
selectedBufferSoc < 100
? 'battery.config.buffer'
: 'battery.config.bufferNone'
"
tag="p"
class="mb-0"
scope="global"
>
<template #soc>
<InlineSocSelect
id="batteryExpBuffer"
:options="bufferOptions"
:selected="selectedBufferSoc"
:label="fmtSoc(selectedBufferSoc)"
nowrap
@change="changeBufferSoc"
/>
</template>
<template #start>
<InlineSocSelect
id="batteryExpBufferStart"
:options="bufferStartOptions"
:selected="selectedBufferStartSoc"
:label="selectedBufferStartName"
@change="changeBufferStart"
/>
</template>
</i18n-t>
</div>
</div>
<template v-if="controllable">
<hr class="my-3" />
<div class="form-check form-switch">
<input
id="batteryExpDischarge"
:checked="batteryDischargeControl"
class="form-check-input"
type="checkbox"
role="switch"
@change="changeDischargeControl"
/>
<label class="form-check-label" for="batteryExpDischarge">
{{ $t("battery.config.discharge") }}
</label>
</div>
</template>
</Card>
</template>
<script lang="ts">
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/lightning";
import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter";
import api from "@/api";
import type { Battery } from "@/types/evcc";
import Card from "../Helper/Card.vue";
import InlineSocSelect from "./InlineSocSelect.vue";
// Battery usage controls for the experimental page. The logic is intentionally duplicated
// from the classic BatteryUsageSettings.vue (slated for removal) so the two can diverge
// during the transition. Difference here: priority and buffer SoC are independent, there is
// no priority < buffer constraint.
export default defineComponent({
name: "BatteryConfigCard",
components: { Card, InlineSocSelect },
mixins: [formatter],
props: {
bufferSoc: { type: Number, default: 100 },
prioritySoc: { type: Number, default: 0 },
bufferStartSoc: { type: Number, default: 0 },
batteryDischargeControl: Boolean,
battery: { type: Object as PropType<Battery> },
},
data() {
return {
selectedBufferSoc: 100,
selectedPrioritySoc: 0,
selectedBufferStartSoc: 0,
};
},
computed: {
chargeSubtitle(): string {
return `${this.$t("battery.card.soc")} ${this.fmtSoc(this.batterySoc)}`;
},
batterySoc(): number {
return this.battery?.soc ?? 0;
},
controllable(): boolean {
return (this.battery?.devices ?? []).some(({ controllable }) => controllable);
},
priorityOptions() {
const options = [];
for (let i = 100; i >= 0; i -= 5) {
options.push({ value: i, name: this.fmtSoc(i) });
}
return options;
},
bufferOptions() {
const options = [];
for (let i = 100; i >= 5; i -= 5) {
options.push({ value: i, name: this.fmtSoc(i) });
}
return options;
},
bufferStartOptions() {
const options = [];
for (let i = 100; i >= this.selectedBufferSoc; i -= 5) {
options.push({ value: i, name: this.getBufferStartName(i) });
}
options.push({ value: 0, name: this.getBufferStartName(0) });
return options;
},
selectedBufferStartName(): string {
return this.getBufferStartName(this.selectedBufferStartSoc);
},
},
watch: {
prioritySoc: {
handler(soc) {
this.selectedPrioritySoc = soc;
},
immediate: true,
},
bufferSoc: {
handler(soc) {
this.selectedBufferSoc = soc || 100;
},
immediate: true,
},
bufferStartSoc: {
handler(soc) {
this.selectedBufferStartSoc = soc;
},
immediate: true,
},
},
methods: {
changePrioritySoc($event: Event) {
this.savePrioritySoc(parseInt(($event.target as HTMLInputElement).value, 10));
},
changeBufferStart($event: Event) {
this.setBufferStartSoc(parseInt(($event.target as HTMLInputElement).value, 10));
},
async changeBufferSoc($event: Event) {
const soc = parseInt(($event.target as HTMLInputElement).value, 10);
if (soc === 100) {
await this.setBufferStartSoc(0);
} else if (soc > this.selectedBufferStartSoc && this.selectedBufferStartSoc > 0) {
await this.setBufferStartSoc(soc);
}
await this.saveBufferSoc(soc);
},
async setBufferStartSoc(soc: number) {
this.selectedBufferStartSoc = soc;
await this.saveBufferStartSoc(soc);
},
async savePrioritySoc(soc: number) {
this.selectedPrioritySoc = soc;
try {
await api.post(`prioritysoc/${encodeURIComponent(soc)}`);
} catch (err) {
console.error(err);
}
},
async saveBufferSoc(soc: number) {
this.selectedBufferSoc = soc;
try {
await api.post(`buffersoc/${encodeURIComponent(soc)}`);
} catch (err) {
console.error(err);
}
},
async saveBufferStartSoc(soc: number) {
try {
await api.post(`bufferstartsoc/${encodeURIComponent(soc)}`);
} catch (err) {
console.error(err);
}
},
async changeDischargeControl(e: Event) {
try {
await api.post(
`batterydischargecontrol/${(e.target as HTMLInputElement).checked ? "true" : "false"}`
);
} catch (err) {
console.error(err);
}
},
getBufferStartName(value: number) {
const key = value === 0 ? "never" : value === 100 ? "full" : "above";
return this.$t(`battery.config.bufferStart.${key}`, { soc: this.fmtSoc(value) });
},
fmtSoc(soc: number) {
return this.fmtPercentage(soc);
},
},
});
</script>

View file

@ -0,0 +1,173 @@
<template>
<div v-if="batteryAvailable" data-testid="battery-experimental">
<BatteryStatusCards class="mb-4" :battery="state.battery" />
<BatteryHistoryCard
class="mb-4"
:batteries="chartBatteries"
:now="now"
:kwh-available="kWhAvailable"
@range-start="onRangeStart"
/>
<BatteryConfigCard
class="mb-4"
:buffer-soc="state.bufferSoc"
:priority-soc="state.prioritySoc"
:buffer-start-soc="state.bufferStartSoc"
:battery-discharge-control="state.batteryDischargeControl"
:battery="state.battery"
/>
<Card v-if="gridChargeVisible" :title="$t('batterySettings.gridChargeTab')">
<SmartCostLimit v-bind="smartCostLimitProps" />
</Card>
</div>
<p v-else class="my-4 text-muted">{{ $t("batterySettings.noBattery") }}</p>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import store from "@/store";
import settings from "@/settings";
import api from "@/api";
import { SMART_COST_TYPE, CURRENCY, type BatteryMeter } from "@/types/evcc";
import Card from "../Helper/Card.vue";
import SmartCostLimit from "../Tariff/SmartCostLimit.vue";
import BatteryStatusCards from "./BatteryStatusCards.vue";
import BatteryConfigCard from "./BatteryConfigCard.vue";
import BatteryHistoryCard from "./BatteryHistoryCard.vue";
import { historyToSeries, forecastToSeries, buildChartBatteries } from "./history";
import type { BatteryHistorySeries, BatterySeries } from "./types";
const CHUNK_MS = 48 * 3600 * 1000; // 48h load/grow step
export default defineComponent({
name: "BatteryExperimental",
components: {
Card,
SmartCostLimit,
BatteryStatusCards,
BatteryConfigCard,
BatteryHistoryCard,
},
data() {
const now = new Date();
return {
now,
rangeStart: new Date(now.getTime() - CHUNK_MS), // earliest time the chart currently shows
loadedFrom: null as Date | null, // earliest time we have history for
loadedTo: null as Date | null, // latest time we have history for
loading: false,
rawSeries: [] as BatteryHistorySeries[],
};
},
computed: {
state() {
return store.state;
},
historyUpdated(): string | undefined {
return store.state.historyUpdated;
},
devices(): BatteryMeter[] {
return this.state.battery?.devices ?? [];
},
batteryAvailable(): boolean {
return this.devices.length > 0;
},
evopt() {
return this.state.evopt;
},
kWhAvailable(): boolean {
return this.batteryAvailable && this.devices.every((d) => d.capacity > 0);
},
// earliest history we want loaded: a chunk before the visible start (kept as a buffer),
// never later than what we already loaded so the window only grows
requestedFrom(): Date {
const wanted = new Date(this.rangeStart.getTime() - CHUNK_MS);
return this.loadedFrom && this.loadedFrom < wanted ? this.loadedFrom : wanted;
},
chartBatteries(): BatterySeries[] {
return buildChartBatteries(
this.devices,
historyToSeries(this.rawSeries),
forecastToSeries(this.evopt, this.now.getTime())
);
},
gridChargePossible(): boolean {
return (
this.devices.some(({ controllable }) => controllable) &&
!!this.state.smartCostAvailable
);
},
gridChargeLimit(): number | null {
return this.state.batteryGridChargeLimit ?? null;
},
gridChargeVisible(): boolean {
return this.gridChargePossible || this.gridChargeLimit !== null;
},
gridChargeTariff() {
const { forecast, smartCostType } = this.state;
return smartCostType === SMART_COST_TYPE.CO2 ? forecast?.co2 : forecast?.grid;
},
smartCostLimitProps() {
return {
currentLimit: this.gridChargeLimit,
lastLimit: settings.lastBatterySmartCostLimit,
smartCostType: this.state.smartCostType,
currency: this.state.currency || CURRENCY.EUR,
tariff: this.gridChargeTariff,
possible: this.gridChargePossible,
};
},
},
watch: {
// fetchHistory reloads only when the needed range is not already covered
requestedFrom: {
handler() {
this.fetchHistory();
},
immediate: true,
},
now() {
this.fetchHistory();
},
// bumped on each metrics persist; advance clock to reload recent history
historyUpdated() {
this.now = new Date();
},
},
methods: {
onRangeStart(start: Date) {
this.rangeStart = start;
},
async fetchHistory() {
if (this.loading || !this.batteryAvailable) return;
const from = this.requestedFrom;
const to = this.now;
const backLoaded = this.loadedFrom !== null && this.loadedFrom <= from;
const frontLoaded = this.loadedTo !== null && this.loadedTo >= to;
if (backLoaded && frontLoaded) return;
this.loading = true;
try {
const res = await api.get("history/energy", {
params: {
from: from.toISOString(),
to: to.toISOString(),
aggregate: "15m",
grouped: false,
group: "battery",
},
});
this.rawSeries = res.data || [];
this.loadedFrom = from;
this.loadedTo = to;
} catch (e) {
console.error("failed to load battery history", e);
} finally {
this.loading = false;
}
},
},
});
</script>

View file

@ -0,0 +1,108 @@
import BatteryHistoryCard from "./BatteryHistoryCard.vue";
import type { SocPoint, BatterySeries } from "./types";
import type { Meta, StoryFn } from "@storybook/vue3";
export default {
title: "Battery/BatteryHistoryCard",
component: BatteryHistoryCard,
parameters: {
layout: "fullscreen",
backgrounds: {
default: "black",
values: [{ name: "black", value: "#000" }],
},
},
} as Meta<typeof BatteryHistoryCard>;
const HOUR = 3600 * 1000;
const STEP = 15 * 60 * 1000; // 15min, matches the history/energy "15m" aggregate
const NOW = new Date("2026-07-01T12:30:00");
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
const smoothstep = (a: number, b: number, x: number) => {
const t = clamp((x - a) / (b - a), 0, 1);
return t * t * (3 - 2 * t);
};
// relative daily soc profile (hour -> 0..~1.1): flat low overnight, charge ramp late morning,
// overshoot midday so it plateaus at full, steep discharge in the evening. Not a sine.
const PROFILE: [number, number][] = [
[0, 0.2],
[5, 0.05],
[8, 0.08],
[11, 0.5],
[14, 1.12],
[17, 1.05],
[19, 0.6],
[21, 0.32],
[24, 0.2],
];
function dayShape(hour: number): number {
for (let i = 1; i < PROFILE.length; i++) {
const [h0, v0] = PROFILE[i - 1]!;
const [h1, v1] = PROFILE[i]!;
if (hour <= h1) return v0 + (v1 - v0) * smoothstep(h0, h1, hour);
}
return PROFILE.at(-1)![1];
}
// per-battery soc timeline at 15min resolution, same shape as the /history/energy data.
// each battery shifts the daily rhythm and its floor/ceiling; a slow continuous wave adds
// day-to-day variation. clamping the overshoot creates realistic full/empty plateaus.
function generateTimeline(from: number, to: number, seed: number): SocPoint[] {
const offset = seed * 1.6; // shift the daily rhythm per battery
const floor = 8 + seed * 5;
const ceil = 100 - seed * 8;
const pts: SocPoint[] = [];
for (let t = Math.ceil(from / STEP) * STEP; t <= to; t += STEP) {
const d = new Date(t);
const hour = (((d.getHours() + d.getMinutes() / 60 - offset) % 24) + 24) % 24;
const drift = 6 * Math.sin(t / (37 * HOUR) + seed);
const soc = floor + (ceil - floor) * clamp(dayShape(hour), 0, 1) + drift;
pts.push({ t, soc: clamp(soc, 5, 100) });
}
return pts;
}
const TITLES = ["Sungrow", "Anker", "Fronius", "BYD"];
const CAPACITIES = [13.5, 7.5, 10, 5];
function buildBatteries(count: number, withForecast: boolean, now: number): BatterySeries[] {
return Array.from({ length: count }, (_, i) => {
const history = generateTimeline(now - 60 * HOUR, now, i);
const title = TITLES[i] ?? `Battery ${i + 1}`;
return {
id: title,
title,
capacity: CAPACITIES[i] ?? 8,
currentSoc: history.at(-1)?.soc ?? 50,
history,
forecast: withForecast ? generateTimeline(now, now + 36 * HOUR, i) : [],
};
});
}
function scenario(count: number, withForecast: boolean): StoryFn<typeof BatteryHistoryCard> {
return () => ({
components: { BatteryHistoryCard },
setup() {
return { batteries: buildBatteries(count, withForecast, NOW.getTime()), now: NOW };
},
template: `<div class="p-4" style="max-width: 900px">
<BatteryHistoryCard :batteries="batteries" :now="now" kwh-available />
</div>`,
});
}
export const OneBattery = scenario(1, false);
export const OneBatteryForecast = scenario(1, true);
export const TwoBatteries = scenario(2, false);
export const TwoBatteriesForecast = scenario(2, true);
export const ThreeBatteries = scenario(3, false);
export const ThreeBatteriesForecast = scenario(3, true);
export const FourBatteries = scenario(4, false);
export const FourBatteriesForecast = scenario(4, true);

View file

@ -0,0 +1,165 @@
<template>
<Card edge-to-edge>
<template #nav>
<WindowNav
:label="windowLabel"
:prev-disabled="prevDisabled"
:next-disabled="nextDisabled"
@prev="changeOffset(-1)"
@next="changeOffset(1)"
/>
</template>
<template #actions>
<SelectGroup
v-if="showUnitToggle"
id="batteryUnit"
:options="unitOptions"
:modelValue="effectiveUnit"
@update:model-value="updateUnit"
/>
</template>
<BatteryHistoryChart
:batteries="batteries"
:mode="effectiveUnit"
:win-start="winStart.getTime()"
:win-end="winEnd.getTime()"
:now="now.getTime()"
:has-forecast="windowHasForecast"
:day-offset="dayOffset"
:focused="focusedBattery"
/>
<LegendList
v-if="batteries.length > 1"
class="mt-4 mb-0"
:legends="chartLegends"
@focus="onLegendFocus"
/>
</Card>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import settings from "@/settings";
import { batteryColor } from "@/colors";
import type { Legend } from "../Sessions/types";
import type { BatterySeries } from "./types";
import Card from "../Helper/Card.vue";
import SelectGroup from "../Helper/SelectGroup.vue";
import LegendList from "../Sessions/LegendList.vue";
import WindowNav from "./WindowNav.vue";
import BatteryHistoryChart from "./BatteryHistoryChart.vue";
const HOUR = 3600 * 1000;
const MIN_OFFSET = -30; // page back ~30 days
// History + forecast chart with its date navigation, unit toggle and legend. Presentation
// only: paging, focus and the %/kWh unit are local view state; battery data is fed in via
// props (fetching stays in the page).
export default defineComponent({
name: "BatteryHistoryCard",
components: { Card, SelectGroup, LegendList, WindowNav, BatteryHistoryChart },
mixins: [formatter],
props: {
batteries: { type: Array as PropType<BatterySeries[]>, default: () => [] },
now: { type: Object as PropType<Date>, required: true },
kwhAvailable: Boolean,
},
emits: ["range-start"],
data() {
return {
dayOffset: 0,
focusedBattery: null as number | null,
selectedUnit: (settings.batteryUnit || "soc") as "soc" | "energy",
};
},
computed: {
// kWh (stacked energy) only makes sense across several batteries, so the toggle
// appears only with more than one battery that all report a capacity
showUnitToggle(): boolean {
return this.batteries.length > 1 && this.kwhAvailable;
},
effectiveUnit(): "soc" | "energy" {
return this.selectedUnit === "energy" && this.showUnitToggle ? "energy" : "soc";
},
unitOptions() {
return [
{ value: "soc", name: "%" },
{ value: "energy", name: "kWh" },
];
},
hasForecastData(): boolean {
return this.batteries.some((b) => b.forecast.length > 0);
},
windowHasForecast(): boolean {
return this.hasForecastData && this.winEnd > this.now;
},
winStart(): Date {
const baseStartH = this.hasForecastData ? 24 : 48;
return new Date(this.now.getTime() - baseStartH * HOUR + this.dayOffset * 24 * HOUR);
},
winEnd(): Date {
const baseEndH = this.hasForecastData ? 24 : 0;
return new Date(this.now.getTime() + baseEndH * HOUR + this.dayOffset * 24 * HOUR);
},
windowLabel(): string {
// short weekday + day, no month (e.g. "Sa. 27. – Mo. 29.")
const fmt = (d: Date) => `${this.weekdayShort(d)} ${d.getDate()}.`;
return `${fmt(this.winStart)} – ${fmt(this.winEnd)}`;
},
prevDisabled(): boolean {
return this.dayOffset <= MIN_OFFSET;
},
// one slide forward reveals the second half of the 48h optimizer forecast
maxOffset(): number {
return this.hasForecastData ? 1 : 0;
},
nextDisabled(): boolean {
return this.dayOffset >= this.maxOffset;
},
chartLegends(): Legend[] {
const focused = this.focusedBattery;
return this.batteries.map((b, i) => ({
label: b.title,
color: batteryColor(i),
type: "area",
focusable: true,
focusKey: i,
dim: focused !== null && focused !== i,
value:
this.effectiveUnit === "energy"
? this.fmtWh((b.currentSoc / 100) * b.capacity * 1e3, POWER_UNIT.AUTO)
: this.fmtPercentage(b.currentSoc),
}));
},
},
watch: {
// report the visible start so the page can keep enough history loaded (initial + paging)
winStart: {
handler(start: Date) {
this.$emit("range-start", start);
},
immediate: true,
},
// drop a focus that points past the end after a battery is removed (else chart blanks)
"batteries.length"(len: number) {
if (this.focusedBattery !== null && this.focusedBattery >= len) {
this.focusedBattery = null;
}
},
},
methods: {
updateUnit(value: string | number | boolean | null) {
this.selectedUnit = value === "energy" ? "energy" : "soc";
settings.batteryUnit = this.selectedUnit;
},
changeOffset(dir: number) {
this.dayOffset = Math.min(this.maxOffset, Math.max(MIN_OFFSET, this.dayOffset + dir));
},
onLegendFocus(legend: Legend) {
const key = legend.focusKey as number;
this.focusedBattery = this.focusedBattery === key ? null : key;
},
},
});
</script>

View file

@ -0,0 +1,495 @@
<template>
<div ref="chartEl" class="battery-chart"></div>
</template>
<script lang="ts">
import { defineComponent, markRaw, type PropType } from "vue";
import {
echarts,
FONT_FAMILY,
forecastYAxis,
tooltipStyle,
tooltipTable,
type TooltipRow,
} from "../Forecast/echarts";
import colors, { dimColor, lighterColor, batteryColor } from "@/colors";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import { is12hFormat } from "@/units";
import type { SocPoint, BatterySeries } from "./types";
type EChartsType = ReturnType<typeof echarts.init>;
type Point = [number, number];
export default defineComponent({
name: "BatteryHistoryChart",
mixins: [formatter],
props: {
batteries: { type: Array as PropType<BatterySeries[]>, default: () => [] },
mode: { type: String as PropType<"soc" | "energy">, default: "soc" },
winStart: { type: Number, required: true },
winEnd: { type: Number, required: true },
now: { type: Number, required: true },
hasForecast: Boolean,
dayOffset: { type: Number, default: 0 }, // paging position; slide animates only when it changes
focused: { type: Number as PropType<number | null>, default: null },
},
data() {
return {
chart: null as EChartsType | null,
previousMode: "soc" as string,
prevWinStart: 0,
prevWinEnd: 0,
prevDayOffset: 0,
hasRendered: false,
slideRaf: undefined as number | undefined,
};
},
computed: {
single(): boolean {
return this.batteries.length === 1;
},
nowInWindow(): boolean {
return this.now >= this.winStart && this.now <= this.winEnd;
},
totalCapacity(): number {
return this.batteries.reduce((s, b) => s + b.capacity, 0);
},
totalCurrentKWh(): number {
return this.batteries.reduce((s, b) => s + (b.currentSoc / 100) * b.capacity, 0);
},
yMax(): number {
if (this.mode === "soc") return 100;
return Math.max(Math.ceil(this.totalCapacity / 5) * 5, 5);
},
energyGrid(): number[] {
// union of all timestamps (full preloaded range) for stack alignment; the
// x-axis min/max clips to the visible window, so paging only shifts the axis
const set = new Set<number>([this.now]);
for (const b of this.batteries) {
for (const p of [...b.history, ...b.forecast]) set.add(p.t);
}
return [...set].sort((a, b) => a - b);
},
echartsSeries(): Record<string, unknown>[] {
const series: Record<string, unknown>[] = [];
if (this.mode === "soc") {
this.batteries.forEach((b, i) => {
if (this.focused !== null && this.focused !== i) return;
const c = batteryColor(i);
series.push({
id: this.seriesId(b.id, "hist"),
type: "line",
z: 3,
data: this.socHistory(b),
showSymbol: false,
lineStyle: { color: c, width: 2 },
itemStyle: { color: c },
...(this.single
? {
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: lighterColor(c) || c },
{ offset: 1, color: (c || "#000000") + "00" },
]),
},
}
: {}),
emphasis: { disabled: true },
});
series.push({
id: this.seriesId(b.id, "fc"),
type: "line",
z: 3,
data: this.socForecast(b),
showSymbol: false,
lineStyle: { color: c, width: 2, type: "dashed" },
itemStyle: { color: c },
...(this.single ? { areaStyle: { color: dimColor(c) } } : {}),
emphasis: { disabled: true },
});
});
} else {
this.batteries.forEach((b, i) => {
if (this.focused !== null && this.focused !== i) return;
const c = batteryColor(i);
series.push({
id: this.seriesId(b.id, "hist"),
type: "line",
stack: "e-hist",
z: 2 + i,
data: this.energyData(b, "hist"),
showSymbol: false,
smooth: 0.4,
lineStyle: { width: 0 },
itemStyle: { color: c },
areaStyle: { color: c },
emphasis: { disabled: true },
});
series.push({
id: this.seriesId(b.id, "fc"),
type: "line",
stack: "e-fc",
z: 2 + i,
data: this.energyData(b, "fc"),
showSymbol: false,
smooth: 0.4,
lineStyle: { color: c, width: 1, type: "dashed" },
itemStyle: { color: c },
areaStyle: { color: dimColor(c) },
emphasis: { disabled: true },
});
});
}
// now marker as a 2-point series so it slides with the axis on paging
if (this.nowInWindow) {
series.push({
id: "now-line",
type: "line",
data: [
[this.now, 0],
[this.now, this.yMax],
],
showSymbol: false,
silent: true,
z: 5,
lineStyle: { color: colors.muted || "", width: 1, type: "dashed" },
emphasis: { disabled: true },
});
}
// overlay carries the forecast-region shading
series.push({
id: "overlay",
type: "line",
data: [],
silent: true,
z: 4,
markArea: this.markArea,
});
return series;
},
markArea(): Record<string, unknown> {
const data =
this.hasForecast && this.nowInWindow
? [[{ xAxis: this.now }, { xAxis: this.winEnd }]]
: [];
return { silent: true, itemStyle: { color: colors.muted || "", opacity: 0.06 }, data };
},
chartOption(): Record<string, unknown> {
return {
// no echarts animation; paging is driven manually via slideWindow
animation: false,
textStyle: { fontFamily: FONT_FAMILY },
grid: { top: 10, right: 36, bottom: 26, left: 0, borderWidth: 0 },
tooltip: {
trigger: "axis",
axisPointer: {
type: "line",
lineStyle: { color: colors.muted || "", opacity: 0.4 },
},
...tooltipStyle(colors.text || "", () => this.chart),
formatter: this.tooltipFormatter,
},
xAxis: this.xAxes,
yAxis: forecastYAxis({
min: 0,
max: this.yMax,
position: "right",
...(this.mode === "soc" ? { interval: 25 } : { splitNumber: 5 }),
axisLabel: {
color: colors.muted || "",
formatter: (v: number) => this.fmtNumber(v, 0),
},
}),
series: this.echartsSeries,
};
},
xAxes(): Record<string, unknown>[] {
const h12 = is12hFormat();
return [
// hours every 6h, midnight handled by the day axis below
{
type: "time",
min: this.winStart,
max: this.winEnd,
minInterval: 6 * 3600 * 1000,
axisLine: { show: false },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: {
color: colors.muted || "",
fontSize: 11,
formatter: (value: number) => {
const d = new Date(value);
const h = d.getHours();
if (d.getMinutes() !== 0 || h === 0 || h % 6 !== 0) return "";
return h12 ? `${h % 12 || 12} ${h < 12 ? "AM" : "PM"}` : String(h);
},
},
},
// day axis: a tick at every local midnight, labelled with the weekday + dashed divider
{
type: "time",
position: "bottom",
min: this.winStart,
max: this.winEnd,
minInterval: 24 * 3600 * 1000,
maxInterval: 24 * 3600 * 1000,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: colors.muted || "",
fontSize: 11,
formatter: (value: number) => this.weekdayShort(new Date(value)),
},
splitLine: {
show: true,
showMinLine: false,
showMaxLine: false,
lineStyle: { color: colors.border || "", type: "dashed" },
},
},
];
},
},
watch: {
chartOption: {
handler() {
const modeChanged = this.previousMode !== this.mode;
const windowChanged =
this.prevWinStart !== this.winStart || this.prevWinEnd !== this.winEnd;
// a running slide re-renders from the latest chartOption every frame, so data that
// loads mid-slide is picked up automatically. only intervene if the target changed.
if (this.slideRaf && !windowChanged && !modeChanged) return;
// animate only on real paging (dayOffset change); forecast arrival and clock
// advance also shift the window but must not trigger the slide
const paging =
this.hasRendered && !modeChanged && this.dayOffset !== this.prevDayOffset;
const fromStart = this.prevWinStart;
const fromEnd = this.prevWinEnd;
this.cancelSlide();
this.previousMode = this.mode;
this.prevWinStart = this.winStart;
this.prevWinEnd = this.winEnd;
this.prevDayOffset = this.dayOffset;
if (paging) {
this.slideWindow(fromStart, fromEnd, this.winStart, this.winEnd);
} else {
this.chart?.setOption(
{ ...this.chartOption, animation: false },
modeChanged ? { notMerge: true } : { replaceMerge: ["series"] }
);
this.hasRendered = true;
this.$nextTick(() => this.updateGraphic());
}
},
deep: true,
},
},
mounted() {
const el = this.$refs["chartEl"] as HTMLElement;
this.chart = markRaw(echarts.init(el));
this.chart.setOption(this.chartOption);
this.$nextTick(() => this.updateGraphic());
window.addEventListener("resize", this.resize);
// initial render done here (not via the watcher), so paging animates from the first click
this.hasRendered = true;
this.prevWinStart = this.winStart;
this.prevWinEnd = this.winEnd;
this.prevDayOffset = this.dayOffset;
this.previousMode = this.mode;
},
beforeUnmount() {
window.removeEventListener("resize", this.resize);
this.cancelSlide();
this.chart?.dispose();
},
methods: {
// stable echarts series id per battery/part; single source for building and tooltip lookup
seriesId(id: string, part: "hist" | "fc"): string {
return `${this.mode}-${id}-${part}`;
},
resize() {
this.chart?.resize();
this.updateGraphic();
},
cancelSlide() {
if (this.slideRaf) cancelAnimationFrame(this.slideRaf);
this.slideRaf = undefined;
},
// tween the x-axis window ourselves so axis and series slide together (echarts cannot
// animate axis min/max, so changing them directly snaps the labels/gridlines). each frame
// re-renders from the latest chartOption, so data loaded mid-slide is picked up seamlessly.
slideWindow(fromStart: number, fromEnd: number, toStart: number, toEnd: number) {
const dur = 450;
const ease = (p: number) => 1 - Math.pow(1 - p, 3); // cubicOut
const apply = (min: number, max: number) => {
this.chart?.setOption(
{
...this.chartOption,
animation: false,
xAxis: [
{ min, max },
{ min, max },
],
},
{ replaceMerge: ["series"] }
);
this.updateGraphic();
};
apply(fromStart, fromEnd); // start position, synchronous, before first paint
this.hasRendered = true;
const t0 = performance.now();
const step = (t: number) => {
const p = Math.min(1, (t - t0) / dur);
const k = ease(p);
apply(fromStart + (toStart - fromStart) * k, fromEnd + (toEnd - fromEnd) * k);
this.slideRaf = p < 1 ? requestAnimationFrame(step) : undefined;
};
this.slideRaf = requestAnimationFrame(step);
},
interp(points: SocPoint[], t: number): number | null {
if (!points.length) return null;
if (t <= points[0]!.t) return points[0]!.soc;
if (t >= points[points.length - 1]!.t) return points[points.length - 1]!.soc;
for (let i = 1; i < points.length; i++) {
const a = points[i - 1]!;
const b = points[i]!;
if (t <= b.t) {
const f = (t - a.t) / (b.t - a.t);
return a.soc + f * (b.soc - a.soc);
}
}
return points[points.length - 1]!.soc;
},
socHistory(b: BatterySeries): Point[] {
// full history up to now; the x-axis min/max clips to the window
const pts: Point[] = b.history.filter((p) => p.t <= this.now).map((p) => [p.t, p.soc]);
pts.push([this.now, b.currentSoc]);
return pts;
},
socForecast(b: BatterySeries): Point[] {
if (!b.forecast.length) return [];
const pts: Point[] = [[this.now, b.currentSoc]];
for (const p of b.forecast) {
if (p.t > this.now) pts.push([p.t, p.soc]);
}
return pts.length > 1 ? pts : [];
},
energyData(b: BatterySeries, part: "hist" | "fc"): Point[] {
const all = [...b.history, ...b.forecast].sort((a, c) => a.t - c.t);
const factor = b.capacity / 100;
const first = all[0]?.t ?? Infinity;
const last = all.at(-1)?.t ?? -Infinity;
return this.energyGrid
.filter((t) => (part === "hist" ? t <= this.now : t >= this.now))
.map((t) => {
// contribute nothing outside this battery's known range instead of
// back-/forward-extending its edge soc across the stacked band
if (t < first || t > last) return [t, 0] as Point;
const soc = this.interp(all, t);
return [t, soc == null ? 0 : soc * factor] as Point;
});
},
tooltipFormatter(
params: { axisValue: number; seriesId?: string; value?: Point }[]
): string {
const arr = Array.isArray(params) ? params : [params];
if (!arr.length) return "";
const t = new Date(arr[0]!.axisValue);
const head = `${this.weekdayShort(t)} ${this.fmtHourMinute(t)}`;
const byId = new Map<string, number>();
for (const p of arr) {
const v = p.value?.[1];
if (v != null && !Number.isNaN(v) && p.seriesId) byId.set(p.seriesId, v);
}
const rows: TooltipRow[] = [];
this.batteries.forEach((b) => {
// past points come from the hist series, future ones from the fc series
const v =
byId.get(this.seriesId(b.id, "hist")) ?? byId.get(this.seriesId(b.id, "fc"));
if (v == null) return;
const val =
this.mode === "soc"
? this.fmtPercentage(v)
: this.fmtWh(v * 1e3, POWER_UNIT.KW, true, 1);
rows.push({ name: this.batteries.length > 1 ? b.title : undefined, values: [val] });
});
if (!rows.length) return "";
return tooltipTable(head, rows);
},
updateGraphic() {
if (!this.chart) return;
const finder = { xAxisIndex: 0, yAxisIndex: 0 };
const elements: Record<string, unknown>[] = [];
if (this.nowInWindow) {
// badge sits to the left of the now point (matches the design, avoids clipping the right edge)
const badge = (px: number[], text: string, fill: string, textFill = "#fff") => {
elements.push({
type: "circle",
z: 10,
shape: { cx: px[0], cy: px[1], r: 4 },
style: { fill, stroke: colors.background || "", lineWidth: 2 },
});
elements.push({
type: "text",
z: 11,
x: px[0] - 9,
y: px[1],
style: {
text,
fill: textFill,
font: `bold 12px ${FONT_FAMILY}`,
align: "right",
verticalAlign: "middle",
backgroundColor: fill,
padding: [4, 9],
borderRadius: 4,
},
});
};
if (this.mode === "soc") {
this.batteries.forEach((b, i) => {
if (this.focused !== null && this.focused !== i) return;
const px = this.chart!.convertToPixel(finder, [this.now, b.currentSoc]);
if (px) badge(px, this.fmtPercentage(b.currentSoc), batteryColor(i));
});
} else {
// focused: that battery's energy (its band is the only one shown); else the total
const f = this.focused;
const kWh =
f !== null && this.batteries[f]
? (this.batteries[f]!.currentSoc / 100) * this.batteries[f]!.capacity
: this.totalCurrentKWh;
const px = this.chart.convertToPixel(finder, [this.now, kWh]);
if (px) {
const label = this.fmtWh(kWh * 1e3, POWER_UNIT.KW, true, 1);
if (f !== null) {
badge(px, label, batteryColor(f));
} else {
// group total sits on the stack top, so use a neutral (not battery) color
badge(px, label, colors.text || "", colors.background || "#fff");
}
}
}
}
this.chart.setOption({ graphic: { elements } }, { replaceMerge: ["graphic"] });
},
},
});
</script>
<style scoped>
.battery-chart {
width: 100%;
height: 260px;
}
@media (max-width: 575.98px) {
.battery-chart {
height: 220px;
}
}
</style>

View file

@ -0,0 +1,88 @@
import BatteryStatusCard from "./BatteryStatusCard.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
export default {
title: "Battery/BatteryStatusCard",
component: BatteryStatusCard,
parameters: {
layout: "centered",
},
argTypes: {
title: { control: "text" },
soc: { control: { type: "range", min: 0, max: 100 } },
power: { control: "number", description: "W, + charging / - discharging" },
capacity: { control: "number", description: "kWh, 0 = unspecified" },
color: { control: "color" },
},
} as Meta<typeof BatteryStatusCard>;
// narrow: how the card looks as a column in a multi-battery grid
const Narrow: StoryFn<typeof BatteryStatusCard> = (args) => ({
components: { BatteryStatusCard },
setup() {
return { args };
},
template: '<div style="width: 420px"><BatteryStatusCard v-bind="args" /></div>',
});
const base = { title: "Sungrow", capacity: 13.5, color: "#0BA631" };
export const Charging = Narrow.bind({});
Charging.args = { ...base, soc: 76, power: 800 };
export const Discharging = Narrow.bind({});
Discharging.args = {
...base,
title: "Anker",
capacity: 7.5,
color: "#7FC41B",
soc: 40,
power: -1200,
};
export const Idle = Narrow.bind({});
Idle.args = { ...base, title: "Battery", capacity: 13.4, soc: 98, power: 0 };
export const Empty = Narrow.bind({});
Empty.args = { ...base, title: "Battery", capacity: 13.4, soc: 4, power: -600 };
export const NoCapacity = Narrow.bind({});
NoCapacity.args = { ...base, title: "Battery", capacity: 0, soc: 55, power: 300 };
// optimizer states, stacked below the values (as on the combined card)
export const OptimizerNormal = Narrow.bind({});
OptimizerNormal.args = { ...base, soc: 62, power: 900, suggestion: { action: "normal" } };
export const OptimizerHold = Narrow.bind({});
OptimizerHold.args = { ...base, soc: 88, power: 0, suggestion: { action: "hold" } };
export const OptimizerCharge = Narrow.bind({});
OptimizerCharge.args = {
...base,
soc: 48,
power: 2500,
suggestion: { action: "charge" },
};
export const OptimizerHoldCharge = Narrow.bind({});
OptimizerHoldCharge.args = { ...base, soc: 70, power: 0, suggestion: { action: "holdcharge" } };
export const WithForecast = Narrow.bind({});
WithForecast.args = {
...base,
soc: 62,
power: 900,
forecast: {
highest: { soc: 100, time: "2026-07-01T16:30:00+02:00" },
lowest: { soc: 12, time: "2026-07-02T06:00:00+02:00" },
},
};
export const SuggestionAndForecast = Narrow.bind({});
SuggestionAndForecast.args = {
...base,
soc: 48,
power: 2500,
suggestion: { action: "charge" },
forecast: { highest: { soc: 100, time: "2026-07-01T18:00:00+02:00", limit: true } },
};

View file

@ -0,0 +1,104 @@
<template>
<Card :title="title" data-testid="battery-status-card">
<div class="d-flex justify-content-between align-items-start">
<LabelAndValue :label="$t('battery.card.soc')" align="start">
<div class="d-flex align-items-center gap-2">
<SocGauge :soc="soc" :color="color" :size="38" class="soc-gauge" />
<span class="value fw-bold" :style="{ color }">{{ fmtPercentage(soc) }}</span>
</div>
</LabelAndValue>
<LabelAndValue
class="text-center"
:label="$t('battery.card.energy')"
:value="hasCapacity ? energyKWh : '–'"
:valueFmt="hasCapacity ? energyFmt : undefined"
:extraValue="hasCapacity ? energySub : undefined"
align="center"
/>
<LabelAndValue
class="text-end"
:label="powerInfo.label"
:value="powerInfo.value"
align="end"
/>
</div>
<OptimizerInfo
v-if="suggestion || forecast?.highest || forecast?.lowest"
class="optimizer"
:suggestion="suggestion"
:forecast="forecast"
/>
</Card>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import Card from "../Helper/Card.vue";
import LabelAndValue from "../Helper/LabelAndValue.vue";
import SocGauge from "./SocGauge.vue";
import OptimizerInfo from "./OptimizerInfo.vue";
import type { BatteryForecast } from "@/types/evcc";
import type { BatterySuggestion } from "./types";
export default defineComponent({
name: "BatteryStatusCard",
components: { Card, LabelAndValue, SocGauge, OptimizerInfo },
mixins: [formatter],
inheritAttrs: false,
props: {
title: { type: String, default: "" },
soc: { type: Number, default: 0 },
power: { type: Number, default: 0 }, // W, + charging / - discharging
capacity: { type: Number, default: 0 }, // kWh, 0 = unspecified
color: { type: String, default: "" },
suggestion: { type: Object as PropType<BatterySuggestion | null>, default: null },
forecast: { type: Object as PropType<BatteryForecast | null>, default: null },
},
computed: {
hasCapacity(): boolean {
return this.capacity > 0;
},
energyKWh(): number {
return (this.soc / 100) * this.capacity;
},
energyFmt(): (n: number) => string {
return (n: number) => this.fmtWh(n * 1e3, POWER_UNIT.KW, true, 1);
},
energySub(): string {
return this.$t("battery.card.ofTotal", {
total: this.fmtWh(this.capacity * 1e3, POWER_UNIT.KW, true, 1),
});
},
powerInfo(): { label: string; value: string } {
const abs = Math.abs(this.power);
const value = this.fmtW(abs, POWER_UNIT.AUTO);
if (abs < 50) {
return { label: this.$t("battery.card.power"), value };
}
return this.power > 0
? { label: this.$t("battery.card.charging"), value }
: { label: this.$t("battery.card.discharging"), value };
},
},
});
</script>
<style scoped>
.value {
font-size: 18px;
}
/* let the gauge straddle the value line so the % sits level with the other values */
.soc-gauge {
margin-top: -0.5rem;
margin-bottom: -0.5rem;
}
.optimizer {
margin-top: 1rem;
padding-top: 0.5rem;
border-top: 1px solid var(--bs-border-color);
}
</style>

View file

@ -0,0 +1,82 @@
import BatteryStatusCards from "./BatteryStatusCards.vue";
import type { Battery, BatteryMeter } from "@/types/evcc";
import type { Meta, StoryFn } from "@storybook/vue3";
export default {
title: "Battery/BatteryStatusCards",
component: BatteryStatusCards,
parameters: {
layout: "fullscreen",
},
} as Meta<typeof BatteryStatusCards>;
const Template: StoryFn<typeof BatteryStatusCards> = (args) => ({
components: { BatteryStatusCards },
setup() {
return { args };
},
template: '<div class="p-4"><BatteryStatusCards v-bind="args" /></div>',
});
function device(overrides: Partial<BatteryMeter>): BatteryMeter {
return { power: 0, soc: 50, controllable: false, capacity: 10, ...overrides };
}
function battery(devices: BatteryMeter[], forecast?: Battery["forecast"]): Battery {
return {
power: devices.reduce((s, d) => s + d.power, 0),
capacity: devices.reduce((s, d) => s + d.capacity, 0),
soc: 50,
devices,
forecast,
};
}
export const Single = Template.bind({});
Single.args = {
battery: battery([device({ title: "Sungrow", soc: 76, power: 800, capacity: 13.5 })]),
};
export const Multiple = Template.bind({});
Multiple.args = {
battery: {
...battery([
device({ title: "Sungrow", soc: 76, power: 800, capacity: 13.5, controllable: true }),
device({ title: "Anker", soc: 40, power: -1200, capacity: 7.5 }),
]),
soc: 63,
},
};
// single battery: optimizer gets its own card, showing suggestion + high + low
export const WithForecast = Template.bind({});
WithForecast.args = {
battery: battery([device({ title: "Battery", soc: 62, power: 900, capacity: 10 })], {
highest: { soc: 100, time: "2026-07-01T16:30:00+02:00" },
lowest: { soc: 12, time: "2026-07-02T06:00:00+02:00" },
}),
suggestion: { action: "charge" },
};
// forecast is a site-aggregate value, so it lands on the combined card
export const MultipleWithForecast = Template.bind({});
MultipleWithForecast.args = {
battery: {
...battery(
[
device({ title: "Sungrow", soc: 76, power: 800, capacity: 13.5, controllable: true }),
device({ title: "Anker", soc: 40, power: -1200, capacity: 7.5 }),
],
{
highest: { soc: 100, time: "2026-07-01T16:30:00+02:00" },
lowest: { soc: 15, time: "2026-07-02T05:00:00+02:00" },
}
),
soc: 63,
},
};
export const SocOnly = Template.bind({});
SocOnly.args = {
battery: battery([device({ title: "Battery", soc: 55, power: 300, capacity: 0 })]),
};

View file

@ -0,0 +1,86 @@
<template>
<div v-if="showSplit" class="cards cards--split gap-3">
<BatteryStatusCard v-bind="cards[0]" />
<Card :title="$t('battery.optimizer.title')" data-testid="battery-optimizer-card">
<OptimizerInfo :suggestion="suggestion" :forecast="batteryForecast" />
</Card>
</div>
<div v-else class="cards gap-3">
<BatteryStatusCard v-for="card in cards" :key="card.id" v-bind="card" />
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { batteryColor } from "@/colors";
import type { Battery, BatteryForecast, BatteryMeter } from "@/types/evcc";
import Card from "../Helper/Card.vue";
import BatteryStatusCard from "./BatteryStatusCard.vue";
import OptimizerInfo from "./OptimizerInfo.vue";
import type { BatterySuggestion, BatteryStatusCardModel } from "./types";
// One battery + forecast splits into battery and optimizer cards; several batteries prepend a
// combined aggregate. Stateless, derived from the battery object.
export default defineComponent({
name: "BatteryStatusCards",
components: { Card, BatteryStatusCard, OptimizerInfo },
props: {
battery: { type: Object as PropType<Battery> },
suggestion: { type: Object as PropType<BatterySuggestion | null>, default: null },
},
computed: {
devices(): BatteryMeter[] {
return this.battery?.devices ?? [];
},
batteryForecast(): BatteryForecast | null {
const fc = this.battery?.forecast;
return fc?.highest || fc?.lowest ? fc : null;
},
showSplit(): boolean {
return this.devices.length === 1 && !!this.batteryForecast;
},
cards(): BatteryStatusCardModel[] {
const multiple = this.devices.length > 1;
const list: BatteryStatusCardModel[] = this.devices.map((d, i) => ({
id: d.name || `battery-${i}`, // name is unique; title may repeat or be empty
title: d.title || d.name || `Battery ${i + 1}`,
soc: d.soc,
power: d.power,
capacity: d.capacity || 0,
color: batteryColor(i),
suggestion: null, // per-battery suggestion not wired yet
forecast: null, // aggregate forecast lives on the combined / dedicated card
}));
// combined uses the site aggregate soc/power, not per-device sums
if (multiple) {
list.unshift({
id: "combined",
title: this.$t("battery.combined"),
soc: this.battery?.soc ?? 0,
power: this.battery?.power ?? 0,
capacity: this.devices.reduce((s, d) => s + (d.capacity || 0), 0),
color: batteryColor(0),
suggestion: this.suggestion,
forecast: this.batteryForecast,
});
}
return list;
},
},
});
</script>
<style scoped>
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
}
.cards--split {
grid-template-columns: 1fr 1fr;
}
@media (max-width: 767.98px) {
.cards--split {
grid-template-columns: 1fr;
}
}
</style>

View file

@ -0,0 +1,34 @@
<template>
<CustomSelect
:id="id"
:options="options"
:selected="selected"
inline
@change="$emit('change', $event)"
>
<span class="text-decoration-underline fw-bold" :class="{ 'text-nowrap': nowrap }">
{{ label }}
</span>
</CustomSelect>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import type { SelectOption } from "@/types/evcc";
import CustomSelect from "../Helper/CustomSelect.vue";
// Inline soc picker: an underlined, clickable value that opens a CustomSelect. Used for the
// priority / buffer / buffer-start values embedded in the usage sentences.
export default defineComponent({
name: "InlineSocSelect",
components: { CustomSelect },
props: {
id: { type: String, required: true },
options: { type: Array as PropType<SelectOption<number>[]>, default: () => [] },
selected: { type: Number, default: 0 },
label: { type: String, default: "" },
nowrap: Boolean, // keep short values (e.g. "80 %") on one line; off for long phrases
},
emits: ["change"],
});
</script>

View file

@ -0,0 +1,67 @@
<template>
<div>
<div v-if="recommendation" class="d-flex justify-content-between gap-3 py-1">
<span class="d-flex align-items-center gap-2 fw-bold">
<Optimizer />
{{ $t("battery.optimizer.suggestion") }}
</span>
<span class="text-muted text-end">{{ recommendation }}</span>
</div>
<div v-if="forecast?.highest" class="d-flex justify-content-between gap-3 py-1">
<span class="d-flex align-items-center gap-2 fw-bold">
<BatteryIcon :soc="95" />
{{ pointLabel(forecast.highest, true) }}
</span>
<span class="text-muted text-end">{{
fmtDayTime(new Date(forecast.highest.time))
}}</span>
</div>
<div v-if="forecast?.lowest" class="d-flex justify-content-between gap-3 py-1">
<span class="d-flex align-items-center gap-2 fw-bold">
<BatteryIcon :soc="10" />
{{ pointLabel(forecast.lowest, false) }}
</span>
<span class="text-muted text-end">{{
fmtDayTime(new Date(forecast.lowest.time))
}}</span>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter";
import type { BatteryForecast, BatteryForecastPoint } from "@/types/evcc";
import BatteryIcon from "../Energyflow/BatteryIcon.vue";
import Optimizer from "../MaterialIcon/Optimizer.vue";
import type { BatterySuggestion } from "./types";
// Optimizer rows: suggestion (not yet wired) plus the site battery high/low
// soc forecast. Simple bold-label / muted-value layout.
export default defineComponent({
name: "OptimizerInfo",
components: { BatteryIcon, Optimizer },
mixins: [formatter],
props: {
suggestion: { type: Object as PropType<BatterySuggestion | null>, default: null },
forecast: { type: Object as PropType<BatteryForecast | null>, default: null },
},
computed: {
recommendation(): string | null {
const s = this.suggestion;
if (!s) return null;
return this.$t(`battery.optimizer.action.${s.action}`);
},
},
methods: {
pointLabel(point: BatteryForecastPoint, high: boolean): string {
const value = point.limit
? this.$t(high ? "battery.optimizer.full" : "battery.optimizer.empty")
: this.fmtPercentage(point.soc, 0);
return this.$t(high ? "battery.optimizer.highest" : "battery.optimizer.lowest", {
value,
});
},
},
});
</script>

View file

@ -0,0 +1,61 @@
<template>
<svg :width="size" :height="size" :viewBox="`0 0 ${size} ${size}`" class="flex-shrink-0">
<circle
:cx="c"
:cy="c"
:r="r"
fill="none"
stroke="var(--bs-border-color)"
:stroke-width="stroke"
/>
<circle
:cx="c"
:cy="c"
:r="r"
fill="none"
:stroke="color"
:stroke-width="stroke"
stroke-linecap="round"
:stroke-dasharray="circumference"
:stroke-dashoffset="offset"
:transform="`rotate(-90 ${c} ${c})`"
class="progress"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "SocGauge",
props: {
soc: { type: Number, default: 0 },
color: { type: String, default: "" },
size: { type: Number, default: 38 },
},
computed: {
stroke(): number {
return Math.max(4, Math.round(this.size * 0.12));
},
c(): number {
return this.size / 2;
},
r(): number {
return (this.size - this.stroke) / 2;
},
circumference(): number {
return 2 * Math.PI * this.r;
},
offset(): number {
return this.circumference * (1 - Math.max(0, Math.min(100, this.soc)) / 100);
},
},
});
</script>
<style scoped>
.progress {
transition: stroke-dashoffset var(--evcc-transition-medium) ease;
}
</style>

View file

@ -0,0 +1,35 @@
<template>
<div class="d-flex align-items-center gap-1">
<DateNavigatorButton
prev
:disabled="prevDisabled"
:on-click="() => $emit('prev')"
data-testid="battery-chart-prev"
/>
<span class="text-center text-truncate evcc-default-text" style="width: 11em">{{
label
}}</span>
<DateNavigatorButton
next
:disabled="nextDisabled"
:on-click="() => $emit('next')"
data-testid="battery-chart-next"
/>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import DateNavigatorButton from "../Sessions/DateNavigatorButton.vue";
export default defineComponent({
name: "WindowNav",
components: { DateNavigatorButton },
props: {
label: { type: String, default: "" },
prevDisabled: Boolean,
nextDisabled: Boolean,
},
emits: ["prev", "next"],
});
</script>

View file

@ -0,0 +1,101 @@
import { describe, it, expect } from "vitest";
import { historyToSeries, forecastToSeries, buildChartBatteries } from "./history";
import type { BatteryMeter, EvOpt } from "@/types/evcc";
import type { BatteryHistorySeries } from "./types";
const t = (iso: string) => new Date(iso).getTime();
describe("historyToSeries", () => {
it("keys by title, drops slots without socTemp, maps start to epoch", () => {
const raw: BatteryHistorySeries[] = [
{
title: "Anker",
group: "battery",
data: [
{ start: "2026-01-01T00:00:00Z", socTemp: 50 },
{ start: "2026-01-01T00:15:00Z", socTemp: null },
{ start: "2026-01-01T00:30:00Z" },
{ start: "2026-01-01T00:45:00Z", socTemp: 55 },
],
},
];
const res = historyToSeries(raw);
expect(res).toHaveLength(1);
expect(res[0]!.key).toBe("Anker");
expect(res[0]!.points).toEqual([
{ t: t("2026-01-01T00:00:00Z"), soc: 50 },
{ t: t("2026-01-01T00:45:00Z"), soc: 55 },
]);
});
it("falls back to group when title is missing", () => {
const res = historyToSeries([{ group: "battery", data: [] }]);
expect(res[0]!.key).toBe("battery");
});
});
describe("forecastToSeries", () => {
const evopt = {
res: {
batteries: [
{ state_of_charge: [4000, 5000, 6000] }, // Wh
{ state_of_charge: [1000, 2000, 3000] },
],
},
details: {
timestamp: ["2026-01-01T00:00:00Z", "2026-01-01T01:00:00Z", "2026-01-01T02:00:00Z"],
batteryDetails: [
{ type: "battery", name: "bat1", title: "Anker", capacity: 10 },
{ type: "vehicle", name: "car", title: "EV", capacity: 60 },
],
},
} as unknown as EvOpt;
it("keys by device name and converts Wh to percent of capacity", () => {
const res = forecastToSeries(evopt, t("2026-01-01T00:00:00Z"));
expect(res).toHaveLength(1); // vehicle skipped
expect(res[0]!.key).toBe("bat1");
// 10 kWh -> 5000 Wh = 50 %
expect(res[0]!.points).toEqual([
{ t: t("2026-01-01T00:00:00Z"), soc: 40 },
{ t: t("2026-01-01T01:00:00Z"), soc: 50 },
{ t: t("2026-01-01T02:00:00Z"), soc: 60 },
]);
});
it("drops points before now", () => {
const res = forecastToSeries(evopt, t("2026-01-01T01:00:00Z"));
expect(res[0]!.points.map((p) => p.soc)).toEqual([50, 60]);
});
it("returns empty for missing optimizer data", () => {
expect(forecastToSeries(undefined, 0)).toEqual([]);
});
});
describe("buildChartBatteries", () => {
const devices = [
{ name: "bat1", title: "Anker", soc: 60, capacity: 10, controllable: false },
] as BatteryMeter[];
it("matches history by title and forecast by device name", () => {
const history = [{ key: "Anker", points: [{ t: 1, soc: 42 }] }];
const forecast = [{ key: "bat1", points: [{ t: 2, soc: 80 }] }];
const res = buildChartBatteries(devices, history, forecast);
expect(res).toHaveLength(1);
expect(res[0]).toMatchObject({
id: "Anker",
title: "Anker",
capacity: 10,
currentSoc: 60,
history: [{ t: 1, soc: 42 }],
forecast: [{ t: 2, soc: 80 }],
});
});
it("yields empty timelines when nothing matches", () => {
const res = buildChartBatteries(devices, [], []);
expect(res[0]!.history).toEqual([]);
expect(res[0]!.forecast).toEqual([]);
});
});

View file

@ -0,0 +1,55 @@
// Pure transforms that turn the raw /history/energy response and the optimizer result into
// the per-battery soc timelines the chart consumes. Kept framework-free for unit testing.
import type { BatteryMeter, EvOpt } from "@/types/evcc";
import type { SocPoint, BatterySeries, BatteryHistorySeries } from "./types";
// A soc timeline tagged with the key used to match it back to a battery device.
export interface KeyedSeries {
key: string;
points: SocPoint[];
}
// History is grouped per title (no device name), so key it by title, falling back to the group.
export function historyToSeries(raw: BatteryHistorySeries[]): KeyedSeries[] {
return raw.map((s) => ({
key: s.title || s.group,
points: s.data
.filter((d) => d.socTemp != null)
.map((d) => ({ t: new Date(d.start).getTime(), soc: d.socTemp as number })),
}));
}
// Optimizer soc per battery, keyed by the device name; only points at/after now, converted
// from Wh to percent using the battery capacity (kWh).
export function forecastToSeries(evopt: EvOpt | undefined, nowMs: number): KeyedSeries[] {
if (!evopt?.res?.batteries || !evopt.details?.timestamp) return [];
const ts = evopt.details.timestamp.map((t) => new Date(t).getTime());
return evopt.res.batteries.flatMap((bat, i): KeyedSeries[] => {
const det = evopt.details.batteryDetails?.[i];
if (!det || det.type !== "battery" || !det.capacity) return [];
const points: SocPoint[] = (bat.state_of_charge || [])
.map((wh, j) => ({ t: ts[j]!, soc: (wh / (det.capacity * 1000)) * 100 }))
.filter((p) => p.t >= nowMs && !Number.isNaN(p.soc));
return [{ key: det.name, points }];
});
}
// Merge per-device meta with its matched history (by title) and forecast (by device name).
export function buildChartBatteries(
devices: BatteryMeter[],
history: KeyedSeries[],
forecast: KeyedSeries[]
): BatterySeries[] {
return devices.map((d) => {
const title = d.title || d.name || "";
return {
id: title,
title,
capacity: d.capacity || 0,
currentSoc: d.soc,
history: history.find((s) => s.key === title)?.points ?? [],
forecast: forecast.find((s) => s.key === d.name)?.points ?? [],
};
});
}

View file

@ -0,0 +1,42 @@
// Shared view-model types for the experimental battery page.
import type { BatteryForecast } from "@/types/evcc";
export interface SocPoint {
t: number; // epoch ms
soc: number; // 0..100
}
export interface BatterySeries {
id: string;
title: string;
capacity: number; // kWh
currentSoc: number; // 0..100
history: SocPoint[]; // ascending t, <= now
forecast: SocPoint[]; // ascending t, >= now (may be empty)
}
export interface BatterySuggestion {
action: "normal" | "hold" | "charge" | "holdcharge";
}
export interface BatteryStatusCardModel {
id: string;
title: string;
soc: number;
power: number;
capacity: number;
color: string;
suggestion: BatterySuggestion | null;
forecast: BatteryForecast | null;
}
export interface BatteryHistorySlot {
start: string;
socTemp?: number | null;
}
export interface BatteryHistorySeries {
title?: string;
group: string;
data: BatteryHistorySlot[];
}

View file

@ -126,7 +126,7 @@ export default defineComponent({
entries(): StationEntry[] { entries(): StationEntry[] {
const byId = new Map<string, StationEntry>(); const byId = new Map<string, StationEntry>();
const published = new Set<string>(); const published = new Set<string>();
for (const station of this.ocpp.status.stations) { for (const station of this.ocpp.status?.stations ?? []) {
byId.set(station.id, { byId.set(station.id, {
id: station.id, id: station.id,
status: station.status, status: station.status,

View file

@ -9,17 +9,13 @@ export default {
}, },
} as Meta<typeof BatteryIcon>; } as Meta<typeof BatteryIcon>;
const Template: StoryFn<typeof BatteryIcon> = (args) => { const Template: StoryFn<typeof BatteryIcon> = (args) => ({
const story = () => ({ components: { BatteryIcon },
components: { BatteryIcon }, setup() {
setup() { return { args };
return { args }; },
}, template: '<BatteryIcon v-bind="args" />',
template: '<BatteryIcon v-bind="args" />', });
});
story.args = args;
return story;
};
export const Empty = Template.bind({}); export const Empty = Template.bind({});
Empty.args = { soc: 0 }; Empty.args = { soc: 0 };

View file

@ -7,6 +7,9 @@ import {
GridComponent, GridComponent,
TooltipComponent, TooltipComponent,
MarkPointComponent, MarkPointComponent,
MarkLineComponent,
MarkAreaComponent,
GraphicComponent,
AxisPointerComponent, AxisPointerComponent,
} from "echarts/components"; } from "echarts/components";
import { SVGRenderer } from "echarts/renderers"; import { SVGRenderer } from "echarts/renderers";
@ -18,6 +21,9 @@ echarts.use([
TooltipComponent, TooltipComponent,
AxisPointerComponent, AxisPointerComponent,
MarkPointComponent, MarkPointComponent,
MarkLineComponent,
MarkAreaComponent,
GraphicComponent,
SVGRenderer, SVGRenderer,
]); ]);

View file

@ -0,0 +1,77 @@
<template>
<section class="evcc-card p-3 p-sm-4" :class="{ 'evcc-card--edge': edgeToEdge }">
<div
v-if="hasHeader"
class="evcc-card-header d-flex align-items-center gap-3 flex-wrap mb-3"
>
<h3
v-if="hasTitle || hasSubtitle"
class="evcc-card-title fw-normal m-0 d-flex gap-3 flex-wrap align-items-baseline overflow-hidden"
>
<span class="d-block no-wrap text-truncate">
<slot name="title">{{ title }}</slot>
</span>
<small v-if="hasSubtitle" class="d-block no-wrap text-truncate evcc-card-subtitle">
<slot name="subtitle">{{ subtitle }}</slot>
</small>
</h3>
<slot name="nav" />
<div v-if="$slots['actions']" class="ms-auto">
<slot name="actions" />
</div>
</div>
<slot />
</section>
</template>
<script lang="ts">
import { defineComponent } from "vue";
// Reusable content card: optional header (title + subtitle + nav/actions slots) over a
// rounded box. `edgeToEdge` makes it full-bleed on mobile (for charts that need width).
// Mirrors the History tile style so the look stays consistent across pages.
export default defineComponent({
name: "Card",
props: {
title: { type: String, default: "" },
subtitle: { type: String, default: "" },
edgeToEdge: Boolean,
},
computed: {
hasTitle(): boolean {
return !!this.title || !!this.$slots["title"];
},
hasSubtitle(): boolean {
return !!this.subtitle || !!this.$slots["subtitle"];
},
hasHeader(): boolean {
return (
this.hasTitle ||
this.hasSubtitle ||
!!this.$slots["nav"] ||
!!this.$slots["actions"]
);
},
},
});
</script>
<style scoped>
.evcc-card {
background: var(--evcc-box);
border: 1px solid var(--bs-border-color-translucent);
border-radius: 1rem;
}
.evcc-card-subtitle {
color: var(--evcc-gray);
}
@media (max-width: 575.98px) {
.evcc-card--edge {
margin-left: -1.5rem;
margin-right: -1.5rem;
border-left: none;
border-right: none;
border-radius: 0;
}
}
</style>

View file

@ -1,5 +1,10 @@
<template> <template>
<label class="root position-relative d-block" :for="id" role="button"> <label
class="root position-relative"
:class="inline ? 'd-inline-block align-baseline' : 'd-block'"
:for="id"
role="button"
>
<select :id="id" :value="selected" class="custom-select" tabindex="0" @change="change"> <select :id="id" :value="selected" class="custom-select" tabindex="0" @change="change">
<option <option
v-for="{ name, value, count, disabled } in options" v-for="{ name, value, count, disabled } in options"
@ -24,6 +29,7 @@ export default defineComponent({
options: { type: Array as PropType<SelectOption<number | string>[]> }, options: { type: Array as PropType<SelectOption<number | string>[]> },
selected: { type: [String, Number] }, selected: { type: [String, Number] },
id: { type: String }, id: { type: String },
inline: Boolean,
}, },
emits: ["change"], emits: ["change"],
methods: { methods: {

View file

@ -14,7 +14,7 @@ import {
tooltipStyle, tooltipStyle,
tooltipTable, tooltipTable,
} from "../Forecast/echarts"; } from "../Forecast/echarts";
import colors, { resolveColors, deviceColorMap, darken } from "@/colors"; import colors, { resolveColors, deviceColorMap, darken, batteryColor, setAlpha } from "@/colors";
import store from "@/store"; import store from "@/store";
import formatter, { POWER_UNIT } from "@/mixins/formatter"; import formatter, { POWER_UNIT } from "@/mixins/formatter";
import { PERIODS } from "../Sessions/types"; import { PERIODS } from "../Sessions/types";
@ -244,6 +244,9 @@ export default defineComponent({
return palette[s.title] || this.color; return palette[s.title] || this.color;
}); });
} }
if (this.group === "battery") {
return this.series.map((s, i) => batteryColor(s.paletteIndex ?? i));
}
if (this.series.length <= 1) return [this.color]; if (this.series.length <= 1) return [this.color];
return this.series.map((_, i) => darken(this.color, stepAlpha(i, this.series.length))); return this.series.map((_, i) => darken(this.color, stepAlpha(i, this.series.length)));
}, },
@ -327,7 +330,9 @@ export default defineComponent({
}; };
this.series.forEach((s, i) => { this.series.forEach((s, i) => {
const c = this.entryColors[i] || this.color; const c = this.entryColors[i] || this.color;
const returnEnergyColor = (s.group === "grid" && colors.export) || c; const returnEnergyColor =
(s.group === "grid" && colors.export) ||
(s.group === "battery" ? setAlpha(c, "cc") || c : c);
const energyValues = energyByEntity[i]!; const energyValues = energyByEntity[i]!;
const returnEnergyValues = returnEnergyByEntity[i]!; const returnEnergyValues = returnEnergyByEntity[i]!;
const energyName = const energyName =

View file

@ -2,7 +2,7 @@
<svg :style="svgStyle" viewBox="0 0 24 24"> <svg :style="svgStyle" viewBox="0 0 24 24">
<path <path
fill="currentColor" fill="currentColor"
d="M3.75 19.75q-.325-.325-.325-.75t.325-.75L9.8 12.2q.15-.15.325-.213t.375-.062q.2 0 .375.062t.325.213l3.3 3.3l6.4-7.2q.275-.325.713-.325t.737.3q.275.275.287.663t-.262.687L15.2 17.7q-.15.175-.337.263t-.388.087q-.2 0-.387-.075t-.338-.225L10.5 14.5l-5.25 5.25q-.325.325-.75.325t-.75-.325ZM4 13.3q-.125 0-.25-.063t-.2-.212l-.5-1.075l-1.075-.5q-.15-.075-.213-.2T1.7 11q0-.125.063-.25t.212-.2l1.075-.5l.5-1.075q.075-.15.2-.212T4 8.7q.125 0 .25.063t.2.212l.5 1.075l1.075.5q.275.125.275.45t-.275.45l-1.075.5l-.5 1.075q-.075.15-.2.212T4 13.3Zm11-2q-.125 0-.25-.063t-.2-.212l-.5-1.075l-1.075-.5q-.15-.075-.212-.2T12.7 9q0-.125.063-.25t.212-.2l1.075-.5l.5-1.075q.075-.15.2-.212T15 6.7q.125 0 .25.063t.2.212l.5 1.075l1.075.5q.15.075.213.2T17.3 9q0 .125-.063.25t-.212.2l-1.075.5l-.5 1.075q-.075.15-.2.213T15 11.3Zm-6.5-3q-.125 0-.25-.075T8.05 8L7.4 6.6L6 5.95q-.15-.075-.225-.2T5.7 5.5q0-.125.075-.25T6 5.05l1.4-.65l.65-1.4q.075-.15.2-.225T8.5 2.7q.125 0 .25.075t.2.225l.65 1.4l1.4.65q.15.075.225.2t.075.25q0 .125-.075.25t-.225.2l-1.4.65L8.95 8q-.075.15-.2.225T8.5 8.3Z" d="M10.45 15.5q.6.6 1.55.588t1.4-.688l4.225-6.325q.225-.35-.062-.638t-.638-.062L10.6 12.6q-.675.45-.712 1.375t.562 1.525M12 4q.9 0 1.775.15t1.7.475q.4.15.85.563t.25.787t-.9.5t-1.125-.025q-.625-.225-1.262-.337T12 6Q8.675 6 6.337 8.338T4 14q0 1.05.288 2.075T5.1 18h13.8q.575-.95.838-1.975T20 13.9q0-.65-.113-1.275T19.55 11.4q-.15-.425-.05-.825t.45-.675q.325-.25.713-.15t.537.45q.375.875.575 1.788T22 13.85q.025 1.425-.325 2.725T20.65 19.05q-.275.45-.75.7t-1 .25H5.1q-.525 0-1-.25t-.75-.7q-.65-1.125-1-2.387T2 14q0-2.075.788-3.887t2.15-3.175t3.187-2.15T12 4m.175 7.825"
/> />
</svg> </svg>
</template> </template>

View file

@ -241,6 +241,19 @@ export default defineComponent({
return `${weekday} ${hour}`.trim(); return `${weekday} ${hour}`.trim();
}, },
// relative day plus time, e.g. "heute 16:30", "morgen 5:00", "Freitag 12:15"
fmtDayTime(date: Date) {
const time = this.fmtHourMinute(date);
const startOfDay = (d: Date) => new Date(d).setHours(0, 0, 0, 0);
const days = Math.round((startOfDay(date) - startOfDay(new Date())) / 86400000);
if (days === 0 || days === 1) {
const day = new Intl.RelativeTimeFormat(this.$i18n?.locale, {
numeric: "auto",
}).format(days, "day");
return `${day} ${time}`;
}
return `${this.weekdayLong(date)} ${time}`;
},
fmtHourMinute(date: Date) { fmtHourMinute(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, { return new Intl.DateTimeFormat(this.$i18n?.locale, {
hour: "numeric", hour: "numeric",

View file

@ -19,6 +19,7 @@ const SAVINGS_REGION = "savings_region";
const SAVINGS_INDICATOR = "savings_indicator"; const SAVINGS_INDICATOR = "savings_indicator";
const SESSIONS_GROUP = "sessions_group"; const SESSIONS_GROUP = "sessions_group";
const SESSIONS_TYPE = "sessions_type"; const SESSIONS_TYPE = "sessions_type";
const BATTERY_UNIT = "battery_unit";
const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted"; const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted";
const SETTINGS_PRICE_ZOOM = "settings_price_zoom"; const SETTINGS_PRICE_ZOOM = "settings_price_zoom";
const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin"; const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin";
@ -124,6 +125,7 @@ export interface Settings {
savingsIndicator: string; savingsIndicator: string;
sessionsGroup: string; sessionsGroup: string;
sessionsType: string; sessionsType: string;
batteryUnit: string;
solarAdjusted: boolean; solarAdjusted: boolean;
priceZoom: boolean; priceZoom: boolean;
hideFeedin: boolean; hideFeedin: boolean;
@ -153,6 +155,7 @@ const settings: Settings = reactive({
savingsIndicator: read(SAVINGS_INDICATOR), savingsIndicator: read(SAVINGS_INDICATOR),
sessionsGroup: read(SESSIONS_GROUP), sessionsGroup: read(SESSIONS_GROUP),
sessionsType: read(SESSIONS_TYPE), sessionsType: read(SESSIONS_TYPE),
batteryUnit: read(BATTERY_UNIT),
solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED), solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED),
priceZoom: readBool(SETTINGS_PRICE_ZOOM), priceZoom: readBool(SETTINGS_PRICE_ZOOM),
hideFeedin: readBool(SETTINGS_HIDE_FEEDIN), hideFeedin: readBool(SETTINGS_HIDE_FEEDIN),
@ -181,6 +184,7 @@ watch(() => settings.savingsRegion, save(SAVINGS_REGION));
watch(() => settings.savingsIndicator, save(SAVINGS_INDICATOR)); watch(() => settings.savingsIndicator, save(SAVINGS_INDICATOR));
watch(() => settings.sessionsGroup, save(SESSIONS_GROUP)); watch(() => settings.sessionsGroup, save(SESSIONS_GROUP));
watch(() => settings.sessionsType, save(SESSIONS_TYPE)); watch(() => settings.sessionsType, save(SESSIONS_TYPE));
watch(() => settings.batteryUnit, save(BATTERY_UNIT));
watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED)); watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED));
watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM)); watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM));
watch(() => settings.hideFeedin, saveBool(SETTINGS_HIDE_FEEDIN)); watch(() => settings.hideFeedin, saveBool(SETTINGS_HIDE_FEEDIN));

View file

@ -118,6 +118,7 @@ export interface State {
batteryGridChargeLimit?: number | null; batteryGridChargeLimit?: number | null;
smartCostAvailable?: boolean; smartCostAvailable?: boolean;
smartCostType?: SMART_COST_TYPE; smartCostType?: SMART_COST_TYPE;
historyUpdated?: string; // ISO timestamp, bumped each 15min metrics persist
siteTitle?: string; siteTitle?: string;
deviceColors?: DeviceColorEntry[]; deviceColors?: DeviceColorEntry[];
vehicles: Record<string, Vehicle>; vehicles: Record<string, Vehicle>;

View file

@ -2,7 +2,7 @@ import type { Ocpp } from "@/types/evcc";
export function getOcppUrl(ocpp: Ocpp): string { export function getOcppUrl(ocpp: Ocpp): string {
// User specified url, e.g., for reverse proxy setups // User specified url, e.g., for reverse proxy setups
if (ocpp.status.externalUrl) { if (ocpp.status?.externalUrl) {
return ocpp.status.externalUrl; return ocpp.status.externalUrl;
} }
return `ws://${window.location.hostname}:${ocpp.config.port}/`; return `ws://${window.location.hostname}:${ocpp.config.port}/`;

View file

@ -3,11 +3,18 @@
<TopHeader :title="$t('batterySettings.modalTitle')" /> <TopHeader :title="$t('batterySettings.modalTitle')" />
<div class="row"> <div class="row">
<main class="col-12"> <main class="col-12">
<template v-if="batteryAvailable"> <BatteryExperimental v-if="experimental" />
<template v-else-if="batteryAvailable">
<h3 class="fw-normal my-4"> <h3 class="fw-normal my-4">
{{ $t("batterySettings.usageTab") }} {{ $t("batterySettings.usageTab") }}
</h3> </h3>
<BatteryUsageSettings style="max-width: 950px" v-bind="batteryUsageProps" /> <BatteryUsageSettings
:buffer-soc="state.bufferSoc"
:priority-soc="state.prioritySoc"
:buffer-start-soc="state.bufferStartSoc"
:battery-discharge-control="state.batteryDischargeControl"
:battery="state.battery"
/>
<template v-if="gridChargeVisible"> <template v-if="gridChargeVisible">
<hr class="my-5" /> <hr class="my-5" />
<h3 class="fw-normal my-4 mt-5"> <h3 class="fw-normal my-4 mt-5">
@ -28,10 +35,10 @@
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import Header from "../components/Top/Header.vue"; import Header from "../components/Top/Header.vue";
import BatteryUsageSettings from "../components/Battery/BatteryUsageSettings.vue"; import BatteryUsageSettings from "../components/Battery/BatteryUsageSettings.vue";
import BatteryExperimental from "../components/Battery/BatteryExperimental.vue";
import SmartCostLimit from "../components/Tariff/SmartCostLimit.vue"; import SmartCostLimit from "../components/Tariff/SmartCostLimit.vue";
import store from "../store"; import store from "../store";
import settings from "../settings"; import settings from "../settings";
import collector from "../mixins/collector";
import { SMART_COST_TYPE } from "../types/evcc"; import { SMART_COST_TYPE } from "../types/evcc";
export default defineComponent({ export default defineComponent({
@ -39,22 +46,22 @@ export default defineComponent({
components: { components: {
TopHeader: Header, TopHeader: Header,
BatteryUsageSettings, BatteryUsageSettings,
BatteryExperimental,
SmartCostLimit, SmartCostLimit,
}, },
mixins: [collector],
head() { head() {
return { title: this.$t("batterySettings.modalTitle") }; return { title: this.$t("batterySettings.modalTitle") };
}, },
computed: { computed: {
experimental(): boolean {
return !!store.state.experimental;
},
state() { state() {
return store.state; return store.state;
}, },
batteryAvailable() { batteryAvailable() {
return (this.state.battery?.devices?.length ?? 0) > 0; return (this.state.battery?.devices?.length ?? 0) > 0;
}, },
batteryUsageProps() {
return this.collectProps(BatteryUsageSettings, this.state);
},
gridChargePossible() { gridChargePossible() {
const devices = this.state.battery?.devices ?? []; const devices = this.state.battery?.devices ?? [];
return ( return (

View file

@ -47,22 +47,15 @@
</div> </div>
</div> </div>
<section <Card
v-for="group in visibleGroups" v-for="group in visibleGroups"
:key="group" :key="group"
class="history-tile mb-4" :title="$t(`main.history.group.${group}`)"
:subtitle="groupTotalLabel(group)"
edge-to-edge
class="mb-4"
:data-testid="`history-section-${group}`" :data-testid="`history-section-${group}`"
> >
<h3
class="fw-normal my-0 d-flex gap-3 flex-wrap align-items-baseline overflow-hidden history-tile-title"
>
<span class="d-block no-wrap text-truncate">
{{ $t(`main.history.group.${group}`) }}
</span>
<small v-if="groupTotalLabel(group)" class="d-block no-wrap text-truncate">
{{ groupTotalLabel(group) }}
</small>
</h3>
<GroupChart <GroupChart
v-if="displayFrom && displayTo && displayPeriod" v-if="displayFrom && displayTo && displayPeriod"
:group="group" :group="group"
@ -93,7 +86,7 @@
:device-colors="deviceColors" :device-colors="deviceColors"
@focus="onLegendFocus(group, $event)" @focus="onLegendFocus(group, $event)"
/> />
</section> </Card>
<p v-if="visibleGroups.length" class="text-end mt-3 mb-0"> <p v-if="visibleGroups.length" class="text-end mt-3 mb-0">
<a <a
:href="csvLink" :href="csvLink"
@ -113,6 +106,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import Header from "../components/Top/Header.vue"; import Header from "../components/Top/Header.vue";
import Card from "../components/Helper/Card.vue";
import PeriodSelector from "../components/Sessions/PeriodSelector.vue"; import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
import DateNavigator from "../components/Sessions/DateNavigator.vue"; import DateNavigator from "../components/Sessions/DateNavigator.vue";
import PeriodHeader from "../components/Sessions/PeriodHeader.vue"; import PeriodHeader from "../components/Sessions/PeriodHeader.vue";
@ -121,7 +115,7 @@ import type { Legend } from "../components/Sessions/types";
import type { DeviceColors } from "@/types/evcc"; import type { DeviceColors } from "@/types/evcc";
import { PERIODS } from "../components/Sessions/types"; import { PERIODS } from "../components/Sessions/types";
import { GROUP_ORDER, groupColor, hasColorPicker } from "../components/History/groups"; import { GROUP_ORDER, groupColor, hasColorPicker } from "../components/History/groups";
import colors, { resolveColors, deviceColorMap, darken } from "../colors"; import colors, { resolveColors, deviceColorMap, darken, batteryColor } from "../colors";
import LegendList from "../components/Sessions/LegendList.vue"; import LegendList from "../components/Sessions/LegendList.vue";
import { handleDownloadClick } from "@/utils/native"; import { handleDownloadClick } from "@/utils/native";
import formatter, { POWER_UNIT } from "../mixins/formatter"; import formatter, { POWER_UNIT } from "../mixins/formatter";
@ -138,6 +132,7 @@ export default defineComponent({
name: "History", name: "History",
components: { components: {
TopHeader: Header, TopHeader: Header,
Card,
PeriodSelector, PeriodSelector,
DateNavigator, DateNavigator,
PeriodHeader, PeriodHeader,
@ -159,7 +154,6 @@ export default defineComponent({
displayTo: null as Date | null, displayTo: null as Date | null,
displayPeriod: null as PERIODS | null, displayPeriod: null as PERIODS | null,
loading: true, loading: true,
interval: null as ReturnType<typeof setInterval> | null,
startDate: new Date(2020, 0, 1), startDate: new Date(2020, 0, 1),
focusedEntity: {} as Record<string, number | null>, focusedEntity: {} as Record<string, number | null>,
}; };
@ -171,6 +165,9 @@ export default defineComponent({
deviceColors(): DeviceColors { deviceColors(): DeviceColors {
return deviceColorMap(store.state.deviceColors); return deviceColorMap(store.state.deviceColors);
}, },
historyUpdated(): string | undefined {
return store.state.historyUpdated;
},
effectivePeriod(): PERIODS { effectivePeriod(): PERIODS {
return this.period && HISTORY_PERIODS.includes(this.period) return this.period && HISTORY_PERIODS.includes(this.period)
? (this.period as PERIODS) ? (this.period as PERIODS)
@ -337,6 +334,9 @@ export default defineComponent({
offline(offline) { offline(offline) {
if (!offline) this.fetchData(); if (!offline) this.fetchData();
}, },
historyUpdated() {
this.fetchData();
},
rawSeries() { rawSeries() {
// Drop focused entries whose paletteIndex no longer matches any entity // Drop focused entries whose paletteIndex no longer matches any entity
// in the new data (e.g. a loadpoint was filtered out after a period // in the new data (e.g. a loadpoint was filtered out after a period
@ -356,10 +356,6 @@ export default defineComponent({
}, },
mounted() { mounted() {
this.fetchData(); this.fetchData();
this.interval = setInterval(() => this.fetchData(), 15 * 60 * 1e3);
},
unmounted() {
if (this.interval) clearInterval(this.interval);
}, },
methods: { methods: {
groupColor, groupColor,
@ -418,6 +414,7 @@ export default defineComponent({
const colorFor = (i: number, s: HistorySeries) => { const colorFor = (i: number, s: HistorySeries) => {
if (s.virtual) return colors.muted || baseColor; if (s.virtual) return colors.muted || baseColor;
if (colorPicker) return palette[s.title] || baseColor; if (colorPicker) return palette[s.title] || baseColor;
if (group === "battery") return batteryColor(s.paletteIndex ?? i);
return darken(baseColor, stepAlpha(i, Math.max(n, 1))); return darken(baseColor, stepAlpha(i, Math.max(n, 1)));
}; };
return list.map((s, i) => { return list.map((s, i) => {
@ -543,6 +540,7 @@ export default defineComponent({
<style scoped> <style scoped>
.history-tile { .history-tile {
background: var(--evcc-box); background: var(--evcc-box);
border: 1px solid var(--bs-border-color-translucent);
padding: 1.25rem 1rem 1.75rem; padding: 1.25rem 1rem 1.75rem;
} }
.empty-container { .empty-container {
@ -581,9 +579,6 @@ export default defineComponent({
color: var(--evcc-default-text); color: var(--evcc-default-text);
text-decoration: underline; text-decoration: underline;
} }
.history-tile-title {
margin: 0 0 0.5rem;
}
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
/* edge-to-edge on mobile: cancel the container's px-4 padding */ /* edge-to-edge on mobile: cancel the container's px-4 padding */
.history-tile { .history-tile {

View file

@ -11,6 +11,7 @@ const (
GreenShareLoadpoints = "greenShareLoadpoints" GreenShareLoadpoints = "greenShareLoadpoints"
GridConfigured = "gridConfigured" GridConfigured = "gridConfigured"
Grid = "grid" Grid = "grid"
HistoryUpdated = "historyUpdated"
HomePower = "homePower" HomePower = "homePower"
PrioritySoc = "prioritySoc" PrioritySoc = "prioritySoc"
Pv = "pv" Pv = "pv"

View file

@ -125,13 +125,23 @@ func SetupSchema() error {
return db.Instance.AutoMigrate(new(meter)) return db.Instance.AutoMigrate(new(meter))
} }
// OnPersist, if set, is called with the slot start after a slot is written.
var OnPersist func(slot time.Time)
// persist stores a completed 15min slot // persist stores a completed 15min slot
func persist(entity entity, ts time.Time, energy, returnEnergy float64, socTemp *float64) error { func persist(entity entity, ts time.Time, energy, returnEnergy float64, socTemp *float64) error {
return db.Instance.Create(&meter{ slot := ts.Truncate(tariff.SlotDuration)
if err := db.Instance.Create(&meter{
Meter: entity.Id, Meter: entity.Id,
Timestamp: ts.Truncate(tariff.SlotDuration).Unix(), Timestamp: slot.Unix(),
Energy: energy, Energy: energy,
ReturnEnergy: returnEnergy, ReturnEnergy: returnEnergy,
SocTemp: socTemp, SocTemp: socTemp,
}).Error }).Error; err != nil {
return err
}
if OnPersist != nil {
OnPersist(slot)
}
return nil
} }

View file

@ -58,8 +58,15 @@ var aggregateDurations = map[string]func(time.Time) time.Time{
"month": func(t time.Time) time.Time { return t.AddDate(0, 1, 0) }, "month": func(t time.Time) time.Time { return t.AddDate(0, 1, 0) },
} }
// EnergyFilter narrows QueryEnergy to matching entities. Empty fields are ignored.
type EnergyFilter struct {
Group string
Name string
Title string
}
// QueryEnergy returns aggregated energy data, per title or per group. // QueryEnergy returns aggregated energy data, per title or per group.
func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, error) { func QueryEnergy(from, to time.Time, aggregate string, grouped bool, filter ...EnergyFilter) ([]Series, error) {
addDuration := aggregateDurations[aggregate] addDuration := aggregateDurations[aggregate]
format, ok := aggregateFormats[aggregate] format, ok := aggregateFormats[aggregate]
@ -109,6 +116,19 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series,
tx = tx.Where("m.ts < ?", to.Unix()) tx = tx.Where("m.ts < ?", to.Unix())
} }
if len(filter) > 0 {
f := filter[0]
if f.Group != "" {
tx = tx.Where(`e."group" = ?`, f.Group)
}
if f.Name != "" {
tx = tx.Where("e.name = ?", f.Name)
}
if f.Title != "" {
tx = tx.Where("e.title = ?", f.Title)
}
}
var rows []row var rows []row
if err := tx.Scan(&rows).Error; err != nil { if err := tx.Scan(&rows).Error; err != nil {
return nil, err return nil, err

View file

@ -172,6 +172,54 @@ func TestQueryEnergyMultipleSeries(t *testing.T) {
require.InDelta(t, 11+21, byGroup[PV].Data[1].ReturnEnergy, 0.001) require.InDelta(t, 11+21, byGroup[PV].Data[1].ReturnEnergy, 0.001)
} }
func TestQueryEnergyFilter(t *testing.T) {
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
require.NoError(t, SetupSchema())
eGrid := entity{Id: 2, Name: Grid, Group: Grid}
require.NoError(t, db.Instance.Create(&eGrid).Error)
ePv := entity{Id: 4, Name: "pv1", Group: PV}
require.NoError(t, db.Instance.Create(&ePv).Error)
eBat := entity{Id: 6, Name: "db:8", Group: Battery, Title: "Anker"}
require.NoError(t, db.Instance.Create(&eBat).Error)
loc := time.Now().Location()
base := time.Date(2026, 4, 15, 16, 0, 0, 0, loc)
for i := range 2 {
ts := base.Add(time.Duration(i) * time.Hour)
require.NoError(t, persist(eGrid, ts, float64(1+i), 0, nil))
require.NoError(t, persist(ePv, ts, 0, float64(10+i), nil))
require.NoError(t, persist(eBat, ts, float64(5+i), 0, nil))
}
from := base.Add(-time.Hour).UTC()
to := base.Add(3 * time.Hour).UTC()
// group filter
res, err := QueryEnergy(from, to, "hour", false, EnergyFilter{Group: Battery})
require.NoError(t, err)
require.Len(t, res, 1)
require.Equal(t, Battery, res[0].Group)
require.Equal(t, "Anker", res[0].Title)
// name filter
res, err = QueryEnergy(from, to, "hour", false, EnergyFilter{Name: "pv1"})
require.NoError(t, err)
require.Len(t, res, 1)
require.Equal(t, PV, res[0].Group)
// title filter
res, err = QueryEnergy(from, to, "hour", false, EnergyFilter{Title: "Anker"})
require.NoError(t, err)
require.Len(t, res, 1)
require.Equal(t, Battery, res[0].Group)
// no filter fields set: all series
res, err = QueryEnergy(from, to, "hour", false, EnergyFilter{})
require.NoError(t, err)
require.Len(t, res, 3)
}
func TestUpdateProfile(t *testing.T) { func TestUpdateProfile(t *testing.T) {
clock := clock.NewMock() clock := clock.NewMock()

View file

@ -148,6 +148,9 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
} }
site.collectors[metrics.Home] = me site.collectors[metrics.Home] = me
// reload history in the UI on each persisted 15min slot instead of polling
metrics.OnPersist = func(slot time.Time) { site.publish(keys.HistoryUpdated, slot) }
// upload telemetry on shutdown // upload telemetry on shutdown
if telemetry.Enabled() { if telemetry.Enabled() {
shutdown.Register(func() { shutdown.Register(func() {

View file

@ -19,6 +19,45 @@
"successCloseTab": "Du kannst diesen Tab jetzt schließen.", "successCloseTab": "Du kannst diesen Tab jetzt schließen.",
"title": "Autorisierungsstatus" "title": "Autorisierungsstatus"
}, },
"battery": {
"card": {
"charging": "Lädt",
"discharging": "Entlädt",
"energy": "Energie",
"ofTotal": "von {total}",
"power": "Leistung",
"soc": "Ladestand"
},
"combined": "Gesamt",
"config": {
"buffer": "Hausspeicherenergie oberhalb von {soc} darf für die Unterstützung der Ladepunkte genutzt werden und zögert die Abschaltung im PV-Modus hinaus. PV-Ladung automatisch starten {start}.",
"bufferNone": "Hausspeicherenergie wird nicht für die Ladepunkte genutzt. Setze einen Wert unter {soc}, um die Unterstützung zu aktivieren.",
"bufferStart": {
"above": "ab einem Speicherstand von {soc}",
"full": "erst, wenn der Speicher voll ist",
"never": "nur mit genug Überschuss"
},
"bufferTitle": "Batterie als Ladepuffer",
"discharge": "Entladung der Hausbatterie im Schnell-Modus und bei geplantem Laden verhindern.",
"priority": "Hausspeicher hat Vorrang, solange er unter {soc} liegt. Dann erhalten die Ladepunkte den Überschuss.",
"priorityNone": "Sonnenüberschuss geht direkt an die Ladepunkte. Setze einen Wert über {soc}, um den Hausspeicher zu bevorzugen.",
"priorityTitle": "Wohin geht der Überschuss zuerst?"
},
"optimizer": {
"action": {
"charge": "Netzladen",
"hold": "Entladung gesperrt",
"holdcharge": "Laden pausiert",
"normal": "PV-Überschuss folgen"
},
"empty": "leer",
"full": "voll",
"highest": "Höchststand ({value})",
"lowest": "Tiefststand ({value})",
"suggestion": "Vorschlag",
"title": "Optimizer"
}
},
"batterySettings": { "batterySettings": {
"batteryLevel": "Ladestand der Batterie", "batteryLevel": "Ladestand der Batterie",
"bufferStart": { "bufferStart": {

View file

@ -19,6 +19,45 @@
"successCloseTab": "You can now close this tab.", "successCloseTab": "You can now close this tab.",
"title": "Authorization Status" "title": "Authorization Status"
}, },
"battery": {
"card": {
"charging": "Charging",
"discharging": "Discharging",
"energy": "Energy",
"ofTotal": "of {total}",
"power": "Power",
"soc": "Battery level"
},
"combined": "Combined",
"config": {
"buffer": "Home battery energy above {soc} may be used to support the charging points and delays switching off in solar mode. Start solar charging automatically {start}.",
"bufferNone": "Home battery energy is not used for the charging points. Set a value below {soc} to enable support.",
"bufferStart": {
"above": "once the battery is above {soc}",
"full": "once the battery is full",
"never": "only with enough surplus"
},
"bufferTitle": "Battery as charging buffer",
"discharge": "Prevent home battery discharge in fast mode and during planned charging.",
"priority": "Home battery has priority while it is below {soc}. Then the charging points receive the surplus.",
"priorityNone": "Solar surplus goes straight to the charging points. Set a value above {soc} to prioritize the home battery.",
"priorityTitle": "Where does the surplus go first?"
},
"optimizer": {
"action": {
"charge": "Grid charging",
"hold": "Discharge locked",
"holdcharge": "Charging paused",
"normal": "Following solar surplus"
},
"empty": "empty",
"full": "full",
"highest": "Highest ({value})",
"lowest": "Lowest ({value})",
"suggestion": "Suggestion",
"title": "Optimizer"
}
},
"batterySettings": { "batterySettings": {
"batteryLevel": "Battery level", "batteryLevel": "Battery level",
"bufferStart": { "bufferStart": {

View file

@ -46,7 +46,13 @@ func energyHistoryHandler(w http.ResponseWriter, r *http.Request) {
grouped := q.Get("grouped") == "true" grouped := q.Get("grouped") == "true"
res, err := metrics.QueryEnergy(from, to, aggregate, grouped) filter := metrics.EnergyFilter{
Group: q.Get("group"),
Name: q.Get("name"),
Title: q.Get("title"),
}
res, err := metrics.QueryEnergy(from, to, aggregate, grouped, filter)
if err != nil { if err != nil {
jsonError(w, http.StatusInternalServerError, err) jsonError(w, http.StatusInternalServerError, err)
return return

View file

@ -1351,6 +1351,7 @@
"in": "query", "in": "query",
"name": "from", "name": "from",
"schema": { "schema": {
"example": "2026-07-01T00:00:00Z",
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
} }
@ -1360,6 +1361,7 @@
"in": "query", "in": "query",
"name": "to", "name": "to",
"schema": { "schema": {
"example": "2026-07-02T00:00:00Z",
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
} }
@ -1370,6 +1372,7 @@
"name": "aggregate", "name": "aggregate",
"schema": { "schema": {
"default": "15m", "default": "15m",
"example": "1h",
"type": "string" "type": "string"
} }
}, },
@ -1379,9 +1382,47 @@
"name": "grouped", "name": "grouped",
"schema": { "schema": {
"default": false, "default": false,
"example": false,
"type": "boolean" "type": "boolean"
} }
}, },
{
"description": "Filter by entity group",
"in": "query",
"name": "group",
"schema": {
"enum": [
"forecast",
"battery",
"grid",
"pv",
"home",
"loadpoint",
"meter",
"consumer"
],
"example": "battery",
"type": "string"
}
},
{
"description": "Filter by entity name",
"in": "query",
"name": "name",
"schema": {
"example": "db:8",
"type": "string"
}
},
{
"description": "Filter by entity title",
"in": "query",
"name": "title",
"schema": {
"example": "Battery",
"type": "string"
}
},
{ {
"description": "Response format", "description": "Response format",
"in": "query", "in": "query",
@ -1392,6 +1433,7 @@
"json", "json",
"csv" "csv"
], ],
"example": "json",
"type": "string" "type": "string"
} }
}, },
@ -1400,6 +1442,7 @@
"in": "query", "in": "query",
"name": "lang", "name": "lang",
"schema": { "schema": {
"example": "de",
"type": "string" "type": "string"
} }
} }

View file

@ -321,8 +321,11 @@ Returns aggregated energy history data. Aggregate granularity defaults to 15 min
| aggregate | string | Aggregation interval. Examples: 15m, 1h, day, month | | aggregate | string | Aggregation interval. Examples: 15m, 1h, day, month |
| format | string | Response format | | format | string | Response format |
| from | string | Start time (RFC3339) | | from | string | Start time (RFC3339) |
| group | string | Filter by entity group |
| grouped | boolean | Group results by loadpoint | | grouped | boolean | Group results by loadpoint |
| lang | string | Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header. | | lang | string | Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header. |
| name | string | Filter by entity name |
| title | string | Filter by entity title |
| to | string | End time (RFC3339) | | to | string | End time (RFC3339) |
**Example call:** **Example call:**
@ -332,8 +335,11 @@ call getEnergyHistory {
"aggregate": "example", "aggregate": "example",
"format": "example", "format": "example",
"from": "example", "from": "example",
"group": "example",
"grouped": true, "grouped": true,
"lang": "example", "lang": "example",
"name": "example",
"title": "example",
"to": "example" "to": "example"
} }
``` ```

View file

@ -1319,24 +1319,55 @@ paths:
schema: schema:
type: string type: string
format: date-time format: date-time
example: "2026-07-01T00:00:00Z"
- name: to - name: to
in: query in: query
description: End time (RFC3339) description: End time (RFC3339)
schema: schema:
type: string type: string
format: date-time format: date-time
example: "2026-07-02T00:00:00Z"
- name: aggregate - name: aggregate
in: query in: query
description: "Aggregation interval. Examples: 15m, 1h, day, month" description: "Aggregation interval. Examples: 15m, 1h, day, month"
schema: schema:
type: string type: string
default: 15m default: 15m
example: 1h
- name: grouped - name: grouped
in: query in: query
description: Group results by loadpoint description: Group results by loadpoint
schema: schema:
type: boolean type: boolean
default: false default: false
example: false
- name: group
in: query
description: Filter by entity group
schema:
type: string
example: battery
enum:
- forecast
- battery
- grid
- pv
- home
- loadpoint
- meter
- consumer
- name: name
in: query
description: Filter by entity name
schema:
type: string
example: db:8
- name: title
in: query
description: Filter by entity title
schema:
type: string
example: Battery
- name: format - name: format
in: query in: query
description: Response format description: Response format
@ -1346,11 +1377,13 @@ paths:
- json - json
- csv - csv
default: json default: json
example: json
- name: lang - name: lang
in: query in: query
description: Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header. description: Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header.
schema: schema:
type: string type: string
example: de
responses: responses:
"200": "200":
description: Energy history data description: Energy history data

View file

@ -0,0 +1,58 @@
interval: 0.1s
site:
title: Battery Experimental
meters:
grid: grid
pv: pv
battery:
- battery1
- battery2
meters:
- name: grid
type: custom
power:
source: const
value: 1000
- name: pv
type: custom
power:
source: const
value: 2000
- name: battery1
type: custom
power:
source: const
value: -800
soc:
source: const
value: 76
capacity: 13.5
batterymode:
source: js
vm: shared
script: |
1
- name: battery2
type: custom
power:
source: const
value: 1200
soc:
source: const
value: 40
capacity: 7.5
tariffs:
currency: EUR
grid:
type: fixed
price: 0.2
zones:
- hours: 5-7
price: 0.45
- hours: 11-14
price: 0.05
- hours: 17-21
price: 0.5

View file

@ -0,0 +1,82 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
const CONFIG = "battery-experimental.evcc.yaml";
const SQL = "battery-experimental.sql";
test.use({ baseURL: baseUrl() });
test.describe.configure({ mode: "parallel" });
test.beforeEach(async () => {
await start(CONFIG, SQL);
});
test.afterEach(async () => {
await stop();
});
test.describe("experimental battery page", async () => {
test("status cards: combined aggregate plus one per battery", async ({ page }) => {
await page.goto("/#/battery");
await expect(page.getByTestId("battery-experimental")).toBeVisible();
const cards = page.getByTestId("battery-status-card");
await expect(cards).toHaveCount(3); // combined + battery1 + battery2
// combined card first, showing the capacity-weighted site soc (76%@13.5 + 40%@7.5 -> 63%)
await expect(cards.first()).toContainText("Combined");
await expect(cards.first()).toContainText("63%");
// per-battery: soc, charge/discharge state and energy of total
const charging = cards.filter({ hasText: "76%" });
await expect(charging).toContainText("Discharging"); // battery1 power -800 W
await expect(charging).toContainText("13.5 kWh"); // of total capacity
const discharging = cards.filter({ hasText: "40%" });
await expect(discharging).toContainText("Charging"); // battery2 power 1200 W
});
test("history chart: unit toggle persists and window pages", async ({ page }) => {
await page.goto("/#/battery");
const energy = page.getByTestId("batteryUnit-energy");
await expect(energy).toBeEnabled(); // both batteries have capacity
await energy.click();
await expect(energy).toHaveAttribute("aria-checked", "true");
// unit choice persists across a reload
await page.reload();
await expect(page.getByTestId("batteryUnit-energy")).toHaveAttribute("aria-checked", "true");
// paging: cannot go into the future at offset 0, prev enables next
const prev = page.getByTestId("battery-chart-prev");
const next = page.getByTestId("battery-chart-next");
await expect(next).toBeDisabled();
await expect(prev).toBeEnabled();
await prev.click();
await expect(next).toBeEnabled();
});
test("usage configuration reflects and updates the stored thresholds", async ({ page }) => {
await page.goto("/#/battery");
// section headings render
await expect(page.getByText("Where does the surplus go first?")).toBeVisible();
await expect(page.getByText("Battery as charging buffer")).toBeVisible();
// stored thresholds shown in the inline pickers (prioritySoc 50, bufferSoc 80)
const prioritySoc = page.getByTestId("battery-priority").getByRole("combobox");
const bufferSoc = page.getByTestId("battery-buffer").getByRole("combobox").first();
await expect(prioritySoc).toHaveValue("50");
await expect(bufferSoc).toHaveValue("80");
// changing priority updates the picker value
await prioritySoc.selectOption("30");
await expect(prioritySoc).toHaveValue("30");
// discharge control is offered for the controllable battery and toggles on
const discharge = page.getByRole("switch", { name: /Prevent home battery/ });
await expect(discharge).not.toBeChecked();
await discharge.click();
await expect(discharge).toBeChecked();
});
});

View file

@ -0,0 +1,13 @@
CREATE TABLE `settings` (
`key` text
, `value` text
, PRIMARY KEY(`key`)
);
-- enable the experimental battery page
INSERT INTO `settings`(key, value) VALUES ('experimental', 'true');
-- realistic battery usage thresholds
INSERT INTO `settings`(key, value) VALUES ('prioritySoc', '50');
INSERT INTO `settings`(key, value) VALUES ('bufferSoc', '80');
INSERT INTO `settings`(key, value) VALUES ('bufferStartSoc', '95');