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;
|
light: string | null;
|
||||||
selfPalette: string[];
|
selfPalette: string[];
|
||||||
palette: string[];
|
palette: string[];
|
||||||
|
batteryPalette: string[];
|
||||||
} = reactive({
|
} = reactive({
|
||||||
text: null,
|
text: null,
|
||||||
muted: null,
|
muted: null,
|
||||||
|
|
@ -69,10 +70,11 @@ const colors: {
|
||||||
"#6D28D9", // violet
|
"#6D28D9", // violet
|
||||||
"#334155", // gray
|
"#334155", // gray
|
||||||
],
|
],
|
||||||
|
batteryPalette: ["#0BA631", "#7FC41B", "#0A6E63", "#34D399", "#0E9E8F"],
|
||||||
});
|
});
|
||||||
|
|
||||||
// normalize 6-digit hex to 8-digit, then replace alpha
|
// normalize 6-digit hex to 8-digit, then replace alpha
|
||||||
const setAlpha = (color: string | null, alpha: string): string | undefined => {
|
export const setAlpha = (color: string | null, alpha: string): string | undefined => {
|
||||||
if (!color) return undefined;
|
if (!color) return undefined;
|
||||||
const c = color.trim().toLowerCase();
|
const c = color.trim().toLowerCase();
|
||||||
// #rrggbb → append alpha, #rrggbbaa → replace alpha
|
// #rrggbb → append alpha, #rrggbbaa → replace alpha
|
||||||
|
|
@ -113,6 +115,12 @@ export function resolveColors(ids: string[], overrides: DeviceColors = {}): Devi
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dedicated battery palette, assigned by index (battery page + history battery group)
|
||||||
|
export function batteryColor(index: number): string {
|
||||||
|
const p = colors.batteryPalette;
|
||||||
|
return p[index % p.length] || "";
|
||||||
|
}
|
||||||
|
|
||||||
export const dimColor = (color: string | null) => setAlpha(color, "20");
|
export const dimColor = (color: string | null) => setAlpha(color, "20");
|
||||||
|
|
||||||
export const lighterColor = (color: string | null) => setAlpha(color, "aa");
|
export const lighterColor = (color: string | null) => setAlpha(color, "aa");
|
||||||
|
|
|
||||||
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[] {
|
entries(): StationEntry[] {
|
||||||
const byId = new Map<string, StationEntry>();
|
const byId = new Map<string, StationEntry>();
|
||||||
const published = new Set<string>();
|
const published = new Set<string>();
|
||||||
for (const station of this.ocpp.status.stations) {
|
for (const station of this.ocpp.status?.stations ?? []) {
|
||||||
byId.set(station.id, {
|
byId.set(station.id, {
|
||||||
id: station.id,
|
id: station.id,
|
||||||
status: station.status,
|
status: station.status,
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,13 @@ export default {
|
||||||
},
|
},
|
||||||
} as Meta<typeof BatteryIcon>;
|
} as Meta<typeof BatteryIcon>;
|
||||||
|
|
||||||
const Template: StoryFn<typeof BatteryIcon> = (args) => {
|
const Template: StoryFn<typeof BatteryIcon> = (args) => ({
|
||||||
const story = () => ({
|
components: { BatteryIcon },
|
||||||
components: { BatteryIcon },
|
setup() {
|
||||||
setup() {
|
return { args };
|
||||||
return { args };
|
},
|
||||||
},
|
template: '<BatteryIcon v-bind="args" />',
|
||||||
template: '<BatteryIcon v-bind="args" />',
|
});
|
||||||
});
|
|
||||||
story.args = args;
|
|
||||||
return story;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Empty = Template.bind({});
|
export const Empty = Template.bind({});
|
||||||
Empty.args = { soc: 0 };
|
Empty.args = { soc: 0 };
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import {
|
||||||
GridComponent,
|
GridComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
MarkPointComponent,
|
MarkPointComponent,
|
||||||
|
MarkLineComponent,
|
||||||
|
MarkAreaComponent,
|
||||||
|
GraphicComponent,
|
||||||
AxisPointerComponent,
|
AxisPointerComponent,
|
||||||
} from "echarts/components";
|
} from "echarts/components";
|
||||||
import { SVGRenderer } from "echarts/renderers";
|
import { SVGRenderer } from "echarts/renderers";
|
||||||
|
|
@ -18,6 +21,9 @@ echarts.use([
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
AxisPointerComponent,
|
AxisPointerComponent,
|
||||||
MarkPointComponent,
|
MarkPointComponent,
|
||||||
|
MarkLineComponent,
|
||||||
|
MarkAreaComponent,
|
||||||
|
GraphicComponent,
|
||||||
SVGRenderer,
|
SVGRenderer,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
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>
|
<template>
|
||||||
<label class="root position-relative d-block" :for="id" role="button">
|
<label
|
||||||
|
class="root position-relative"
|
||||||
|
:class="inline ? 'd-inline-block align-baseline' : 'd-block'"
|
||||||
|
:for="id"
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
<select :id="id" :value="selected" class="custom-select" tabindex="0" @change="change">
|
<select :id="id" :value="selected" class="custom-select" tabindex="0" @change="change">
|
||||||
<option
|
<option
|
||||||
v-for="{ name, value, count, disabled } in options"
|
v-for="{ name, value, count, disabled } in options"
|
||||||
|
|
@ -24,6 +29,7 @@ export default defineComponent({
|
||||||
options: { type: Array as PropType<SelectOption<number | string>[]> },
|
options: { type: Array as PropType<SelectOption<number | string>[]> },
|
||||||
selected: { type: [String, Number] },
|
selected: { type: [String, Number] },
|
||||||
id: { type: String },
|
id: { type: String },
|
||||||
|
inline: Boolean,
|
||||||
},
|
},
|
||||||
emits: ["change"],
|
emits: ["change"],
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
tooltipStyle,
|
tooltipStyle,
|
||||||
tooltipTable,
|
tooltipTable,
|
||||||
} from "../Forecast/echarts";
|
} from "../Forecast/echarts";
|
||||||
import colors, { resolveColors, deviceColorMap, darken } from "@/colors";
|
import colors, { resolveColors, deviceColorMap, darken, batteryColor, setAlpha } from "@/colors";
|
||||||
import store from "@/store";
|
import store from "@/store";
|
||||||
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||||
import { PERIODS } from "../Sessions/types";
|
import { PERIODS } from "../Sessions/types";
|
||||||
|
|
@ -244,6 +244,9 @@ export default defineComponent({
|
||||||
return palette[s.title] || this.color;
|
return palette[s.title] || this.color;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (this.group === "battery") {
|
||||||
|
return this.series.map((s, i) => batteryColor(s.paletteIndex ?? i));
|
||||||
|
}
|
||||||
if (this.series.length <= 1) return [this.color];
|
if (this.series.length <= 1) return [this.color];
|
||||||
return this.series.map((_, i) => darken(this.color, stepAlpha(i, this.series.length)));
|
return this.series.map((_, i) => darken(this.color, stepAlpha(i, this.series.length)));
|
||||||
},
|
},
|
||||||
|
|
@ -327,7 +330,9 @@ export default defineComponent({
|
||||||
};
|
};
|
||||||
this.series.forEach((s, i) => {
|
this.series.forEach((s, i) => {
|
||||||
const c = this.entryColors[i] || this.color;
|
const c = this.entryColors[i] || this.color;
|
||||||
const returnEnergyColor = (s.group === "grid" && colors.export) || c;
|
const returnEnergyColor =
|
||||||
|
(s.group === "grid" && colors.export) ||
|
||||||
|
(s.group === "battery" ? setAlpha(c, "cc") || c : c);
|
||||||
const energyValues = energyByEntity[i]!;
|
const energyValues = energyByEntity[i]!;
|
||||||
const returnEnergyValues = returnEnergyByEntity[i]!;
|
const returnEnergyValues = returnEnergyByEntity[i]!;
|
||||||
const energyName =
|
const energyName =
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<svg :style="svgStyle" viewBox="0 0 24 24">
|
<svg :style="svgStyle" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
d="M3.75 19.75q-.325-.325-.325-.75t.325-.75L9.8 12.2q.15-.15.325-.213t.375-.062q.2 0 .375.062t.325.213l3.3 3.3l6.4-7.2q.275-.325.713-.325t.737.3q.275.275.287.663t-.262.687L15.2 17.7q-.15.175-.337.263t-.388.087q-.2 0-.387-.075t-.338-.225L10.5 14.5l-5.25 5.25q-.325.325-.75.325t-.75-.325ZM4 13.3q-.125 0-.25-.063t-.2-.212l-.5-1.075l-1.075-.5q-.15-.075-.213-.2T1.7 11q0-.125.063-.25t.212-.2l1.075-.5l.5-1.075q.075-.15.2-.212T4 8.7q.125 0 .25.063t.2.212l.5 1.075l1.075.5q.275.125.275.45t-.275.45l-1.075.5l-.5 1.075q-.075.15-.2.212T4 13.3Zm11-2q-.125 0-.25-.063t-.2-.212l-.5-1.075l-1.075-.5q-.15-.075-.212-.2T12.7 9q0-.125.063-.25t.212-.2l1.075-.5l.5-1.075q.075-.15.2-.212T15 6.7q.125 0 .25.063t.2.212l.5 1.075l1.075.5q.15.075.213.2T17.3 9q0 .125-.063.25t-.212.2l-1.075.5l-.5 1.075q-.075.15-.2.213T15 11.3Zm-6.5-3q-.125 0-.25-.075T8.05 8L7.4 6.6L6 5.95q-.15-.075-.225-.2T5.7 5.5q0-.125.075-.25T6 5.05l1.4-.65l.65-1.4q.075-.15.2-.225T8.5 2.7q.125 0 .25.075t.2.225l.65 1.4l1.4.65q.15.075.225.2t.075.25q0 .125-.075.25t-.225.2l-1.4.65L8.95 8q-.075.15-.2.225T8.5 8.3Z"
|
d="M10.45 15.5q.6.6 1.55.588t1.4-.688l4.225-6.325q.225-.35-.062-.638t-.638-.062L10.6 12.6q-.675.45-.712 1.375t.562 1.525M12 4q.9 0 1.775.15t1.7.475q.4.15.85.563t.25.787t-.9.5t-1.125-.025q-.625-.225-1.262-.337T12 6Q8.675 6 6.337 8.338T4 14q0 1.05.288 2.075T5.1 18h13.8q.575-.95.838-1.975T20 13.9q0-.65-.113-1.275T19.55 11.4q-.15-.425-.05-.825t.45-.675q.325-.25.713-.15t.537.45q.375.875.575 1.788T22 13.85q.025 1.425-.325 2.725T20.65 19.05q-.275.45-.75.7t-1 .25H5.1q-.525 0-1-.25t-.75-.7q-.65-1.125-1-2.387T2 14q0-2.075.788-3.887t2.15-3.175t3.187-2.15T12 4m.175 7.825"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,19 @@ export default defineComponent({
|
||||||
|
|
||||||
return `${weekday} ${hour}`.trim();
|
return `${weekday} ${hour}`.trim();
|
||||||
},
|
},
|
||||||
|
// relative day plus time, e.g. "heute 16:30", "morgen 5:00", "Freitag 12:15"
|
||||||
|
fmtDayTime(date: Date) {
|
||||||
|
const time = this.fmtHourMinute(date);
|
||||||
|
const startOfDay = (d: Date) => new Date(d).setHours(0, 0, 0, 0);
|
||||||
|
const days = Math.round((startOfDay(date) - startOfDay(new Date())) / 86400000);
|
||||||
|
if (days === 0 || days === 1) {
|
||||||
|
const day = new Intl.RelativeTimeFormat(this.$i18n?.locale, {
|
||||||
|
numeric: "auto",
|
||||||
|
}).format(days, "day");
|
||||||
|
return `${day} ${time}`;
|
||||||
|
}
|
||||||
|
return `${this.weekdayLong(date)} ${time}`;
|
||||||
|
},
|
||||||
fmtHourMinute(date: Date) {
|
fmtHourMinute(date: Date) {
|
||||||
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
return new Intl.DateTimeFormat(this.$i18n?.locale, {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ const SAVINGS_REGION = "savings_region";
|
||||||
const SAVINGS_INDICATOR = "savings_indicator";
|
const SAVINGS_INDICATOR = "savings_indicator";
|
||||||
const SESSIONS_GROUP = "sessions_group";
|
const SESSIONS_GROUP = "sessions_group";
|
||||||
const SESSIONS_TYPE = "sessions_type";
|
const SESSIONS_TYPE = "sessions_type";
|
||||||
|
const BATTERY_UNIT = "battery_unit";
|
||||||
const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted";
|
const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted";
|
||||||
const SETTINGS_PRICE_ZOOM = "settings_price_zoom";
|
const SETTINGS_PRICE_ZOOM = "settings_price_zoom";
|
||||||
const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin";
|
const SETTINGS_HIDE_FEEDIN = "settings_hide_feedin";
|
||||||
|
|
@ -124,6 +125,7 @@ export interface Settings {
|
||||||
savingsIndicator: string;
|
savingsIndicator: string;
|
||||||
sessionsGroup: string;
|
sessionsGroup: string;
|
||||||
sessionsType: string;
|
sessionsType: string;
|
||||||
|
batteryUnit: string;
|
||||||
solarAdjusted: boolean;
|
solarAdjusted: boolean;
|
||||||
priceZoom: boolean;
|
priceZoom: boolean;
|
||||||
hideFeedin: boolean;
|
hideFeedin: boolean;
|
||||||
|
|
@ -153,6 +155,7 @@ const settings: Settings = reactive({
|
||||||
savingsIndicator: read(SAVINGS_INDICATOR),
|
savingsIndicator: read(SAVINGS_INDICATOR),
|
||||||
sessionsGroup: read(SESSIONS_GROUP),
|
sessionsGroup: read(SESSIONS_GROUP),
|
||||||
sessionsType: read(SESSIONS_TYPE),
|
sessionsType: read(SESSIONS_TYPE),
|
||||||
|
batteryUnit: read(BATTERY_UNIT),
|
||||||
solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED),
|
solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED),
|
||||||
priceZoom: readBool(SETTINGS_PRICE_ZOOM),
|
priceZoom: readBool(SETTINGS_PRICE_ZOOM),
|
||||||
hideFeedin: readBool(SETTINGS_HIDE_FEEDIN),
|
hideFeedin: readBool(SETTINGS_HIDE_FEEDIN),
|
||||||
|
|
@ -181,6 +184,7 @@ watch(() => settings.savingsRegion, save(SAVINGS_REGION));
|
||||||
watch(() => settings.savingsIndicator, save(SAVINGS_INDICATOR));
|
watch(() => settings.savingsIndicator, save(SAVINGS_INDICATOR));
|
||||||
watch(() => settings.sessionsGroup, save(SESSIONS_GROUP));
|
watch(() => settings.sessionsGroup, save(SESSIONS_GROUP));
|
||||||
watch(() => settings.sessionsType, save(SESSIONS_TYPE));
|
watch(() => settings.sessionsType, save(SESSIONS_TYPE));
|
||||||
|
watch(() => settings.batteryUnit, save(BATTERY_UNIT));
|
||||||
watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED));
|
watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED));
|
||||||
watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM));
|
watch(() => settings.priceZoom, saveBool(SETTINGS_PRICE_ZOOM));
|
||||||
watch(() => settings.hideFeedin, saveBool(SETTINGS_HIDE_FEEDIN));
|
watch(() => settings.hideFeedin, saveBool(SETTINGS_HIDE_FEEDIN));
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ export interface State {
|
||||||
batteryGridChargeLimit?: number | null;
|
batteryGridChargeLimit?: number | null;
|
||||||
smartCostAvailable?: boolean;
|
smartCostAvailable?: boolean;
|
||||||
smartCostType?: SMART_COST_TYPE;
|
smartCostType?: SMART_COST_TYPE;
|
||||||
|
historyUpdated?: string; // ISO timestamp, bumped each 15min metrics persist
|
||||||
siteTitle?: string;
|
siteTitle?: string;
|
||||||
deviceColors?: DeviceColorEntry[];
|
deviceColors?: DeviceColorEntry[];
|
||||||
vehicles: Record<string, Vehicle>;
|
vehicles: Record<string, Vehicle>;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { Ocpp } from "@/types/evcc";
|
||||||
|
|
||||||
export function getOcppUrl(ocpp: Ocpp): string {
|
export function getOcppUrl(ocpp: Ocpp): string {
|
||||||
// User specified url, e.g., for reverse proxy setups
|
// User specified url, e.g., for reverse proxy setups
|
||||||
if (ocpp.status.externalUrl) {
|
if (ocpp.status?.externalUrl) {
|
||||||
return ocpp.status.externalUrl;
|
return ocpp.status.externalUrl;
|
||||||
}
|
}
|
||||||
return `ws://${window.location.hostname}:${ocpp.config.port}/`;
|
return `ws://${window.location.hostname}:${ocpp.config.port}/`;
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,18 @@
|
||||||
<TopHeader :title="$t('batterySettings.modalTitle')" />
|
<TopHeader :title="$t('batterySettings.modalTitle')" />
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<main class="col-12">
|
<main class="col-12">
|
||||||
<template v-if="batteryAvailable">
|
<BatteryExperimental v-if="experimental" />
|
||||||
|
<template v-else-if="batteryAvailable">
|
||||||
<h3 class="fw-normal my-4">
|
<h3 class="fw-normal my-4">
|
||||||
{{ $t("batterySettings.usageTab") }}
|
{{ $t("batterySettings.usageTab") }}
|
||||||
</h3>
|
</h3>
|
||||||
<BatteryUsageSettings style="max-width: 950px" v-bind="batteryUsageProps" />
|
<BatteryUsageSettings
|
||||||
|
:buffer-soc="state.bufferSoc"
|
||||||
|
:priority-soc="state.prioritySoc"
|
||||||
|
:buffer-start-soc="state.bufferStartSoc"
|
||||||
|
:battery-discharge-control="state.batteryDischargeControl"
|
||||||
|
:battery="state.battery"
|
||||||
|
/>
|
||||||
<template v-if="gridChargeVisible">
|
<template v-if="gridChargeVisible">
|
||||||
<hr class="my-5" />
|
<hr class="my-5" />
|
||||||
<h3 class="fw-normal my-4 mt-5">
|
<h3 class="fw-normal my-4 mt-5">
|
||||||
|
|
@ -28,10 +35,10 @@
|
||||||
import { defineComponent } from "vue";
|
import { defineComponent } from "vue";
|
||||||
import Header from "../components/Top/Header.vue";
|
import Header from "../components/Top/Header.vue";
|
||||||
import BatteryUsageSettings from "../components/Battery/BatteryUsageSettings.vue";
|
import BatteryUsageSettings from "../components/Battery/BatteryUsageSettings.vue";
|
||||||
|
import BatteryExperimental from "../components/Battery/BatteryExperimental.vue";
|
||||||
import SmartCostLimit from "../components/Tariff/SmartCostLimit.vue";
|
import SmartCostLimit from "../components/Tariff/SmartCostLimit.vue";
|
||||||
import store from "../store";
|
import store from "../store";
|
||||||
import settings from "../settings";
|
import settings from "../settings";
|
||||||
import collector from "../mixins/collector";
|
|
||||||
import { SMART_COST_TYPE } from "../types/evcc";
|
import { SMART_COST_TYPE } from "../types/evcc";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
|
|
@ -39,22 +46,22 @@ export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
TopHeader: Header,
|
TopHeader: Header,
|
||||||
BatteryUsageSettings,
|
BatteryUsageSettings,
|
||||||
|
BatteryExperimental,
|
||||||
SmartCostLimit,
|
SmartCostLimit,
|
||||||
},
|
},
|
||||||
mixins: [collector],
|
|
||||||
head() {
|
head() {
|
||||||
return { title: this.$t("batterySettings.modalTitle") };
|
return { title: this.$t("batterySettings.modalTitle") };
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
experimental(): boolean {
|
||||||
|
return !!store.state.experimental;
|
||||||
|
},
|
||||||
state() {
|
state() {
|
||||||
return store.state;
|
return store.state;
|
||||||
},
|
},
|
||||||
batteryAvailable() {
|
batteryAvailable() {
|
||||||
return (this.state.battery?.devices?.length ?? 0) > 0;
|
return (this.state.battery?.devices?.length ?? 0) > 0;
|
||||||
},
|
},
|
||||||
batteryUsageProps() {
|
|
||||||
return this.collectProps(BatteryUsageSettings, this.state);
|
|
||||||
},
|
|
||||||
gridChargePossible() {
|
gridChargePossible() {
|
||||||
const devices = this.state.battery?.devices ?? [];
|
const devices = this.state.battery?.devices ?? [];
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -47,22 +47,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section
|
<Card
|
||||||
v-for="group in visibleGroups"
|
v-for="group in visibleGroups"
|
||||||
:key="group"
|
:key="group"
|
||||||
class="history-tile mb-4"
|
:title="$t(`main.history.group.${group}`)"
|
||||||
|
:subtitle="groupTotalLabel(group)"
|
||||||
|
edge-to-edge
|
||||||
|
class="mb-4"
|
||||||
:data-testid="`history-section-${group}`"
|
:data-testid="`history-section-${group}`"
|
||||||
>
|
>
|
||||||
<h3
|
|
||||||
class="fw-normal my-0 d-flex gap-3 flex-wrap align-items-baseline overflow-hidden history-tile-title"
|
|
||||||
>
|
|
||||||
<span class="d-block no-wrap text-truncate">
|
|
||||||
{{ $t(`main.history.group.${group}`) }}
|
|
||||||
</span>
|
|
||||||
<small v-if="groupTotalLabel(group)" class="d-block no-wrap text-truncate">
|
|
||||||
{{ groupTotalLabel(group) }}
|
|
||||||
</small>
|
|
||||||
</h3>
|
|
||||||
<GroupChart
|
<GroupChart
|
||||||
v-if="displayFrom && displayTo && displayPeriod"
|
v-if="displayFrom && displayTo && displayPeriod"
|
||||||
:group="group"
|
:group="group"
|
||||||
|
|
@ -93,7 +86,7 @@
|
||||||
:device-colors="deviceColors"
|
:device-colors="deviceColors"
|
||||||
@focus="onLegendFocus(group, $event)"
|
@focus="onLegendFocus(group, $event)"
|
||||||
/>
|
/>
|
||||||
</section>
|
</Card>
|
||||||
<p v-if="visibleGroups.length" class="text-end mt-3 mb-0">
|
<p v-if="visibleGroups.length" class="text-end mt-3 mb-0">
|
||||||
<a
|
<a
|
||||||
:href="csvLink"
|
:href="csvLink"
|
||||||
|
|
@ -113,6 +106,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, type PropType } from "vue";
|
import { defineComponent, type PropType } from "vue";
|
||||||
import Header from "../components/Top/Header.vue";
|
import Header from "../components/Top/Header.vue";
|
||||||
|
import Card from "../components/Helper/Card.vue";
|
||||||
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
|
import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
|
||||||
import DateNavigator from "../components/Sessions/DateNavigator.vue";
|
import DateNavigator from "../components/Sessions/DateNavigator.vue";
|
||||||
import PeriodHeader from "../components/Sessions/PeriodHeader.vue";
|
import PeriodHeader from "../components/Sessions/PeriodHeader.vue";
|
||||||
|
|
@ -121,7 +115,7 @@ import type { Legend } from "../components/Sessions/types";
|
||||||
import type { DeviceColors } from "@/types/evcc";
|
import type { DeviceColors } from "@/types/evcc";
|
||||||
import { PERIODS } from "../components/Sessions/types";
|
import { PERIODS } from "../components/Sessions/types";
|
||||||
import { GROUP_ORDER, groupColor, hasColorPicker } from "../components/History/groups";
|
import { GROUP_ORDER, groupColor, hasColorPicker } from "../components/History/groups";
|
||||||
import colors, { resolveColors, deviceColorMap, darken } from "../colors";
|
import colors, { resolveColors, deviceColorMap, darken, batteryColor } from "../colors";
|
||||||
import LegendList from "../components/Sessions/LegendList.vue";
|
import LegendList from "../components/Sessions/LegendList.vue";
|
||||||
import { handleDownloadClick } from "@/utils/native";
|
import { handleDownloadClick } from "@/utils/native";
|
||||||
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
import formatter, { POWER_UNIT } from "../mixins/formatter";
|
||||||
|
|
@ -138,6 +132,7 @@ export default defineComponent({
|
||||||
name: "History",
|
name: "History",
|
||||||
components: {
|
components: {
|
||||||
TopHeader: Header,
|
TopHeader: Header,
|
||||||
|
Card,
|
||||||
PeriodSelector,
|
PeriodSelector,
|
||||||
DateNavigator,
|
DateNavigator,
|
||||||
PeriodHeader,
|
PeriodHeader,
|
||||||
|
|
@ -159,7 +154,6 @@ export default defineComponent({
|
||||||
displayTo: null as Date | null,
|
displayTo: null as Date | null,
|
||||||
displayPeriod: null as PERIODS | null,
|
displayPeriod: null as PERIODS | null,
|
||||||
loading: true,
|
loading: true,
|
||||||
interval: null as ReturnType<typeof setInterval> | null,
|
|
||||||
startDate: new Date(2020, 0, 1),
|
startDate: new Date(2020, 0, 1),
|
||||||
focusedEntity: {} as Record<string, number | null>,
|
focusedEntity: {} as Record<string, number | null>,
|
||||||
};
|
};
|
||||||
|
|
@ -171,6 +165,9 @@ export default defineComponent({
|
||||||
deviceColors(): DeviceColors {
|
deviceColors(): DeviceColors {
|
||||||
return deviceColorMap(store.state.deviceColors);
|
return deviceColorMap(store.state.deviceColors);
|
||||||
},
|
},
|
||||||
|
historyUpdated(): string | undefined {
|
||||||
|
return store.state.historyUpdated;
|
||||||
|
},
|
||||||
effectivePeriod(): PERIODS {
|
effectivePeriod(): PERIODS {
|
||||||
return this.period && HISTORY_PERIODS.includes(this.period)
|
return this.period && HISTORY_PERIODS.includes(this.period)
|
||||||
? (this.period as PERIODS)
|
? (this.period as PERIODS)
|
||||||
|
|
@ -337,6 +334,9 @@ export default defineComponent({
|
||||||
offline(offline) {
|
offline(offline) {
|
||||||
if (!offline) this.fetchData();
|
if (!offline) this.fetchData();
|
||||||
},
|
},
|
||||||
|
historyUpdated() {
|
||||||
|
this.fetchData();
|
||||||
|
},
|
||||||
rawSeries() {
|
rawSeries() {
|
||||||
// Drop focused entries whose paletteIndex no longer matches any entity
|
// Drop focused entries whose paletteIndex no longer matches any entity
|
||||||
// in the new data (e.g. a loadpoint was filtered out after a period
|
// in the new data (e.g. a loadpoint was filtered out after a period
|
||||||
|
|
@ -356,10 +356,6 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.fetchData();
|
this.fetchData();
|
||||||
this.interval = setInterval(() => this.fetchData(), 15 * 60 * 1e3);
|
|
||||||
},
|
|
||||||
unmounted() {
|
|
||||||
if (this.interval) clearInterval(this.interval);
|
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
groupColor,
|
groupColor,
|
||||||
|
|
@ -418,6 +414,7 @@ export default defineComponent({
|
||||||
const colorFor = (i: number, s: HistorySeries) => {
|
const colorFor = (i: number, s: HistorySeries) => {
|
||||||
if (s.virtual) return colors.muted || baseColor;
|
if (s.virtual) return colors.muted || baseColor;
|
||||||
if (colorPicker) return palette[s.title] || baseColor;
|
if (colorPicker) return palette[s.title] || baseColor;
|
||||||
|
if (group === "battery") return batteryColor(s.paletteIndex ?? i);
|
||||||
return darken(baseColor, stepAlpha(i, Math.max(n, 1)));
|
return darken(baseColor, stepAlpha(i, Math.max(n, 1)));
|
||||||
};
|
};
|
||||||
return list.map((s, i) => {
|
return list.map((s, i) => {
|
||||||
|
|
@ -543,6 +540,7 @@ export default defineComponent({
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.history-tile {
|
.history-tile {
|
||||||
background: var(--evcc-box);
|
background: var(--evcc-box);
|
||||||
|
border: 1px solid var(--bs-border-color-translucent);
|
||||||
padding: 1.25rem 1rem 1.75rem;
|
padding: 1.25rem 1rem 1.75rem;
|
||||||
}
|
}
|
||||||
.empty-container {
|
.empty-container {
|
||||||
|
|
@ -581,9 +579,6 @@ export default defineComponent({
|
||||||
color: var(--evcc-default-text);
|
color: var(--evcc-default-text);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
.history-tile-title {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 575.98px) {
|
@media (max-width: 575.98px) {
|
||||||
/* edge-to-edge on mobile: cancel the container's px-4 padding */
|
/* edge-to-edge on mobile: cancel the container's px-4 padding */
|
||||||
.history-tile {
|
.history-tile {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const (
|
||||||
GreenShareLoadpoints = "greenShareLoadpoints"
|
GreenShareLoadpoints = "greenShareLoadpoints"
|
||||||
GridConfigured = "gridConfigured"
|
GridConfigured = "gridConfigured"
|
||||||
Grid = "grid"
|
Grid = "grid"
|
||||||
|
HistoryUpdated = "historyUpdated"
|
||||||
HomePower = "homePower"
|
HomePower = "homePower"
|
||||||
PrioritySoc = "prioritySoc"
|
PrioritySoc = "prioritySoc"
|
||||||
Pv = "pv"
|
Pv = "pv"
|
||||||
|
|
|
||||||
|
|
@ -125,13 +125,23 @@ func SetupSchema() error {
|
||||||
return db.Instance.AutoMigrate(new(meter))
|
return db.Instance.AutoMigrate(new(meter))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnPersist, if set, is called with the slot start after a slot is written.
|
||||||
|
var OnPersist func(slot time.Time)
|
||||||
|
|
||||||
// persist stores a completed 15min slot
|
// persist stores a completed 15min slot
|
||||||
func persist(entity entity, ts time.Time, energy, returnEnergy float64, socTemp *float64) error {
|
func persist(entity entity, ts time.Time, energy, returnEnergy float64, socTemp *float64) error {
|
||||||
return db.Instance.Create(&meter{
|
slot := ts.Truncate(tariff.SlotDuration)
|
||||||
|
if err := db.Instance.Create(&meter{
|
||||||
Meter: entity.Id,
|
Meter: entity.Id,
|
||||||
Timestamp: ts.Truncate(tariff.SlotDuration).Unix(),
|
Timestamp: slot.Unix(),
|
||||||
Energy: energy,
|
Energy: energy,
|
||||||
ReturnEnergy: returnEnergy,
|
ReturnEnergy: returnEnergy,
|
||||||
SocTemp: socTemp,
|
SocTemp: socTemp,
|
||||||
}).Error
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if OnPersist != nil {
|
||||||
|
OnPersist(slot)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,15 @@ var aggregateDurations = map[string]func(time.Time) time.Time{
|
||||||
"month": func(t time.Time) time.Time { return t.AddDate(0, 1, 0) },
|
"month": func(t time.Time) time.Time { return t.AddDate(0, 1, 0) },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnergyFilter narrows QueryEnergy to matching entities. Empty fields are ignored.
|
||||||
|
type EnergyFilter struct {
|
||||||
|
Group string
|
||||||
|
Name string
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
// QueryEnergy returns aggregated energy data, per title or per group.
|
// QueryEnergy returns aggregated energy data, per title or per group.
|
||||||
func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series, error) {
|
func QueryEnergy(from, to time.Time, aggregate string, grouped bool, filter ...EnergyFilter) ([]Series, error) {
|
||||||
addDuration := aggregateDurations[aggregate]
|
addDuration := aggregateDurations[aggregate]
|
||||||
|
|
||||||
format, ok := aggregateFormats[aggregate]
|
format, ok := aggregateFormats[aggregate]
|
||||||
|
|
@ -109,6 +116,19 @@ func QueryEnergy(from, to time.Time, aggregate string, grouped bool) ([]Series,
|
||||||
tx = tx.Where("m.ts < ?", to.Unix())
|
tx = tx.Where("m.ts < ?", to.Unix())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(filter) > 0 {
|
||||||
|
f := filter[0]
|
||||||
|
if f.Group != "" {
|
||||||
|
tx = tx.Where(`e."group" = ?`, f.Group)
|
||||||
|
}
|
||||||
|
if f.Name != "" {
|
||||||
|
tx = tx.Where("e.name = ?", f.Name)
|
||||||
|
}
|
||||||
|
if f.Title != "" {
|
||||||
|
tx = tx.Where("e.title = ?", f.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var rows []row
|
var rows []row
|
||||||
if err := tx.Scan(&rows).Error; err != nil {
|
if err := tx.Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,54 @@ func TestQueryEnergyMultipleSeries(t *testing.T) {
|
||||||
require.InDelta(t, 11+21, byGroup[PV].Data[1].ReturnEnergy, 0.001)
|
require.InDelta(t, 11+21, byGroup[PV].Data[1].ReturnEnergy, 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQueryEnergyFilter(t *testing.T) {
|
||||||
|
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||||
|
require.NoError(t, SetupSchema())
|
||||||
|
|
||||||
|
eGrid := entity{Id: 2, Name: Grid, Group: Grid}
|
||||||
|
require.NoError(t, db.Instance.Create(&eGrid).Error)
|
||||||
|
ePv := entity{Id: 4, Name: "pv1", Group: PV}
|
||||||
|
require.NoError(t, db.Instance.Create(&ePv).Error)
|
||||||
|
eBat := entity{Id: 6, Name: "db:8", Group: Battery, Title: "Anker"}
|
||||||
|
require.NoError(t, db.Instance.Create(&eBat).Error)
|
||||||
|
|
||||||
|
loc := time.Now().Location()
|
||||||
|
base := time.Date(2026, 4, 15, 16, 0, 0, 0, loc)
|
||||||
|
for i := range 2 {
|
||||||
|
ts := base.Add(time.Duration(i) * time.Hour)
|
||||||
|
require.NoError(t, persist(eGrid, ts, float64(1+i), 0, nil))
|
||||||
|
require.NoError(t, persist(ePv, ts, 0, float64(10+i), nil))
|
||||||
|
require.NoError(t, persist(eBat, ts, float64(5+i), 0, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
from := base.Add(-time.Hour).UTC()
|
||||||
|
to := base.Add(3 * time.Hour).UTC()
|
||||||
|
|
||||||
|
// group filter
|
||||||
|
res, err := QueryEnergy(from, to, "hour", false, EnergyFilter{Group: Battery})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, res, 1)
|
||||||
|
require.Equal(t, Battery, res[0].Group)
|
||||||
|
require.Equal(t, "Anker", res[0].Title)
|
||||||
|
|
||||||
|
// name filter
|
||||||
|
res, err = QueryEnergy(from, to, "hour", false, EnergyFilter{Name: "pv1"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, res, 1)
|
||||||
|
require.Equal(t, PV, res[0].Group)
|
||||||
|
|
||||||
|
// title filter
|
||||||
|
res, err = QueryEnergy(from, to, "hour", false, EnergyFilter{Title: "Anker"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, res, 1)
|
||||||
|
require.Equal(t, Battery, res[0].Group)
|
||||||
|
|
||||||
|
// no filter fields set: all series
|
||||||
|
res, err = QueryEnergy(from, to, "hour", false, EnergyFilter{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, res, 3)
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateProfile(t *testing.T) {
|
func TestUpdateProfile(t *testing.T) {
|
||||||
clock := clock.NewMock()
|
clock := clock.NewMock()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,9 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
|
||||||
}
|
}
|
||||||
site.collectors[metrics.Home] = me
|
site.collectors[metrics.Home] = me
|
||||||
|
|
||||||
|
// reload history in the UI on each persisted 15min slot instead of polling
|
||||||
|
metrics.OnPersist = func(slot time.Time) { site.publish(keys.HistoryUpdated, slot) }
|
||||||
|
|
||||||
// upload telemetry on shutdown
|
// upload telemetry on shutdown
|
||||||
if telemetry.Enabled() {
|
if telemetry.Enabled() {
|
||||||
shutdown.Register(func() {
|
shutdown.Register(func() {
|
||||||
|
|
|
||||||
39
i18n/de.json
39
i18n/de.json
|
|
@ -19,6 +19,45 @@
|
||||||
"successCloseTab": "Du kannst diesen Tab jetzt schließen.",
|
"successCloseTab": "Du kannst diesen Tab jetzt schließen.",
|
||||||
"title": "Autorisierungsstatus"
|
"title": "Autorisierungsstatus"
|
||||||
},
|
},
|
||||||
|
"battery": {
|
||||||
|
"card": {
|
||||||
|
"charging": "Lädt",
|
||||||
|
"discharging": "Entlädt",
|
||||||
|
"energy": "Energie",
|
||||||
|
"ofTotal": "von {total}",
|
||||||
|
"power": "Leistung",
|
||||||
|
"soc": "Ladestand"
|
||||||
|
},
|
||||||
|
"combined": "Gesamt",
|
||||||
|
"config": {
|
||||||
|
"buffer": "Hausspeicherenergie oberhalb von {soc} darf für die Unterstützung der Ladepunkte genutzt werden und zögert die Abschaltung im PV-Modus hinaus. PV-Ladung automatisch starten {start}.",
|
||||||
|
"bufferNone": "Hausspeicherenergie wird nicht für die Ladepunkte genutzt. Setze einen Wert unter {soc}, um die Unterstützung zu aktivieren.",
|
||||||
|
"bufferStart": {
|
||||||
|
"above": "ab einem Speicherstand von {soc}",
|
||||||
|
"full": "erst, wenn der Speicher voll ist",
|
||||||
|
"never": "nur mit genug Überschuss"
|
||||||
|
},
|
||||||
|
"bufferTitle": "Batterie als Ladepuffer",
|
||||||
|
"discharge": "Entladung der Hausbatterie im Schnell-Modus und bei geplantem Laden verhindern.",
|
||||||
|
"priority": "Hausspeicher hat Vorrang, solange er unter {soc} liegt. Dann erhalten die Ladepunkte den Überschuss.",
|
||||||
|
"priorityNone": "Sonnenüberschuss geht direkt an die Ladepunkte. Setze einen Wert über {soc}, um den Hausspeicher zu bevorzugen.",
|
||||||
|
"priorityTitle": "Wohin geht der Überschuss zuerst?"
|
||||||
|
},
|
||||||
|
"optimizer": {
|
||||||
|
"action": {
|
||||||
|
"charge": "Netzladen",
|
||||||
|
"hold": "Entladung gesperrt",
|
||||||
|
"holdcharge": "Laden pausiert",
|
||||||
|
"normal": "PV-Überschuss folgen"
|
||||||
|
},
|
||||||
|
"empty": "leer",
|
||||||
|
"full": "voll",
|
||||||
|
"highest": "Höchststand ({value})",
|
||||||
|
"lowest": "Tiefststand ({value})",
|
||||||
|
"suggestion": "Vorschlag",
|
||||||
|
"title": "Optimizer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"batterySettings": {
|
"batterySettings": {
|
||||||
"batteryLevel": "Ladestand der Batterie",
|
"batteryLevel": "Ladestand der Batterie",
|
||||||
"bufferStart": {
|
"bufferStart": {
|
||||||
|
|
|
||||||
39
i18n/en.json
39
i18n/en.json
|
|
@ -19,6 +19,45 @@
|
||||||
"successCloseTab": "You can now close this tab.",
|
"successCloseTab": "You can now close this tab.",
|
||||||
"title": "Authorization Status"
|
"title": "Authorization Status"
|
||||||
},
|
},
|
||||||
|
"battery": {
|
||||||
|
"card": {
|
||||||
|
"charging": "Charging",
|
||||||
|
"discharging": "Discharging",
|
||||||
|
"energy": "Energy",
|
||||||
|
"ofTotal": "of {total}",
|
||||||
|
"power": "Power",
|
||||||
|
"soc": "Battery level"
|
||||||
|
},
|
||||||
|
"combined": "Combined",
|
||||||
|
"config": {
|
||||||
|
"buffer": "Home battery energy above {soc} may be used to support the charging points and delays switching off in solar mode. Start solar charging automatically {start}.",
|
||||||
|
"bufferNone": "Home battery energy is not used for the charging points. Set a value below {soc} to enable support.",
|
||||||
|
"bufferStart": {
|
||||||
|
"above": "once the battery is above {soc}",
|
||||||
|
"full": "once the battery is full",
|
||||||
|
"never": "only with enough surplus"
|
||||||
|
},
|
||||||
|
"bufferTitle": "Battery as charging buffer",
|
||||||
|
"discharge": "Prevent home battery discharge in fast mode and during planned charging.",
|
||||||
|
"priority": "Home battery has priority while it is below {soc}. Then the charging points receive the surplus.",
|
||||||
|
"priorityNone": "Solar surplus goes straight to the charging points. Set a value above {soc} to prioritize the home battery.",
|
||||||
|
"priorityTitle": "Where does the surplus go first?"
|
||||||
|
},
|
||||||
|
"optimizer": {
|
||||||
|
"action": {
|
||||||
|
"charge": "Grid charging",
|
||||||
|
"hold": "Discharge locked",
|
||||||
|
"holdcharge": "Charging paused",
|
||||||
|
"normal": "Following solar surplus"
|
||||||
|
},
|
||||||
|
"empty": "empty",
|
||||||
|
"full": "full",
|
||||||
|
"highest": "Highest ({value})",
|
||||||
|
"lowest": "Lowest ({value})",
|
||||||
|
"suggestion": "Suggestion",
|
||||||
|
"title": "Optimizer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"batterySettings": {
|
"batterySettings": {
|
||||||
"batteryLevel": "Battery level",
|
"batteryLevel": "Battery level",
|
||||||
"bufferStart": {
|
"bufferStart": {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,13 @@ func energyHistoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
grouped := q.Get("grouped") == "true"
|
grouped := q.Get("grouped") == "true"
|
||||||
|
|
||||||
res, err := metrics.QueryEnergy(from, to, aggregate, grouped)
|
filter := metrics.EnergyFilter{
|
||||||
|
Group: q.Get("group"),
|
||||||
|
Name: q.Get("name"),
|
||||||
|
Title: q.Get("title"),
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := metrics.QueryEnergy(from, to, aggregate, grouped, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonError(w, http.StatusInternalServerError, err)
|
jsonError(w, http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1351,6 +1351,7 @@
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"name": "from",
|
"name": "from",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
"example": "2026-07-01T00:00:00Z",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|
@ -1360,6 +1361,7 @@
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"name": "to",
|
"name": "to",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
"example": "2026-07-02T00:00:00Z",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|
@ -1370,6 +1372,7 @@
|
||||||
"name": "aggregate",
|
"name": "aggregate",
|
||||||
"schema": {
|
"schema": {
|
||||||
"default": "15m",
|
"default": "15m",
|
||||||
|
"example": "1h",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1379,9 +1382,47 @@
|
||||||
"name": "grouped",
|
"name": "grouped",
|
||||||
"schema": {
|
"schema": {
|
||||||
"default": false,
|
"default": false,
|
||||||
|
"example": false,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"description": "Filter by entity group",
|
||||||
|
"in": "query",
|
||||||
|
"name": "group",
|
||||||
|
"schema": {
|
||||||
|
"enum": [
|
||||||
|
"forecast",
|
||||||
|
"battery",
|
||||||
|
"grid",
|
||||||
|
"pv",
|
||||||
|
"home",
|
||||||
|
"loadpoint",
|
||||||
|
"meter",
|
||||||
|
"consumer"
|
||||||
|
],
|
||||||
|
"example": "battery",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Filter by entity name",
|
||||||
|
"in": "query",
|
||||||
|
"name": "name",
|
||||||
|
"schema": {
|
||||||
|
"example": "db:8",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Filter by entity title",
|
||||||
|
"in": "query",
|
||||||
|
"name": "title",
|
||||||
|
"schema": {
|
||||||
|
"example": "Battery",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"description": "Response format",
|
"description": "Response format",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
|
|
@ -1392,6 +1433,7 @@
|
||||||
"json",
|
"json",
|
||||||
"csv"
|
"csv"
|
||||||
],
|
],
|
||||||
|
"example": "json",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1400,6 +1442,7 @@
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"name": "lang",
|
"name": "lang",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
"example": "de",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -321,8 +321,11 @@ Returns aggregated energy history data. Aggregate granularity defaults to 15 min
|
||||||
| aggregate | string | Aggregation interval. Examples: 15m, 1h, day, month |
|
| aggregate | string | Aggregation interval. Examples: 15m, 1h, day, month |
|
||||||
| format | string | Response format |
|
| format | string | Response format |
|
||||||
| from | string | Start time (RFC3339) |
|
| from | string | Start time (RFC3339) |
|
||||||
|
| group | string | Filter by entity group |
|
||||||
| grouped | boolean | Group results by loadpoint |
|
| grouped | boolean | Group results by loadpoint |
|
||||||
| lang | string | Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header. |
|
| lang | string | Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header. |
|
||||||
|
| name | string | Filter by entity name |
|
||||||
|
| title | string | Filter by entity title |
|
||||||
| to | string | End time (RFC3339) |
|
| to | string | End time (RFC3339) |
|
||||||
|
|
||||||
**Example call:**
|
**Example call:**
|
||||||
|
|
@ -332,8 +335,11 @@ call getEnergyHistory {
|
||||||
"aggregate": "example",
|
"aggregate": "example",
|
||||||
"format": "example",
|
"format": "example",
|
||||||
"from": "example",
|
"from": "example",
|
||||||
|
"group": "example",
|
||||||
"grouped": true,
|
"grouped": true,
|
||||||
"lang": "example",
|
"lang": "example",
|
||||||
|
"name": "example",
|
||||||
|
"title": "example",
|
||||||
"to": "example"
|
"to": "example"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1319,24 +1319,55 @@ paths:
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
example: "2026-07-01T00:00:00Z"
|
||||||
- name: to
|
- name: to
|
||||||
in: query
|
in: query
|
||||||
description: End time (RFC3339)
|
description: End time (RFC3339)
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
example: "2026-07-02T00:00:00Z"
|
||||||
- name: aggregate
|
- name: aggregate
|
||||||
in: query
|
in: query
|
||||||
description: "Aggregation interval. Examples: 15m, 1h, day, month"
|
description: "Aggregation interval. Examples: 15m, 1h, day, month"
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
default: 15m
|
default: 15m
|
||||||
|
example: 1h
|
||||||
- name: grouped
|
- name: grouped
|
||||||
in: query
|
in: query
|
||||||
description: Group results by loadpoint
|
description: Group results by loadpoint
|
||||||
schema:
|
schema:
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
example: false
|
||||||
|
- name: group
|
||||||
|
in: query
|
||||||
|
description: Filter by entity group
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: battery
|
||||||
|
enum:
|
||||||
|
- forecast
|
||||||
|
- battery
|
||||||
|
- grid
|
||||||
|
- pv
|
||||||
|
- home
|
||||||
|
- loadpoint
|
||||||
|
- meter
|
||||||
|
- consumer
|
||||||
|
- name: name
|
||||||
|
in: query
|
||||||
|
description: Filter by entity name
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: db:8
|
||||||
|
- name: title
|
||||||
|
in: query
|
||||||
|
description: Filter by entity title
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: Battery
|
||||||
- name: format
|
- name: format
|
||||||
in: query
|
in: query
|
||||||
description: Response format
|
description: Response format
|
||||||
|
|
@ -1346,11 +1377,13 @@ paths:
|
||||||
- json
|
- json
|
||||||
- csv
|
- csv
|
||||||
default: json
|
default: json
|
||||||
|
example: json
|
||||||
- name: lang
|
- name: lang
|
||||||
in: query
|
in: query
|
||||||
description: Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header.
|
description: Language for CSV column headers (BCP 47, e.g. de, en). Defaults to Accept-Language header.
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
example: de
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Energy history data
|
description: Energy history data
|
||||||
|
|
|
||||||
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