Battery UI: soc history and future, new layout (#31383)
This commit is contained in:
parent
3482e79f14
commit
d0a4617144
44 changed files with 2509 additions and 52 deletions
|
|
@ -29,6 +29,7 @@ const colors: {
|
|||
light: string | null;
|
||||
selfPalette: string[];
|
||||
palette: string[];
|
||||
batteryPalette: string[];
|
||||
} = reactive({
|
||||
text: null,
|
||||
muted: null,
|
||||
|
|
@ -69,10 +70,11 @@ const colors: {
|
|||
"#6D28D9", // violet
|
||||
"#334155", // gray
|
||||
],
|
||||
batteryPalette: ["#0BA631", "#7FC41B", "#0A6E63", "#34D399", "#0E9E8F"],
|
||||
});
|
||||
|
||||
// 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;
|
||||
const c = color.trim().toLowerCase();
|
||||
// #rrggbb → append alpha, #rrggbbaa → replace alpha
|
||||
|
|
@ -113,6 +115,12 @@ export function resolveColors(ids: string[], overrides: DeviceColors = {}): Devi
|
|||
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 lighterColor = (color: string | null) => setAlpha(color, "aa");
|
||||
|
|
|
|||
242
assets/js/components/Battery/BatteryConfigCard.vue
Normal file
242
assets/js/components/Battery/BatteryConfigCard.vue
Normal 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>
|
||||
173
assets/js/components/Battery/BatteryExperimental.vue
Normal file
173
assets/js/components/Battery/BatteryExperimental.vue
Normal 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>
|
||||
108
assets/js/components/Battery/BatteryHistoryCard.stories.ts
Normal file
108
assets/js/components/Battery/BatteryHistoryCard.stories.ts
Normal 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);
|
||||
165
assets/js/components/Battery/BatteryHistoryCard.vue
Normal file
165
assets/js/components/Battery/BatteryHistoryCard.vue
Normal 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>
|
||||
495
assets/js/components/Battery/BatteryHistoryChart.vue
Normal file
495
assets/js/components/Battery/BatteryHistoryChart.vue
Normal 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>
|
||||
88
assets/js/components/Battery/BatteryStatusCard.stories.ts
Normal file
88
assets/js/components/Battery/BatteryStatusCard.stories.ts
Normal 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 } },
|
||||
};
|
||||
104
assets/js/components/Battery/BatteryStatusCard.vue
Normal file
104
assets/js/components/Battery/BatteryStatusCard.vue
Normal 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>
|
||||
82
assets/js/components/Battery/BatteryStatusCards.stories.ts
Normal file
82
assets/js/components/Battery/BatteryStatusCards.stories.ts
Normal 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 })]),
|
||||
};
|
||||
86
assets/js/components/Battery/BatteryStatusCards.vue
Normal file
86
assets/js/components/Battery/BatteryStatusCards.vue
Normal 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>
|
||||
34
assets/js/components/Battery/InlineSocSelect.vue
Normal file
34
assets/js/components/Battery/InlineSocSelect.vue
Normal 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>
|
||||
67
assets/js/components/Battery/OptimizerInfo.vue
Normal file
67
assets/js/components/Battery/OptimizerInfo.vue
Normal 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>
|
||||
61
assets/js/components/Battery/SocGauge.vue
Normal file
61
assets/js/components/Battery/SocGauge.vue
Normal 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>
|
||||
35
assets/js/components/Battery/WindowNav.vue
Normal file
35
assets/js/components/Battery/WindowNav.vue
Normal 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>
|
||||
101
assets/js/components/Battery/history.test.ts
Normal file
101
assets/js/components/Battery/history.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
55
assets/js/components/Battery/history.ts
Normal file
55
assets/js/components/Battery/history.ts
Normal 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 ?? [],
|
||||
};
|
||||
});
|
||||
}
|
||||
42
assets/js/components/Battery/types.ts
Normal file
42
assets/js/components/Battery/types.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -126,7 +126,7 @@ export default defineComponent({
|
|||
entries(): StationEntry[] {
|
||||
const byId = new Map<string, StationEntry>();
|
||||
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, {
|
||||
id: station.id,
|
||||
status: station.status,
|
||||
|
|
|
|||
|
|
@ -9,17 +9,13 @@ export default {
|
|||
},
|
||||
} as Meta<typeof BatteryIcon>;
|
||||
|
||||
const Template: StoryFn<typeof BatteryIcon> = (args) => {
|
||||
const story = () => ({
|
||||
components: { BatteryIcon },
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
template: '<BatteryIcon v-bind="args" />',
|
||||
});
|
||||
story.args = args;
|
||||
return story;
|
||||
};
|
||||
const Template: StoryFn<typeof BatteryIcon> = (args) => ({
|
||||
components: { BatteryIcon },
|
||||
setup() {
|
||||
return { args };
|
||||
},
|
||||
template: '<BatteryIcon v-bind="args" />',
|
||||
});
|
||||
|
||||
export const Empty = Template.bind({});
|
||||
Empty.args = { soc: 0 };
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import {
|
|||
GridComponent,
|
||||
TooltipComponent,
|
||||
MarkPointComponent,
|
||||
MarkLineComponent,
|
||||
MarkAreaComponent,
|
||||
GraphicComponent,
|
||||
AxisPointerComponent,
|
||||
} from "echarts/components";
|
||||
import { SVGRenderer } from "echarts/renderers";
|
||||
|
|
@ -18,6 +21,9 @@ echarts.use([
|
|||
TooltipComponent,
|
||||
AxisPointerComponent,
|
||||
MarkPointComponent,
|
||||
MarkLineComponent,
|
||||
MarkAreaComponent,
|
||||
GraphicComponent,
|
||||
SVGRenderer,
|
||||
]);
|
||||
|
||||
|
|
|
|||
77
assets/js/components/Helper/Card.vue
Normal file
77
assets/js/components/Helper/Card.vue
Normal 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>
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
<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">
|
||||
<option
|
||||
v-for="{ name, value, count, disabled } in options"
|
||||
|
|
@ -24,6 +29,7 @@ export default defineComponent({
|
|||
options: { type: Array as PropType<SelectOption<number | string>[]> },
|
||||
selected: { type: [String, Number] },
|
||||
id: { type: String },
|
||||
inline: Boolean,
|
||||
},
|
||||
emits: ["change"],
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
tooltipStyle,
|
||||
tooltipTable,
|
||||
} from "../Forecast/echarts";
|
||||
import colors, { resolveColors, deviceColorMap, darken } from "@/colors";
|
||||
import colors, { resolveColors, deviceColorMap, darken, batteryColor, setAlpha } from "@/colors";
|
||||
import store from "@/store";
|
||||
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||
import { PERIODS } from "../Sessions/types";
|
||||
|
|
@ -244,6 +244,9 @@ export default defineComponent({
|
|||
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];
|
||||
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) => {
|
||||
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 returnEnergyValues = returnEnergyByEntity[i]!;
|
||||
const energyName =
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<svg :style="svgStyle" viewBox="0 0 24 24">
|
||||
<path
|
||||
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>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -241,6 +241,19 @@ export default defineComponent({
|
|||
|
||||
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) {
|
||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||
hour: "numeric",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const SAVINGS_REGION = "savings_region";
|
|||
const SAVINGS_INDICATOR = "savings_indicator";
|
||||
const SESSIONS_GROUP = "sessions_group";
|
||||
const SESSIONS_TYPE = "sessions_type";
|
||||
const BATTERY_UNIT = "battery_unit";
|
||||
const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted";
|
||||
const SETTINGS_PRICE_ZOOM = "settings_price_zoom";
|
||||
const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin";
|
||||
|
|
@ -124,6 +125,7 @@ export interface Settings {
|
|||
savingsIndicator: string;
|
||||
sessionsGroup: string;
|
||||
sessionsType: string;
|
||||
batteryUnit: string;
|
||||
solarAdjusted: boolean;
|
||||
priceZoom: boolean;
|
||||
hideFeedin: boolean;
|
||||
|
|
@ -153,6 +155,7 @@ const settings: Settings = reactive({
|
|||
savingsIndicator: read(SAVINGS_INDICATOR),
|
||||
sessionsGroup: read(SESSIONS_GROUP),
|
||||
sessionsType: read(SESSIONS_TYPE),
|
||||
batteryUnit: read(BATTERY_UNIT),
|
||||
solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED),
|
||||
priceZoom: readBool(SETTINGS_PRICE_ZOOM),
|
||||
hideFeedin: readBool(SETTINGS_HIDE_FEEDIN),
|
||||
|
|
@ -181,6 +184,7 @@ watch(() => settings.savingsRegion, save(SAVINGS_REGION));
|
|||
watch(() => settings.savingsIndicator, save(SAVINGS_INDICATOR));
|
||||
watch(() => settings.sessionsGroup, save(SESSIONS_GROUP));
|
||||
watch(() => settings.sessionsType, save(SESSIONS_TYPE));
|
||||
watch(() => settings.batteryUnit, save(BATTERY_UNIT));
|
||||
watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED));
|
||||
watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM));
|
||||
watch(() => settings.hideFeedin, saveBool(SETTINGS_HIDE_FEEDIN));
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ export interface State {
|
|||
batteryGridChargeLimit?: number | null;
|
||||
smartCostAvailable?: boolean;
|
||||
smartCostType?: SMART_COST_TYPE;
|
||||
historyUpdated?: string; // ISO timestamp, bumped each 15min metrics persist
|
||||
siteTitle?: string;
|
||||
deviceColors?: DeviceColorEntry[];
|
||||
vehicles: Record<string, Vehicle>;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { Ocpp } from "@/types/evcc";
|
|||
|
||||
export function getOcppUrl(ocpp: Ocpp): string {
|
||||
// User specified url, e.g., for reverse proxy setups
|
||||
if (ocpp.status.externalUrl) {
|
||||
if (ocpp.status?.externalUrl) {
|
||||
return ocpp.status.externalUrl;
|
||||
}
|
||||
return `ws://${window.location.hostname}:${ocpp.config.port}/`;
|
||||
|
|
|
|||
|
|
@ -3,11 +3,18 @@
|
|||
<TopHeader :title="$t('batterySettings.modalTitle')" />
|
||||
<div class="row">
|
||||
<main class="col-12">
|
||||
<template v-if="batteryAvailable">
|
||||
<BatteryExperimental v-if="experimental" />
|
||||
<template v-else-if="batteryAvailable">
|
||||
<h3 class="fw-normal my-4">
|
||||
{{ $t("batterySettings.usageTab") }}
|
||||
</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">
|
||||
<hr class="my-5" />
|
||||
<h3 class="fw-normal my-4 mt-5">
|
||||
|
|
@ -28,10 +35,10 @@
|
|||
import { defineComponent } from "vue";
|
||||
import Header from "../components/Top/Header.vue";
|
||||
import BatteryUsageSettings from "../components/Battery/BatteryUsageSettings.vue";
|
||||
import BatteryExperimental from "../components/Battery/BatteryExperimental.vue";
|
||||
import SmartCostLimit from "../components/Tariff/SmartCostLimit.vue";
|
||||
import store from "../store";
|
||||
import settings from "../settings";
|
||||
import collector from "../mixins/collector";
|
||||
import { SMART_COST_TYPE } from "../types/evcc";
|
||||
|
||||
export default defineComponent({
|
||||
|
|
@ -39,22 +46,22 @@ export default defineComponent({
|
|||
components: {
|
||||
TopHeader: Header,
|
||||
BatteryUsageSettings,
|
||||
BatteryExperimental,
|
||||
SmartCostLimit,
|
||||
},
|
||||
mixins: [collector],
|
||||
head() {
|
||||
return { title: this.$t("batterySettings.modalTitle") };
|
||||
},
|
||||
computed: {
|
||||
experimental(): boolean {
|
||||
return !!store.state.experimental;
|
||||
},
|
||||
state() {
|
||||
return store.state;
|
||||
},
|
||||
batteryAvailable() {
|
||||
return (this.state.battery?.devices?.length ?? 0) > 0;
|
||||
},
|
||||
batteryUsageProps() {
|
||||
return this.collectProps(BatteryUsageSettings, this.state);
|
||||
},
|
||||
gridChargePossible() {
|
||||
const devices = this.state.battery?.devices ?? [];
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -47,22 +47,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
<Card
|
||||
v-for="group in visibleGroups"
|
||||
: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}`"
|
||||
>
|
||||
<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
|
||||
v-if="displayFrom && displayTo && displayPeriod"
|
||||
:group="group"
|
||||
|
|
@ -93,7 +86,7 @@
|
|||
:device-colors="deviceColors"
|
||||
@focus="onLegendFocus(group, $event)"
|
||||
/>
|
||||
</section>
|
||||
</Card>
|
||||
<p v-if="visibleGroups.length" class="text-end mt-3 mb-0">
|
||||
<a
|
||||
:href="csvLink"
|
||||
|
|
@ -113,6 +106,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import Header from "../components/Top/Header.vue";
|
||||
import Card from "../components/Helper/Card.vue";
|
||||
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
|
||||
import DateNavigator from "../components/Sessions/DateNavigator.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 { PERIODS } from "../components/Sessions/types";
|
||||
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 { handleDownloadClick } from "@/utils/native";
|
||||
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
||||
|
|
@ -138,6 +132,7 @@ export default defineComponent({
|
|||
name: "History",
|
||||
components: {
|
||||
TopHeader: Header,
|
||||
Card,
|
||||
PeriodSelector,
|
||||
DateNavigator,
|
||||
PeriodHeader,
|
||||
|
|
@ -159,7 +154,6 @@ export default defineComponent({
|
|||
displayTo: null as Date | null,
|
||||
displayPeriod: null as PERIODS | null,
|
||||
loading: true,
|
||||
interval: null as ReturnType<typeof setInterval> | null,
|
||||
startDate: new Date(2020, 0, 1),
|
||||
focusedEntity: {} as Record<string, number | null>,
|
||||
};
|
||||
|
|
@ -171,6 +165,9 @@ export default defineComponent({
|
|||
deviceColors(): DeviceColors {
|
||||
return deviceColorMap(store.state.deviceColors);
|
||||
},
|
||||
historyUpdated(): string | undefined {
|
||||
return store.state.historyUpdated;
|
||||
},
|
||||
effectivePeriod(): PERIODS {
|
||||
return this.period && HISTORY_PERIODS.includes(this.period)
|
||||
? (this.period as PERIODS)
|
||||
|
|
@ -337,6 +334,9 @@ export default defineComponent({
|
|||
offline(offline) {
|
||||
if (!offline) this.fetchData();
|
||||
},
|
||||
historyUpdated() {
|
||||
this.fetchData();
|
||||
},
|
||||
rawSeries() {
|
||||
// Drop focused entries whose paletteIndex no longer matches any entity
|
||||
// in the new data (e.g. a loadpoint was filtered out after a period
|
||||
|
|
@ -356,10 +356,6 @@ export default defineComponent({
|
|||
},
|
||||
mounted() {
|
||||
this.fetchData();
|
||||
this.interval = setInterval(() => this.fetchData(), 15 * 60 * 1e3);
|
||||
},
|
||||
unmounted() {
|
||||
if (this.interval) clearInterval(this.interval);
|
||||
},
|
||||
methods: {
|
||||
groupColor,
|
||||
|
|
@ -418,6 +414,7 @@ export default defineComponent({
|
|||
const colorFor = (i: number, s: HistorySeries) => {
|
||||
if (s.virtual) return colors.muted || 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 list.map((s, i) => {
|
||||
|
|
@ -543,6 +540,7 @@ export default defineComponent({
|
|||
<style scoped>
|
||||
.history-tile {
|
||||
background: var(--evcc-box);
|
||||
border: 1px solid var(--bs-border-color-translucent);
|
||||
padding: 1.25rem 1rem 1.75rem;
|
||||
}
|
||||
.empty-container {
|
||||
|
|
@ -581,9 +579,6 @@ export default defineComponent({
|
|||
color: var(--evcc-default-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.history-tile-title {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
@media (max-width: 575.98px) {
|
||||
/* edge-to-edge on mobile: cancel the container's px-4 padding */
|
||||
.history-tile {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const (
|
|||
GreenShareLoadpoints = "greenShareLoadpoints"
|
||||
GridConfigured = "gridConfigured"
|
||||
Grid = "grid"
|
||||
HistoryUpdated = "historyUpdated"
|
||||
HomePower = "homePower"
|
||||
PrioritySoc = "prioritySoc"
|
||||
Pv = "pv"
|
||||
|
|
|
|||
|
|
@ -125,13 +125,23 @@ func SetupSchema() error {
|
|||
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
|
||||
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,
|
||||
Timestamp: ts.Truncate(tariff.SlotDuration).Unix(),
|
||||
Timestamp: slot.Unix(),
|
||||
Energy: energy,
|
||||
ReturnEnergy: returnEnergy,
|
||||
SocTemp: socTemp,
|
||||
}).Error
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if OnPersist != nil {
|
||||
OnPersist(slot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
}
|
||||
|
||||
// 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.
|
||||
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]
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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
|
||||
if err := tx.Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -172,6 +172,54 @@ func TestQueryEnergyMultipleSeries(t *testing.T) {
|
|||
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) {
|
||||
clock := clock.NewMock()
|
||||
|
||||
|
|
|
|||
|
|
@ -148,6 +148,9 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
|||
}
|
||||
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
|
||||
if telemetry.Enabled() {
|
||||
shutdown.Register(func() {
|
||||
|
|
|
|||
39
i18n/de.json
39
i18n/de.json
|
|
@ -19,6 +19,45 @@
|
|||
"successCloseTab": "Du kannst diesen Tab jetzt schließen.",
|
||||
"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": {
|
||||
"batteryLevel": "Ladestand der Batterie",
|
||||
"bufferStart": {
|
||||
|
|
|
|||
39
i18n/en.json
39
i18n/en.json
|
|
@ -19,6 +19,45 @@
|
|||
"successCloseTab": "You can now close this tab.",
|
||||
"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": {
|
||||
"batteryLevel": "Battery level",
|
||||
"bufferStart": {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,13 @@ func energyHistoryHandler(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
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 {
|
||||
jsonError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1351,6 +1351,7 @@
|
|||
"in": "query",
|
||||
"name": "from",
|
||||
"schema": {
|
||||
"example": "2026-07-01T00:00:00Z",
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -1360,6 +1361,7 @@
|
|||
"in": "query",
|
||||
"name": "to",
|
||||
"schema": {
|
||||
"example": "2026-07-02T00:00:00Z",
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -1370,6 +1372,7 @@
|
|||
"name": "aggregate",
|
||||
"schema": {
|
||||
"default": "15m",
|
||||
"example": "1h",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
|
|
@ -1379,9 +1382,47 @@
|
|||
"name": "grouped",
|
||||
"schema": {
|
||||
"default": false,
|
||||
"example": false,
|
||||
"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",
|
||||
"in": "query",
|
||||
|
|
@ -1392,6 +1433,7 @@
|
|||
"json",
|
||||
"csv"
|
||||
],
|
||||
"example": "json",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
|
|
@ -1400,6 +1442,7 @@
|
|||
"in": "query",
|
||||
"name": "lang",
|
||||
"schema": {
|
||||
"example": "de",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -321,8 +321,11 @@ Returns aggregated energy history data. Aggregate granularity defaults to 15 min
|
|||
| aggregate | string | Aggregation interval. Examples: 15m, 1h, day, month |
|
||||
| format | string | Response format |
|
||||
| from | string | Start time (RFC3339) |
|
||||
| group | string | Filter by entity group |
|
||||
| grouped | boolean | Group results by loadpoint |
|
||||
| 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) |
|
||||
|
||||
**Example call:**
|
||||
|
|
@ -332,8 +335,11 @@ call getEnergyHistory {
|
|||
"aggregate": "example",
|
||||
"format": "example",
|
||||
"from": "example",
|
||||
"group": "example",
|
||||
"grouped": true,
|
||||
"lang": "example",
|
||||
"name": "example",
|
||||
"title": "example",
|
||||
"to": "example"
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1319,24 +1319,55 @@ paths:
|
|||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2026-07-01T00:00:00Z"
|
||||
- name: to
|
||||
in: query
|
||||
description: End time (RFC3339)
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2026-07-02T00:00:00Z"
|
||||
- name: aggregate
|
||||
in: query
|
||||
description: "Aggregation interval. Examples: 15m, 1h, day, month"
|
||||
schema:
|
||||
type: string
|
||||
default: 15m
|
||||
example: 1h
|
||||
- name: grouped
|
||||
in: query
|
||||
description: Group results by loadpoint
|
||||
schema:
|
||||
type: boolean
|
||||
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
|
||||
in: query
|
||||
description: Response format
|
||||
|
|
@ -1346,11 +1377,13 @@ paths:
|
|||
- json
|
||||
- csv
|
||||
default: json
|
||||
example: json
|
||||
- name: lang
|
||||
in: query
|
||||
description: Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header.
|
||||
schema:
|
||||
type: string
|
||||
example: de
|
||||
responses:
|
||||
"200":
|
||||
description: Energy history data
|
||||
|
|
|
|||
58
tests/battery-experimental.evcc.yaml
Normal file
58
tests/battery-experimental.evcc.yaml
Normal 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
|
||||
82
tests/battery-experimental.spec.ts
Normal file
82
tests/battery-experimental.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
13
tests/battery-experimental.sql
Normal file
13
tests/battery-experimental.sql
Normal 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');
|
||||
Loading…
Add table
Add a link
Reference in a new issue