Battery Forecast UI Improvements (#27630)

This commit is contained in:
Michael Geers 2026-02-26 12:01:58 +01:00 • committed by GitHub
parent 9f982f676a
commit ea857537f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 62 additions and 175 deletions

View file

@ -87,13 +87,12 @@ const bat = (power: number, soc: number, forecast: any, devices: any[] = []) =>
forecast,
});
const dev = (title: string, power: number, soc: number, forecast: any) => ({
const dev = (title: string, power: number, soc: number) => ({
title,
power,
soc,
capacity: 10,
controllable: true,
forecast,
});
export const BatteryForecastDischarging = Template.bind({});
@ -120,26 +119,15 @@ BatteryForecastBoth.args = {
battery: bat(-1700, 70, { full: hoursFromNow(2), empty: hoursFromNow(36) }),
} as any;
export const BatteryForecastMultiDischarging = Template.bind({});
BatteryForecastMultiDischarging.args = {
...batteryBase,
gridPower: 500,
homePower: 2500,
battery: bat(2000, 55, { full: null, empty: hoursFromNow(48) }, [
dev("Powerwall", 1200, 60, { full: null, empty: hoursFromNow(48) }),
dev("BYD", 800, 48, { full: null, empty: hoursFromNow(0.25) }),
]),
} as any;
export const BatteryForecastMultiCharging = Template.bind({});
BatteryForecastMultiCharging.args = {
export const BatteryForecastMulti = Template.bind({});
BatteryForecastMulti.args = {
...batteryBase,
pvPower: 8000,
gridPower: -1000,
homePower: 1000,
battery: bat(-6000, 40, { full: hoursFromNow(26), empty: null }, [
dev("Powerwall", -3500, 35, { full: hoursFromNow(26), empty: null }),
dev("BYD", -2500, 47, { full: hoursFromNow(0.7), empty: null }),
dev("Powerwall", -3500, 35),
dev("BYD", -2500, 47),
]),
} as any;

View file

@ -134,9 +134,7 @@
v-if="batteryForecastEmpty"
class="d-flex align-items-center mb-2"
>
<ForecastMessage>{{
batteryForecastEmpty
}}</ForecastMessage>
<ForecastMessage :message="batteryForecastEmpty" />
</div>
<div
v-else-if="batteryForecastFull"
@ -153,7 +151,6 @@
v-for="(b, index) in batteryDevices"
:key="index"
:name="b.title || genericBatteryTitle(index)"
:prediction="devicePrediction(b, false)"
:details="b.soc"
:detailsFmt="batteryFmt"
:power="dischargePower(b.power)"
@ -278,7 +275,7 @@
v-if="batteryForecastFull"
class="d-flex align-items-center mb-2"
>
<ForecastMessage>{{ batteryForecastFull }}</ForecastMessage>
<ForecastMessage :message="batteryForecastFull" />
</div>
<div
v-else-if="batteryForecastEmpty"
@ -312,7 +309,6 @@
v-for="(b, index) in batteryDevices"
:key="index"
:name="b.title || genericBatteryTitle(index)"
:prediction="devicePrediction(b, true)"
:details="b.soc"
:detailsFmt="batteryFmt"
:power="chargePower(b.power)"
@ -588,17 +584,11 @@ export default defineComponent({
consumers() {
return [...this.aux, ...this.ext];
},
batteryForecastFull(): string | null {
if (this.batteryExpanded && this.hasMultipleBatteries) return null;
const timeago = this.fmtForecastRelative(this.battery?.forecast?.full);
if (!timeago) return null;
return this.$t("main.energyflow.batteryForecastFull", { timeago });
batteryForecastFull(): string | undefined {
return this.fmtForecast(this.battery?.forecast, true);
},
batteryForecastEmpty(): string | null {
if (this.batteryExpanded && this.hasMultipleBatteries) return null;
const timeago = this.fmtForecastRelative(this.battery?.forecast?.empty);
if (!timeago) return null;
return this.$t("main.energyflow.batteryForecastEmpty", { timeago });
batteryForecastEmpty(): string | undefined {
return this.fmtForecast(this.battery?.forecast, false);
},
batteryForecastExists(): boolean {
return !!(this.batteryForecastEmpty || this.batteryForecastFull);
@ -704,30 +694,17 @@ export default defineComponent({
genericConsumerTitle(index: number) {
return `${this.$t("config.devices.consumer")} #${index + 1}`;
},
fmtForecastRelative(isoString?: string | null): string | null {
if (!isoString) return null;
const elapsed = new Date(isoString).getTime() - Date.now();
if (elapsed <= 0) return null;
return this.fmtTimeAgo(elapsed, "always");
},
deviceForecastFull(device: { forecast?: { full?: string | null } }): string | null {
return this.fmtForecastRelative(device.forecast?.full);
},
deviceForecastEmpty(device: { forecast?: { empty?: string | null } }): string | null {
return this.fmtForecastRelative(device.forecast?.empty);
},
devicePrediction(
device: { forecast?: { full?: string | null; empty?: string | null } },
fmtForecast(
forecast: { full?: string | null; empty?: string | null } | undefined,
full: boolean
): string | undefined {
const timeago = full
? this.deviceForecastFull(device)
: this.deviceForecastEmpty(device);
if (!timeago) return undefined;
const isoString = full ? forecast?.full : forecast?.empty;
if (!isoString) return undefined;
const time = this.fmtAbsoluteDate(new Date(isoString));
const key = full
? "main.energyflow.batteryForecastFull"
: "main.energyflow.batteryForecastEmpty";
return this.$t(key, { timeago });
return this.$t(key, { time });
},
},
});

View file

@ -1,6 +1,6 @@
<template>
<div>
<div class="mb-2 entry" :class="{ 'evcc-gray': !active }">
<div class="entry" :class="{ 'evcc-gray': !active }">
<div class="mb-2">
<div class="d-flex justify-content-between">
<span class="d-flex flex-nowrap">
<BatteryIcon v-if="isBattery" v-bind="iconProps" />
@ -10,10 +10,7 @@
</span>
<div class="d-flex flex-grow-1 ms-3 align-items-center text-truncate">
<span v-if="!$slots['expanded']" class="text-truncate">
{{ name
}}<ForecastMessage v-if="prediction" class="ms-1" inline>{{
prediction
}}</ForecastMessage>
{{ name }}
</span>
<button
v-else
@ -83,16 +80,14 @@ import formatter, { POWER_UNIT } from "@/mixins/formatter";
import AnimatedNumber from "../Helper/AnimatedNumber.vue";
import VehicleIcon from "../VehicleIcon";
import ForecastIcon from "../MaterialIcon/Forecast.vue";
import ForecastMessage from "./ForecastMessage.vue";
import { defineComponent, type PropType } from "vue";
export default defineComponent({
name: "EnergyflowEntry",
components: { BatteryIcon, AnimatedNumber, VehicleIcon, ForecastIcon, ForecastMessage },
components: { BatteryIcon, AnimatedNumber, VehicleIcon, ForecastIcon },
mixins: [formatter],
props: {
name: { type: String },
prediction: { type: String },
icon: { type: String },
iconProps: { type: Object, default: () => ({}) },
power: { type: Number, default: 0 },
@ -205,6 +200,7 @@ export default defineComponent({
.entry {
transition: color var(--evcc-transition-medium) linear;
}
.power {
min-width: 75px;
}

View file

@ -1,7 +1,6 @@
<template>
<router-link to="/optimize" class="root" @click.stop>
<template v-if="!inline">{{ $t("main.energyflow.forecast") }} </template>
<span class="message"><slot /></span>
{{ $t("main.energyflow.forecast") }} <span class="message">{{ message }}</span>
</router-link>
</template>
@ -11,42 +10,17 @@ import { defineComponent } from "vue";
export default defineComponent({
name: "ForecastMessage",
props: {
inline: {
type: Boolean,
default: false,
},
message: { type: String, required: true },
},
});
</script>
<style scoped>
.root {
background: linear-gradient(
90deg,
var(--evcc-battery) 0%,
var(--evcc-battery) 15%,
var(--evcc-self) 30%,
var(--evcc-battery) 50%,
var(--evcc-battery) 65%,
var(--evcc-self) 80%,
var(--evcc-battery) 100%
);
background-size: 200% 100%;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: forecast-sweep 6s linear infinite;
color: inherit;
text-decoration: none;
}
.message {
text-decoration: underline;
}
@keyframes forecast-sweep {
0% {
background-position: 0% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View file

@ -189,27 +189,10 @@ export default defineComponent({
const today = new Date();
return today.toDateString() === date.toDateString();
},
isTomorrow(date: Date) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
return tomorrow.toDateString() === date.toDateString();
},
weekdayPrefix(date: Date) {
if (this.isToday(date)) {
return "";
}
if (this.isTomorrow(date)) {
try {
const rtf = new Intl.RelativeTimeFormat(this.$i18n?.locale, {
numeric: "auto",
});
const part = rtf.formatToParts(1, "day")[0];
return part?.value || "";
} catch (e) {
console.warn("weekdayPrefix: Intl.RelativeTimeFormat not supported", e);
return "tomorrow";
}
}
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "short",
}).format(date);
@ -332,7 +315,7 @@ export default defineComponent({
const unit = this.energyPriceSubunit(currency) || CURRENCY_SYMBOLS[currency] || currency;
return `${unit}${short ? "" : "/kWh"}`;
},
fmtTimeAgo(elapsed: number, numeric: "auto" | "always" = "auto") {
fmtTimeAgo(elapsed: number) {
const units = {
day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000,
@ -346,7 +329,7 @@ export default defineComponent({
if (Math.abs(elapsed) > units[unitKey] || u == "second") {
try {
const rtf = new Intl.RelativeTimeFormat(this.$i18n?.locale, {
numeric,
numeric: "auto",
});
return rtf.format(Math.round(elapsed / units[unitKey]), unitKey);
} catch (e) {

View file

@ -528,7 +528,6 @@ export interface BatteryMeter extends Meter {
soc: number;
controllable: boolean;
capacity: number; // 0 when not specified
forecast?: BatteryForecast;
}
export interface Vehicle {

View file

@ -666,11 +666,6 @@ func (site *Site) updateBatteryMeters() {
site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(site.battery.Soc))
}
// keep forecast property created by optimizer
for i := range site.battery.Devices {
mm[i].Forecast = site.battery.Devices[i].Forecast
}
site.battery.Devices = mm
site.publish(keys.Battery, site.battery)

View file

@ -255,17 +255,6 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
}
batteries = append(batteries, batResult)
for j, dev := range site.batteryMeters {
if details.BatteryDetails[i].Name == dev.Config().Name {
site.battery.Devices[j].Forecast = &types.BatteryForecast{
Full: batResult.Full,
Empty: batResult.Empty,
}
break
}
}
}
site.publish("evopt-batteries", batteries)

View file

@ -8,17 +8,16 @@ import (
// Measurement is the device measurements struct
type Measurement struct {
Title string `json:"title,omitempty"`
Icon string `json:"icon,omitempty"`
Power float64 `json:"power"`
Energy float64 `json:"energy,omitempty"`
Powers []float64 `json:"powers,omitempty"`
Currents []float64 `json:"currents,omitempty"`
ExcessDCPower float64 `json:"excessdcpower,omitempty"`
Capacity *float64 `json:"capacity,omitempty"`
Soc *float64 `json:"soc,omitempty"`
Controllable *bool `json:"controllable,omitempty"`
Forecast *BatteryForecast `json:"forecast,omitempty"`
Title string `json:"title,omitempty"`
Icon string `json:"icon,omitempty"`
Power float64 `json:"power"`
Energy float64 `json:"energy,omitempty"`
Powers []float64 `json:"powers,omitempty"`
Currents []float64 `json:"currents,omitempty"`
ExcessDCPower float64 `json:"excessdcpower,omitempty"`
Capacity *float64 `json:"capacity,omitempty"`
Soc *float64 `json:"soc,omitempty"`
Controllable *bool `json:"controllable,omitempty"`
}
type BatteryForecast struct {
@ -26,26 +25,6 @@ type BatteryForecast struct {
Empty *time.Time `json:"empty"`
}
// func (f BatteryForecast) MarshalJSON() ([]byte, error) {
// var full, empty int64
// if !f.Full.IsZero() {
// full = int64(time.Until(f.Full).Seconds())
// }
// if !f.Empty.IsZero() {
// empty = int64(time.Until(f.Empty).Seconds())
// }
// return json.Marshal(struct {
// BatteryForecast
// UntilFull int64 `json:"untilFull,omitempty"`
// UntilEmpty int64 `json:"untilEmpty,omitempty"`
// }{
// BatteryForecast: f,
// UntilFull: full,
// UntilEmpty: empty,
// })
// }
var _ api.TitleDescriber = (*Measurement)(nil)
// GetTitle implements api.TitleDescriber interface for InfluxDB tagging

View file

@ -994,8 +994,8 @@
"battery": "Batterie",
"batteryCharge": "Batterie laden",
"batteryDischarge": "Batterie entladen",
"batteryForecastEmpty": "leer {timeago}",
"batteryForecastFull": "voll {timeago}",
"batteryForecastEmpty": "leer {time}",
"batteryForecastFull": "voll {time}",
"batteryGridChargeActive": "Netzladen: aktiv",
"batteryGridChargeLimit": "Netzladen: wenn",
"batteryHold": "Batterie (gesperrt)",

View file

@ -994,8 +994,8 @@
"battery": "Battery",
"batteryCharge": "Battery charging",
"batteryDischarge": "Battery discharging",
"batteryForecastEmpty": "empty {timeago}",
"batteryForecastFull": "full {timeago}",
"batteryForecastEmpty": "empty {time}",
"batteryForecastFull": "full {time}",
"batteryGridChargeActive": "Grid charging: active",
"batteryGridChargeLimit": "Grid charging: when",
"batteryHold": "Battery (locked)",

View file

@ -106,32 +106,32 @@ func (suite *mqttSuite) TestNilInterface() {
func (suite *mqttSuite) TestMeasurement() {
topics := lo.Map([]string{
"title", "icon", "power", "energy", "powers", "currents", "excessDCPower", "capacity", "soc", "controllable", "forecast",
"title", "icon", "power", "energy", "powers", "currents", "excessDCPower", "capacity", "soc", "controllable",
}, func(s string, _ int) string {
return "test/" + s
})
suite.publish("test", false, types.Measurement{})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"", "", "0", "", "", "", "", "", "", "", ""}, suite.payloads, "empty payloads")
suite.Equal([]string{"", "", "0", "", "", "", "", "", "", ""}, suite.payloads, "empty payloads")
suite.publish("test", false, types.Measurement{Energy: 1})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"", "", "0", "1", "", "", "", "", "", "", ""}, suite.payloads, "energy payloads")
suite.Equal([]string{"", "", "0", "1", "", "", "", "", "", ""}, suite.payloads, "energy payloads")
suite.publish("test", false, types.Measurement{Controllable: new(false)})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"", "", "0", "", "", "", "", "", "", "false", ""}, suite.payloads, "controllable payloads")
suite.Equal([]string{"", "", "0", "", "", "", "", "", "", "false"}, suite.payloads, "controllable payloads")
suite.publish("test", false, types.Measurement{Currents: []float64{1, 2, 3}})
suite.Equal(append(topics, "test/currents/1", "test/currents/2", "test/currents/3"), suite.topics, "currents topics")
suite.Equal([]string{"", "", "0", "", "", "3", "", "", "", "", "", "1", "2", "3"}, suite.payloads, "currents payloads")
suite.Equal([]string{"", "", "0", "", "", "3", "", "", "", "", "1", "2", "3"}, suite.payloads, "currents payloads")
}
func (suite *mqttSuite) TestBatteryState() {
topics := lo.Map([]string{
"power", "energy", "capacity", "soc",
"devices", "devices/1/title", "devices/1/icon", "devices/1/power", "devices/1/energy", "devices/1/powers", "devices/1/currents", "devices/1/excessDCPower", "devices/1/capacity", "devices/1/soc", "devices/1/controllable", "devices/1/forecast",
"devices", "devices/1/title", "devices/1/icon", "devices/1/power", "devices/1/energy", "devices/1/powers", "devices/1/currents", "devices/1/excessDCPower", "devices/1/capacity", "devices/1/soc", "devices/1/controllable",
"forecast",
}, func(s string, _ int) string {
return "test/" + s
@ -147,5 +147,5 @@ func (suite *mqttSuite) TestBatteryState() {
})
suite.Equal(topics, suite.topics, "topics")
suite.Equal([]string{"2", "", "", "20", "1", "", "", "1", "", "", "", "", "", "10", "", "", ""}, suite.payloads, "payloads")
suite.Equal([]string{"2", "", "", "20", "1", "", "", "1", "", "", "", "", "", "10", "", ""}, suite.payloads, "payloads")
}

View file

@ -18,10 +18,10 @@ test.afterEach(async () => {
await stop();
});
function getWeekday(offset = 1) {
function getWeekday(offset = 1, style: "long" | "short" = "long") {
const date = new Date();
date.setDate(date.getDate() + offset);
return date.toLocaleDateString("en-US", { weekday: "long" });
return date.toLocaleDateString("en-US", { weekday: style });
}
async function setAndVerifyPlan(
@ -85,16 +85,19 @@ test.describe("basic functionality", async () => {
await page.getByTestId("static-plan-active").click();
await page.getByRole("button", { name: "Close" }).click();
const tomorrowShort = getWeekday(1, "short");
await expect(lp1.getByTestId("plan-marker")).toBeVisible();
await expect(lp1.getByTestId("charging-plan").getByRole("button")).toHaveText(
["tomorrow 09:30", "80%"].join("")
[`${tomorrowShort} 09:30`, "80%"].join("")
);
await expect(lp1.getByTestId("vehicle-status-charger")).toHaveText("Connected.");
await expect(lp1.getByTestId("vehicle-status-planstart")).toHaveText(/tomorrow .*/);
await expect(lp1.getByTestId("vehicle-status-planstart")).toHaveText(
new RegExp(`${tomorrowShort} .*`)
);
await expect(lp1.getByTestId("plan-marker")).toBeVisible();
await expect(lp1.getByTestId("charging-plan").getByRole("button")).toHaveText(
["tomorrow 09:30", "80%"].join("")
[`${tomorrowShort} 09:30`, "80%"].join("")
);
await lp1.getByTestId("charging-plan").getByRole("button").click();
await expect(page.getByTestId("static-plan-soc")).toHaveValue("80");
@ -633,6 +636,7 @@ test.describe("repeating", async () => {
await page.goto("/");
const tomorrow = getWeekday(1);
const tomorrowShort = getWeekday(1, "short");
let lp1 = await page.getByTestId("loadpoint").first();
await lp1
@ -658,7 +662,7 @@ test.describe("repeating", async () => {
await expect(modal.getByTestId("target-text")).toContainText("09:20");
await modal.getByRole("button", { name: "Close" }).click();
await expect(
lp1.getByTestId("charging-plan").getByRole("button", { name: "tomorrow 09:20" })
lp1.getByTestId("charging-plan").getByRole("button", { name: `${tomorrowShort} 09:20` })
).toBeVisible();
await restart(CONFIG);
@ -670,7 +674,10 @@ test.describe("repeating", async () => {
.locator("select")
.selectOption("Vehicle with SoC with Capacity");
await lp1.getByTestId("charging-plan").getByRole("button", { name: "tomorrow 09:20" }).click();
await lp1
.getByTestId("charging-plan")
.getByRole("button", { name: `${tomorrowShort} 09:20` })
.click();
await expect(modal.getByTestId("plan-entry")).toHaveCount(2);
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("target-text")).toContainText("09:20");