Optimizer: derive per-device action suggestions from result (#30834)
This commit is contained in:
parent
4dc9775ea3
commit
ab3220c475
22 changed files with 428 additions and 72 deletions
|
|
@ -47,8 +47,7 @@ import Card from "../Helper/Card.vue";
|
|||
import LabelAndValue from "../Helper/LabelAndValue.vue";
|
||||
import SocGauge from "./SocGauge.vue";
|
||||
import OptimizerInfo from "./OptimizerInfo.vue";
|
||||
import { BATTERY_MODE, type BatteryForecast } from "@/types/evcc";
|
||||
import type { BatterySuggestion } from "./types";
|
||||
import { BATTERY_MODE, type BatteryForecast, type BatterySuggestion } from "@/types/evcc";
|
||||
|
||||
export default defineComponent({
|
||||
name: "BatteryStatusCard",
|
||||
|
|
@ -131,6 +130,11 @@ export default defineComponent({
|
|||
align-items: start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.optimizer {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
}
|
||||
.values :deep(.root) {
|
||||
margin-bottom: 0;
|
||||
min-width: 0;
|
||||
|
|
|
|||
|
|
@ -42,12 +42,20 @@ export const Multiple = Template.bind({});
|
|||
Multiple.args = {
|
||||
battery: {
|
||||
...battery([
|
||||
device({ title: "Sungrow", soc: 76, power: 800, capacity: 13.5, controllable: true }),
|
||||
device({
|
||||
title: "Sungrow",
|
||||
soc: 76,
|
||||
power: 800,
|
||||
capacity: 13.5,
|
||||
controllable: true,
|
||||
suggestion: { action: "hold" },
|
||||
}),
|
||||
device({
|
||||
title: "Anker SOLIX X1 Home Energy Storage System Extended Pro Max",
|
||||
soc: 40,
|
||||
power: -1200,
|
||||
capacity: 7.5,
|
||||
suggestion: { action: "normal" },
|
||||
}),
|
||||
]),
|
||||
soc: 63,
|
||||
|
|
@ -57,11 +65,21 @@ Multiple.args = {
|
|||
// 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" },
|
||||
battery: battery(
|
||||
[
|
||||
device({
|
||||
title: "Battery",
|
||||
soc: 62,
|
||||
power: 900,
|
||||
capacity: 10,
|
||||
suggestion: { action: "charge" },
|
||||
}),
|
||||
],
|
||||
{
|
||||
highest: { soc: 100, time: "2026-07-01T16:30:00+02:00" },
|
||||
lowest: { soc: 12, time: "2026-07-02T06:00:00+02:00" },
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
// forecast is a site-aggregate value, so it lands on the combined card
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
<div v-if="showSplit" class="cards cards--split gap-4">
|
||||
<BatteryStatusCard v-bind="cards[0]" :battery-mode="batteryMode" />
|
||||
<Card data-testid="battery-optimizer-card">
|
||||
<OptimizerInfo :suggestion="suggestion" :forecast="batteryForecast" />
|
||||
<OptimizerInfo
|
||||
:suggestion="devices[0]?.suggestion ?? null"
|
||||
:forecast="batteryForecast"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
<div v-else class="cards gap-4">
|
||||
|
|
@ -22,7 +25,7 @@ import type { BATTERY_MODE, Battery, BatteryForecast, BatteryMeter } from "@/typ
|
|||
import Card from "../Helper/Card.vue";
|
||||
import BatteryStatusCard from "./BatteryStatusCard.vue";
|
||||
import OptimizerInfo from "./OptimizerInfo.vue";
|
||||
import type { BatterySuggestion, BatteryStatusCardModel } from "./types";
|
||||
import type { BatteryStatusCardModel } from "./types";
|
||||
|
||||
// One battery + forecast splits into battery and optimizer cards; several batteries prepend a
|
||||
// combined aggregate. Stateless, derived from the battery object.
|
||||
|
|
@ -32,7 +35,6 @@ export default defineComponent({
|
|||
props: {
|
||||
battery: { type: Object as PropType<Battery> },
|
||||
batteryMode: String as PropType<BATTERY_MODE>,
|
||||
suggestion: { type: Object as PropType<BatterySuggestion | null>, default: null },
|
||||
},
|
||||
computed: {
|
||||
devices(): BatteryMeter[] {
|
||||
|
|
@ -43,7 +45,9 @@ export default defineComponent({
|
|||
return fc?.highest || fc?.lowest ? fc : null;
|
||||
},
|
||||
showSplit(): boolean {
|
||||
return this.devices.length === 1 && !!this.batteryForecast;
|
||||
return (
|
||||
this.devices.length === 1 && !!(this.batteryForecast || this.devices[0]?.suggestion)
|
||||
);
|
||||
},
|
||||
cards(): BatteryStatusCardModel[] {
|
||||
const multiple = this.devices.length > 1;
|
||||
|
|
@ -55,7 +59,8 @@ export default defineComponent({
|
|||
capacity: d.capacity || 0,
|
||||
color: batteryColor(i),
|
||||
controllable: d.controllable,
|
||||
suggestion: null, // per-battery suggestion not wired yet
|
||||
// single battery shows the suggestion on the dedicated optimizer card
|
||||
suggestion: multiple ? (d.suggestion ?? null) : null,
|
||||
forecast: null, // aggregate forecast lives on the combined / dedicated card
|
||||
}));
|
||||
// combined uses the site aggregate soc/power, not per-device sums; the site-wide
|
||||
|
|
@ -69,7 +74,7 @@ export default defineComponent({
|
|||
capacity: this.devices.reduce((s, d) => s + (d.capacity || 0), 0),
|
||||
color: batteryColor(0),
|
||||
controllable: true,
|
||||
suggestion: this.suggestion,
|
||||
suggestion: null, // no aggregate; device cards show their own
|
||||
forecast: this.batteryForecast,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,13 +31,12 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import formatter from "@/mixins/formatter";
|
||||
import type { BatteryForecast, BatteryForecastPoint } from "@/types/evcc";
|
||||
import type { BatteryForecast, BatteryForecastPoint, BatterySuggestion } 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.
|
||||
// Optimizer rows: suggestion plus the battery high/low soc forecast.
|
||||
// Simple bold-label / muted-value layout.
|
||||
export default defineComponent({
|
||||
name: "OptimizerInfo",
|
||||
components: { BatteryIcon, Optimizer },
|
||||
|
|
@ -48,9 +47,9 @@ export default defineComponent({
|
|||
},
|
||||
computed: {
|
||||
recommendation(): string | null {
|
||||
const s = this.suggestion;
|
||||
if (!s) return null;
|
||||
return this.$t(`battery.optimizer.action.${s.action}`);
|
||||
const action = this.suggestion?.action;
|
||||
if (!action) return null;
|
||||
return this.$t(`battery.optimizer.action.${action}`);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Shared view-model types for the experimental battery page.
|
||||
|
||||
import type { BatteryForecast } from "@/types/evcc";
|
||||
import type { BatteryForecast, BatterySuggestion } from "@/types/evcc";
|
||||
|
||||
export interface SocPoint {
|
||||
t: number; // epoch ms
|
||||
|
|
@ -16,10 +16,6 @@ export interface BatterySeries {
|
|||
forecast: SocPoint[]; // ascending t, >= now (may be empty)
|
||||
}
|
||||
|
||||
export interface BatterySuggestion {
|
||||
action: "normal" | "hold" | "charge" | "holdcharge";
|
||||
}
|
||||
|
||||
export interface BatteryStatusCardModel {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ import type {
|
|||
SMART_COST_TYPE,
|
||||
BATTERY_MODE,
|
||||
LoadpointUi,
|
||||
LoadpointSuggestion,
|
||||
} from "@/types/evcc";
|
||||
import type { PlanStrategy } from "@/components/ChargingPlans/types";
|
||||
|
||||
|
|
@ -229,6 +230,7 @@ export default defineComponent({
|
|||
smartFeedInPriorityAvailable: Boolean,
|
||||
smartFeedInPriorityActive: Boolean,
|
||||
smartFeedInPriorityNextStart: String as PropType<string | null>,
|
||||
suggestion: Object as PropType<LoadpointSuggestion | null>,
|
||||
tariffGrid: Number,
|
||||
tariffFeedIn: Number,
|
||||
tariffCo2: Number,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<svg :style="svgStyle" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
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"
|
||||
d="M21.375 8.625L20 8l1.375-.625L22 6l.625 1.375L24 8l-1.375.625L22 10l-.625-1.375ZM18.05 3.95L16 3l2.05-.95L19 0l.95 2.05L22 3l-2.05.95L19 6l-.95-2.05ZM9 22q-.825 0-1.413-.588T7 20h4q0 .825-.588 1.413T9 22Zm-3-3q-.425 0-.713-.288T5 18q0-.425.288-.713T6 17h6q.425 0 .713.288T13 18q0 .425-.288.713T12 19H6Zm-.75-3q-1.725-1.025-2.738-2.75T1.5 9.5q0-3.125 2.188-5.313T9 2q3.125 0 5.313 2.188T16.5 9.5q0 2.025-1.012 3.75T12.75 16h-7.5Zm.6-2h6.3q1.125-.8 1.738-1.975T14.5 9.5q0-2.3-1.6-3.9T9 4Q6.7 4 5.1 5.6T3.5 9.5q0 1.35.612 2.525T5.85 14ZM9 14Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
|
|
|||
19
assets/js/components/MaterialIcon/OptimizerCharge.vue
Normal file
19
assets/js/components/MaterialIcon/OptimizerCharge.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<template>
|
||||
<svg :style="svgStyle" viewBox="0 0 134 134">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M118.75,47.917l-7.639,-3.472l7.639,-3.472l3.472,-7.639l3.472,7.639l7.639,3.472l-7.639,3.472l-3.472,7.639l-3.472,-7.639Zm-18.472,-25.972l-11.389,-5.278l11.389,-5.278l5.278,-11.389l5.278,11.389l11.389,5.278l-11.389,5.278l-5.278,11.389l-5.278,-11.389Zm-50.278,100.278c-3.056,0 -5.672,-1.089 -7.85,-3.267c-2.178,-2.178 -3.265,-4.793 -3.261,-7.844l22.222,0c0,3.056 -1.089,5.672 -3.267,7.85c-2.178,2.178 -4.793,3.265 -7.844,3.261Zm-16.667,-16.667c-1.574,0 -2.894,-0.533 -3.961,-1.6c-1.067,-1.067 -1.598,-2.385 -1.594,-3.956c0,-1.574 0.533,-2.894 1.6,-3.961c1.067,-1.067 2.385,-1.598 3.956,-1.594l33.333,0c1.574,0 2.894,0.533 3.961,1.6c1.067,1.067 1.598,2.385 1.594,3.956c0,1.574 -0.533,2.894 -1.6,3.961c-1.067,1.067 -2.385,1.598 -3.956,1.594l-33.333,0Zm-4.167,-16.667c-6.389,-3.796 -11.459,-8.889 -15.211,-15.278c-3.752,-6.389 -5.626,-13.333 -5.622,-20.833c0,-11.574 4.052,-21.413 12.156,-29.517c8.104,-8.104 17.941,-12.154 29.511,-12.15c11.574,0 21.413,4.052 29.517,12.156c8.104,8.104 12.154,17.941 12.15,29.511c0,7.5 -1.874,14.444 -5.622,20.833c-3.748,6.389 -8.819,11.481 -15.211,15.278l-41.667,0Zm3.333,-11.111l35,0c4.167,-2.963 7.385,-6.62 9.656,-10.972c2.27,-4.352 3.404,-9.028 3.4,-14.028c0,-8.519 -2.963,-15.741 -8.889,-21.667c-5.926,-5.926 -13.148,-8.889 -21.667,-8.889c-8.519,0 -15.741,2.963 -21.667,8.889c-5.926,5.926 -8.889,13.148 -8.889,21.667c0,5 1.133,9.676 3.4,14.028c2.267,4.352 5.485,8.009 9.656,10.972Zm74.236,26.319l-11.409,0c-1.472,0 -2.544,-0.643 -3.217,-1.928c-0.672,-1.286 -0.581,-2.544 0.272,-3.776l18.402,-26.314c0.613,-0.736 1.32,-0.981 2.12,-0.736c0.8,0.245 1.197,0.859 1.192,1.84l0,16.193l11.409,0c1.472,0 2.547,0.645 3.224,1.936c0.677,1.291 0.584,2.547 -0.28,3.769l-18.402,26.314c-0.613,0.736 -1.32,0.981 -2.12,0.736c-0.8,-0.245 -1.197,-0.859 -1.192,-1.84l0,-16.193Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import icon from "@/mixins/icon";
|
||||
|
||||
export default defineComponent({
|
||||
name: "OptimizerCharge",
|
||||
mixins: [icon],
|
||||
});
|
||||
</script>
|
||||
18
assets/js/components/MaterialIcon/OptimizerPause.vue
Normal file
18
assets/js/components/MaterialIcon/OptimizerPause.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<template>
|
||||
<svg :style="svgStyle" viewBox="0 0 134 134">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M118.75,47.917l-7.639,-3.472l7.639,-3.472l3.472,-7.639l3.472,7.639l7.639,3.472l-7.639,3.472l-3.472,7.639l-3.472,-7.639Zm-18.472,-25.972l-11.389,-5.278l11.389,-5.278l5.278,-11.389l5.278,11.389l11.389,5.278l-11.389,5.278l-5.278,11.389l-5.278,-11.389Zm-50.278,100.278c-3.056,0 -5.672,-1.089 -7.85,-3.267c-2.178,-2.178 -3.265,-4.793 -3.261,-7.844l22.222,0c0,3.056 -1.089,5.672 -3.267,7.85c-2.178,2.178 -4.793,3.265 -7.844,3.261Zm-16.667,-16.667c-1.574,0 -2.894,-0.533 -3.961,-1.6c-1.067,-1.067 -1.598,-2.385 -1.594,-3.956c0,-1.574 0.533,-2.894 1.6,-3.961c1.067,-1.067 2.385,-1.598 3.956,-1.594l33.333,0c1.574,0 2.894,0.533 3.961,1.6c1.067,1.067 1.598,2.385 1.594,3.956c0,1.574 -0.533,2.894 -1.6,3.961c-1.067,1.067 -2.385,1.598 -3.956,1.594l-33.333,0Zm-4.167,-16.667c-6.389,-3.796 -11.459,-8.889 -15.211,-15.278c-3.752,-6.389 -5.626,-13.333 -5.622,-20.833c0,-11.574 4.052,-21.413 12.156,-29.517c8.104,-8.104 17.941,-12.154 29.511,-12.15c11.574,0 21.413,4.052 29.517,12.156c8.104,8.104 12.154,17.941 12.15,29.511c0,7.5 -1.874,14.444 -5.622,20.833c-3.748,6.389 -8.819,11.481 -15.211,15.278l-41.667,0Zm3.333,-11.111l35,0c4.167,-2.963 7.385,-6.62 9.656,-10.972c2.27,-4.352 3.404,-9.028 3.4,-14.028c0,-8.519 -2.963,-15.741 -8.889,-21.667c-5.926,-5.926 -13.148,-8.889 -21.667,-8.889c-8.519,0 -15.741,2.963 -21.667,8.889c-5.926,5.926 -8.889,13.148 -8.889,21.667c0,5 1.133,9.676 3.4,14.028c2.267,4.352 5.485,8.009 9.656,10.972Zm64.722,44.444c-1.574,0 -2.893,-0.533 -3.956,-1.6c-1.063,-1.067 -1.596,-2.385 -1.6,-3.956l0,-27.778c0,-1.574 0.533,-2.893 1.6,-3.956c1.067,-1.063 2.385,-1.596 3.956,-1.6c1.57,-0.004 2.891,0.53 3.961,1.6c1.07,1.07 1.602,2.389 1.594,3.956l0,27.778c0,1.574 -0.533,2.894 -1.6,3.961c-1.067,1.067 -2.385,1.598 -3.956,1.594Zm22.222,0c-1.574,0 -2.893,-0.533 -3.956,-1.6c-1.063,-1.067 -1.596,-2.385 -1.6,-3.956l0,-27.778c0,-1.574 0.533,-2.893 1.6,-3.956c1.067,-1.063 2.385,-1.596 3.956,-1.6c1.57,-0.004 2.891,0.53 3.961,1.6c1.07,1.07 1.602,2.389 1.594,3.956l0,27.778c0,1.574 -0.533,2.894 -1.6,3.961c-1.067,1.067 -2.385,1.598 -3.956,1.594Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import icon from "@/mixins/icon";
|
||||
|
||||
export default defineComponent({
|
||||
name: "OptimizerPause",
|
||||
mixins: [icon],
|
||||
});
|
||||
</script>
|
||||
|
|
@ -23,6 +23,7 @@ const allEntries = {
|
|||
smartcost: false,
|
||||
planactive: false,
|
||||
planstart: false,
|
||||
suggestion: false,
|
||||
};
|
||||
|
||||
const expectEntries = (props: InstanceType<typeof Status>["$props"], entries: object) => {
|
||||
|
|
@ -80,6 +81,37 @@ describe("min charge", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("optimizer suggestion", () => {
|
||||
test("charge suggested while not charging", () => {
|
||||
expectEntries(
|
||||
{ connected: true, suggestion: { action: "charge" } },
|
||||
{ charger: "Connected.", suggestion: true }
|
||||
);
|
||||
});
|
||||
test("stop suggested while charging", () => {
|
||||
expectEntries(
|
||||
{ connected: true, enabled: true, charging: true, suggestion: { action: "stop" } },
|
||||
{ charger: "Charging…", suggestion: true }
|
||||
);
|
||||
});
|
||||
test("hidden when suggestion matches state", () => {
|
||||
expectEntries(
|
||||
{ connected: true, enabled: true, charging: true, suggestion: { action: "charge" } },
|
||||
{ charger: "Charging…", suggestion: false }
|
||||
);
|
||||
expectEntries(
|
||||
{ connected: true, suggestion: { action: "stop" } },
|
||||
{ charger: "Connected.", suggestion: false }
|
||||
);
|
||||
});
|
||||
test("hidden when disconnected", () => {
|
||||
expectEntries(
|
||||
{ connected: false, suggestion: { action: "charge" } },
|
||||
{ charger: "Disconnected.", suggestion: false }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plan", () => {
|
||||
const effectivePlanTime = "2020-03-16T06:00:00Z";
|
||||
const planProjectedStart = "2020-03-16T02:00:00Z";
|
||||
|
|
|
|||
|
|
@ -61,10 +61,18 @@ import { DEFAULT_LOCALE } from "@/i18n.ts";
|
|||
import formatter from "@/mixins/formatter";
|
||||
import minuteTicker from "@/mixins/minuteTicker";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import { SMART_COST_TYPE, type CURRENCY, type VehicleStatus, type Timeout } from "@/types/evcc";
|
||||
import {
|
||||
SMART_COST_TYPE,
|
||||
type CURRENCY,
|
||||
type LoadpointSuggestion,
|
||||
type VehicleStatus,
|
||||
type Timeout,
|
||||
} from "@/types/evcc";
|
||||
|
||||
import ClimaterIcon from "../MaterialIcon/Climater.vue";
|
||||
import DynamicPriceIcon from "../MaterialIcon/DynamicPrice.vue";
|
||||
import OptimizerChargeIcon from "../MaterialIcon/OptimizerCharge.vue";
|
||||
import OptimizerPauseIcon from "../MaterialIcon/OptimizerPause.vue";
|
||||
import PlanEndIcon from "../MaterialIcon/PlanEnd.vue";
|
||||
import PlanStartIcon from "../MaterialIcon/PlanStart.vue";
|
||||
import ReconnectIcon from "../MaterialIcon/Reconnect.vue";
|
||||
|
|
@ -125,6 +133,7 @@ export default defineComponent({
|
|||
smartFeedInPriorityDisabled: Boolean,
|
||||
smartFeedInPriorityLimit: { type: Number, default: null },
|
||||
smartFeedInPriorityNextStart: String,
|
||||
suggestion: Object as PropType<LoadpointSuggestion | null>,
|
||||
tariffCo2: { type: Number, default: 0 },
|
||||
tariffGrid: { type: Number, default: 0 },
|
||||
tariffFeedIn: { type: Number, default: 0 },
|
||||
|
|
@ -166,6 +175,12 @@ export default defineComponent({
|
|||
vehicleLimitWarning() {
|
||||
return this.effectivePlanSoc > this.vehicleLimitSoc;
|
||||
},
|
||||
showSuggestions(): boolean {
|
||||
if (!this.connected) return false;
|
||||
// only advise when the suggestion differs from the current charging state
|
||||
const action = this.suggestion?.action;
|
||||
return (action === "charge" && !this.enabled) || (action === "stop" && this.enabled);
|
||||
},
|
||||
smartCostPrice() {
|
||||
return this.smartCostType !== SMART_COST_TYPE.CO2;
|
||||
},
|
||||
|
|
@ -349,6 +364,20 @@ export default defineComponent({
|
|||
clickable: true,
|
||||
clickHandler: () => this.openLoadpointSettings(),
|
||||
},
|
||||
{
|
||||
id: "suggestion",
|
||||
visible: this.showSuggestions,
|
||||
tooltipContent: this.translateStatus(
|
||||
this.suggestion?.action === "charge"
|
||||
? "suggestionStartTooltip"
|
||||
: "suggestionStopTooltip"
|
||||
),
|
||||
iconComponent:
|
||||
this.suggestion?.action === "charge"
|
||||
? OptimizerChargeIcon
|
||||
: OptimizerPauseIcon,
|
||||
testId: "vehicle-status-suggestion",
|
||||
},
|
||||
{
|
||||
id: "planActive",
|
||||
visible: this.planProjectedEnd && this.planActive && !this.chargingPlanDisabled,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@ import { CHARGE_MODE } from "@/types/evcc";
|
|||
import Vehicle from "./Vehicle.vue";
|
||||
import type { Meta, StoryFn } from "@storybook/vue3";
|
||||
|
||||
function getFutureTime(hours: number, minutes: number) {
|
||||
const now = new Date();
|
||||
now.setHours(now.getHours() + hours);
|
||||
now.setMinutes(now.getMinutes() + minutes);
|
||||
return now.toISOString();
|
||||
}
|
||||
|
||||
const baseState = {
|
||||
vehicle: {
|
||||
title: "Mein Auto",
|
||||
|
|
@ -56,12 +63,15 @@ export default {
|
|||
},
|
||||
} as Meta<typeof Vehicle>;
|
||||
|
||||
// white box background as in the app's loadpoint card
|
||||
const cardStyle = "padding: 1rem; background: var(--evcc-box); border-radius: 0.5rem;";
|
||||
|
||||
const Template: StoryFn<typeof Vehicle> = (args) => ({
|
||||
components: { Vehicle },
|
||||
setup() {
|
||||
return { args };
|
||||
return { args, cardStyle };
|
||||
},
|
||||
template: '<Vehicle v-bind="args" />',
|
||||
template: '<div :style="cardStyle"><Vehicle v-bind="args" /></div>',
|
||||
});
|
||||
|
||||
export const Disconnected = Template.bind({});
|
||||
|
|
@ -175,6 +185,35 @@ SmartChargeCostLimitActive.args = {
|
|||
mode: CHARGE_MODE.PV,
|
||||
};
|
||||
|
||||
export const SuggestionCharge = Template.bind({});
|
||||
SuggestionCharge.args = {
|
||||
...baseState,
|
||||
suggestion: { action: "charge" },
|
||||
};
|
||||
|
||||
export const SuggestionPause = Template.bind({});
|
||||
SuggestionPause.args = {
|
||||
...baseState,
|
||||
enabled: true,
|
||||
charging: true,
|
||||
suggestion: { action: "stop" },
|
||||
};
|
||||
|
||||
export const SuggestionCombination = Template.bind({});
|
||||
SuggestionCombination.args = {
|
||||
...baseState,
|
||||
enabled: true,
|
||||
charging: true,
|
||||
suggestion: { action: "stop" },
|
||||
currency: "EUR",
|
||||
tariffGrid: 0.32,
|
||||
smartCostLimit: 0.12,
|
||||
smartCostType: "price",
|
||||
planActive: true,
|
||||
effectivePlanTime: getFutureTime(6, 54),
|
||||
planProjectedEnd: getFutureTime(5, 43),
|
||||
};
|
||||
|
||||
export const PvEnableTimer = Template.bind({});
|
||||
PvEnableTimer.args = {
|
||||
...baseState,
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ import {
|
|||
type VehicleStatus,
|
||||
type Vehicle,
|
||||
type LoadpointUi,
|
||||
type LoadpointSuggestion,
|
||||
} from "@/types/evcc";
|
||||
import type { PlanStrategy } from "@/components/ChargingPlans/types";
|
||||
import BatteryBoostButton from "../Loadpoints/BatteryBoostButton.vue";
|
||||
|
|
@ -162,6 +163,7 @@ export default defineComponent({
|
|||
smartFeedInPriorityNextStart: String,
|
||||
smartFeedInPriorityLimit: Number,
|
||||
socBasedCharging: Boolean,
|
||||
suggestion: Object as PropType<LoadpointSuggestion | null>,
|
||||
socBasedPlanning: Boolean,
|
||||
tariffCo2: Number,
|
||||
tariffGrid: Number,
|
||||
|
|
|
|||
|
|
@ -367,6 +367,7 @@ export interface Loadpoint {
|
|||
smartFeedInPriorityActive: boolean;
|
||||
smartFeedInPriorityLimit: number | null;
|
||||
smartFeedInPriorityNextStart: string | null;
|
||||
suggestion?: LoadpointSuggestion | null;
|
||||
title: string;
|
||||
vehicleClimaterActive: boolean | null;
|
||||
vehicleDetectionActive: boolean;
|
||||
|
|
@ -669,10 +670,23 @@ export interface Battery {
|
|||
forecast?: BatteryForecast;
|
||||
}
|
||||
|
||||
export interface BatterySuggestion {
|
||||
action: "normal" | "hold" | "charge" | "holdcharge";
|
||||
charge?: number; // recommended charge power, W
|
||||
discharge?: number; // recommended discharge power, W
|
||||
}
|
||||
|
||||
export interface LoadpointSuggestion {
|
||||
action: "charge" | "stop";
|
||||
charge?: number; // recommended charge power, W
|
||||
discharge?: number; // recommended discharge power, W
|
||||
}
|
||||
|
||||
export interface BatteryMeter extends Meter {
|
||||
soc: number;
|
||||
controllable: boolean;
|
||||
capacity: number; // 0 when not specified
|
||||
suggestion?: BatterySuggestion;
|
||||
}
|
||||
|
||||
export interface Vehicle {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ const (
|
|||
// loadpoint setpoint
|
||||
OfferedCurrent = "offeredCurrent" // offered current
|
||||
|
||||
// optimizer
|
||||
Suggestion = "suggestion" // optimizer's advisory suggestion for the current slot
|
||||
|
||||
// smart charging
|
||||
SmartCostActive = "smartCostActive" // smart cost active
|
||||
SmartCostLimit = "smartCostLimit" // smart cost limit, fast charge when costs are below
|
||||
|
|
|
|||
36
core/site.go
36
core/site.go
|
|
@ -93,14 +93,15 @@ type Site struct {
|
|||
collectors map[string]*metrics.Collector // keyed by meter ref
|
||||
|
||||
// cached state
|
||||
gridPower float64 // Grid power
|
||||
pvPower float64 // PV power
|
||||
excessDCPower float64 // PV excess DC charge power (hybrid only)
|
||||
auxPower float64 // Aux power
|
||||
battery types.BatteryState // Battery cached and published state
|
||||
batteryMode api.BatteryMode // Battery mode (runtime only, not persisted)
|
||||
batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted)
|
||||
batteryModeExternalTimer time.Time // Battery mode timer for external control
|
||||
gridPower float64 // Grid power
|
||||
pvPower float64 // PV power
|
||||
excessDCPower float64 // PV excess DC charge power (hybrid only)
|
||||
auxPower float64 // Aux power
|
||||
battery types.BatteryState // Battery cached and published state
|
||||
batteryMode api.BatteryMode // Battery mode (runtime only, not persisted)
|
||||
batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted)
|
||||
batteryModeExternalTimer time.Time // Battery mode timer for external control
|
||||
batterySuggestions map[string]types.Suggestion // Optimizer suggestions by battery meter name
|
||||
}
|
||||
|
||||
// MetersConfig contains the site's meter configuration
|
||||
|
|
@ -517,6 +518,16 @@ func (site *Site) Publish(key string, val any) {
|
|||
site.publish(key, val)
|
||||
}
|
||||
|
||||
// publishLoadpoint sends a value into the given loadpoint's state
|
||||
func (site *Site) publishLoadpoint(id int, key string, val any) {
|
||||
// test helper
|
||||
if site.valueChan == nil {
|
||||
return
|
||||
}
|
||||
|
||||
site.valueChan <- util.Param{Loadpoint: &id, Key: key, Val: val}
|
||||
}
|
||||
|
||||
// clearPlanLocks clears locked plan goals for all loadpoints
|
||||
func (site *Site) clearPlanLocks() {
|
||||
for _, lp := range site.Loadpoints() {
|
||||
|
|
@ -725,6 +736,15 @@ func (site *Site) updateBatteryMeters() {
|
|||
}
|
||||
}
|
||||
|
||||
site.publishBattery()
|
||||
}
|
||||
|
||||
// publishBattery applies the optimizer suggestions and publishes the battery state
|
||||
func (site *Site) publishBattery() {
|
||||
for i, d := range site.battery.Devices {
|
||||
site.battery.Devices[i].Suggestion = site.batterySuggestion(d.Name)
|
||||
}
|
||||
|
||||
site.publish(keys.Battery, site.battery)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ type batteryDetail struct {
|
|||
Title string `json:"title,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Capacity float64 `json:"capacity,omitempty"`
|
||||
|
||||
loadpoint *int // originating loadpoint id for loadpoint/vehicle entries
|
||||
}
|
||||
|
||||
type batteryResult struct {
|
||||
|
|
@ -103,6 +105,77 @@ type batteryResult struct {
|
|||
Empty time.Time `json:"empty,omitzero"`
|
||||
}
|
||||
|
||||
// suggestionThreshold ignores numerical noise around zero power (W)
|
||||
const suggestionThreshold = 50
|
||||
|
||||
// currentSlotSuggestion maps the optimizer's first-slot corner result onto an advisory action.
|
||||
// Because the optimization is linear, the first slot is at an operating-range extreme, so it
|
||||
// maps cleanly onto the discrete battery mode / loadpoint intent that control would later apply.
|
||||
// An idle battery is interpreted from the grid flow: importing means discharge is withheld
|
||||
// (hold), exporting means charging is withheld (holdcharge).
|
||||
func currentSlotSuggestion(detail batteryDetail, res optimizer.BatteryResult, gridImporting, gridExporting bool, slotHours float64) types.Suggestion {
|
||||
if slotHours <= 0 || len(res.ChargingPower) == 0 || len(res.DischargingPower) == 0 {
|
||||
return types.Suggestion{}
|
||||
}
|
||||
|
||||
charge := float64(res.ChargingPower[0]) / slotHours
|
||||
discharge := float64(res.DischargingPower[0]) / slotHours
|
||||
|
||||
s := types.Suggestion{Charge: charge, Discharge: discharge}
|
||||
|
||||
if detail.Type == batteryTypeBattery {
|
||||
idle := charge <= suggestionThreshold && discharge <= suggestionThreshold
|
||||
switch {
|
||||
case charge > suggestionThreshold && gridImporting:
|
||||
// charging while importing means grid charging
|
||||
s.Action = api.BatteryCharge.String()
|
||||
case idle && gridImporting:
|
||||
// idle while importing: discharge is deliberately withheld
|
||||
s.Action = api.BatteryHold.String()
|
||||
case idle && gridExporting:
|
||||
// idle while exporting: surplus is exported instead of charged
|
||||
s.Action = api.BatteryHoldCharge.String()
|
||||
default:
|
||||
s.Action = api.BatteryNormal.String()
|
||||
}
|
||||
} else if charge > suggestionThreshold {
|
||||
s.Action = "charge"
|
||||
} else {
|
||||
s.Action = "stop"
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// setBatterySuggestions replaces the suggestions applied on each battery publish
|
||||
func (site *Site) setBatterySuggestions(suggestions map[string]types.Suggestion) {
|
||||
site.Lock()
|
||||
defer site.Unlock()
|
||||
|
||||
site.batterySuggestions = suggestions
|
||||
}
|
||||
|
||||
// batterySuggestion returns the optimizer suggestion for the given battery meter
|
||||
func (site *Site) batterySuggestion(name string) *types.Suggestion {
|
||||
site.RLock()
|
||||
defer site.RUnlock()
|
||||
|
||||
if s, ok := site.batterySuggestions[name]; ok {
|
||||
return &s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearSuggestions removes all suggestions when the optimizer result is stale
|
||||
func (site *Site) clearSuggestions() {
|
||||
site.setBatterySuggestions(nil)
|
||||
site.publishBattery()
|
||||
|
||||
for id := range site.Loadpoints() {
|
||||
site.publishLoadpoint(id, keys.Suggestion, nil)
|
||||
}
|
||||
}
|
||||
|
||||
type requestDetails struct {
|
||||
Timestamps []time.Time `json:"timestamp"`
|
||||
BatteryDetails []batteryDetail `json:"batteryDetails"`
|
||||
|
|
@ -140,6 +213,9 @@ func (site *Site) optimizerUpdateAsync() {
|
|||
|
||||
if err != nil {
|
||||
site.log.ERROR.Println("optimizer:", err)
|
||||
|
||||
// stale advice must not linger
|
||||
site.clearSuggestions()
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
@ -237,7 +313,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
|
|||
details.BatteryDetails = append(details.BatteryDetails, detail)
|
||||
}
|
||||
|
||||
for _, lp := range site.Loadpoints() {
|
||||
for id, lp := range site.Loadpoints() {
|
||||
// ignore disconnected loadpoints, including StatusNone
|
||||
if s := lp.GetStatus(); s != api.StatusB && s != api.StatusC {
|
||||
continue
|
||||
|
|
@ -249,6 +325,7 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
|
|||
|
||||
// skip disabled loadpoints
|
||||
if req, detail := site.loadpointRequest(lp, minLen, firstSlotDuration, grid); req.CMax > 0 {
|
||||
detail.loadpoint = &id
|
||||
add(req, detail)
|
||||
}
|
||||
}
|
||||
|
|
@ -309,12 +386,20 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
|
|||
Details: details,
|
||||
})
|
||||
|
||||
slotHours := firstSlotDuration.Hours()
|
||||
gridImporting := len(resp.JSON200.GridImport) > 0 && resp.JSON200.GridImport[0] > 0
|
||||
gridExporting := len(resp.JSON200.GridExport) > 0 && resp.JSON200.GridExport[0] > 0
|
||||
|
||||
var batteries []batteryResult
|
||||
suggestions := make(map[string]types.Suggestion, len(req.Batteries))
|
||||
lpSuggestions := make(map[int]types.Suggestion)
|
||||
|
||||
for i, batReq := range req.Batteries {
|
||||
batResp := resp.JSON200.Batteries[i]
|
||||
detail := details.BatteryDetails[i]
|
||||
|
||||
batResult := batteryResult{
|
||||
batteryDetail: details.BatteryDetails[i],
|
||||
batteryDetail: detail,
|
||||
Full: matchSoc(batResp.StateOfCharge, func(soc float32) bool {
|
||||
return soc >= batReq.SMax
|
||||
}),
|
||||
|
|
@ -324,13 +409,33 @@ func (site *Site) optimizerUpdate(battery []types.Measurement) error {
|
|||
}
|
||||
|
||||
batteries = append(batteries, batResult)
|
||||
|
||||
suggestion := currentSlotSuggestion(detail, batResp, gridImporting, gridExporting, slotHours)
|
||||
if suggestion.Action == "" {
|
||||
continue
|
||||
}
|
||||
if detail.Type == batteryTypeBattery {
|
||||
suggestions[detail.Name] = suggestion
|
||||
} else if detail.loadpoint != nil {
|
||||
lpSuggestions[*detail.loadpoint] = suggestion
|
||||
}
|
||||
}
|
||||
|
||||
site.publish("evopt-batteries", batteries)
|
||||
|
||||
site.setBatterySuggestions(suggestions)
|
||||
site.battery.Forecast = site.addBatteryForecastTotals(req.Batteries, resp.JSON200.Batteries)
|
||||
|
||||
site.publish(keys.Battery, site.battery)
|
||||
site.publishBattery()
|
||||
|
||||
// publish for all loadpoints so suggestions of dropped-out loadpoints clear
|
||||
for id := range site.Loadpoints() {
|
||||
var val any
|
||||
if s, ok := lpSuggestions[id]; ok {
|
||||
val = s
|
||||
}
|
||||
site.publishLoadpoint(id, keys.Suggestion, val)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -163,3 +163,38 @@ func TestOptimizerChargingStrategy(t *testing.T) {
|
|||
require.NoError(t, site.SetOptimizerChargingStrategy(string(optimizer.OptimizerStrategyChargingStrategyAttenuateGridPeaks)))
|
||||
assert.Equal(t, "attenuate_grid_peaks", site.GetOptimizerChargingStrategy())
|
||||
}
|
||||
|
||||
func TestCurrentSlotSuggestion(t *testing.T) {
|
||||
// slotHours 1 makes the per-slot Wh values map 1:1 to W
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
typ batteryType
|
||||
charge, disch float32
|
||||
importing, export bool
|
||||
want string
|
||||
}{
|
||||
{"battery grid charge", batteryTypeBattery, 3000, 0, true, false, "charge"},
|
||||
{"battery pv charge (no import)", batteryTypeBattery, 3000, 0, false, true, "normal"},
|
||||
{"battery hold (idle while importing)", batteryTypeBattery, 0, 0, true, false, "hold"},
|
||||
{"battery holdcharge (idle while exporting)", batteryTypeBattery, 0, 0, false, true, "holdcharge"},
|
||||
{"battery discharge", batteryTypeBattery, 0, 2000, true, false, "normal"},
|
||||
{"battery idle balanced", batteryTypeBattery, 0, 0, false, false, "normal"},
|
||||
{"loadpoint charge", batteryTypeLoadpoint, 11000, 0, false, false, "charge"},
|
||||
{"loadpoint stop", batteryTypeLoadpoint, 0, 0, false, false, "stop"},
|
||||
{"vehicle below threshold is stop", batteryTypeVehicle, 40, 0, false, false, "stop"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res := optimizer.BatteryResult{
|
||||
ChargingPower: []float32{tc.charge},
|
||||
DischargingPower: []float32{tc.disch},
|
||||
}
|
||||
s := currentSlotSuggestion(batteryDetail{Type: tc.typ}, res, tc.importing, tc.export, 1)
|
||||
assert.Equal(t, tc.want, s.Action)
|
||||
assert.InDelta(t, tc.charge, s.Charge, 1e-3)
|
||||
assert.InDelta(t, tc.disch, s.Discharge, 1e-3)
|
||||
})
|
||||
}
|
||||
|
||||
// no result yields an empty suggestion
|
||||
assert.Empty(t, currentSlotSuggestion(batteryDetail{Type: batteryTypeBattery}, optimizer.BatteryResult{}, true, false, 1))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,18 +8,28 @@ import (
|
|||
|
||||
// Measurement is the device measurements struct
|
||||
type Measurement struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Power float64 `json:"power"`
|
||||
Energy *float64 `json:"energy,omitempty"`
|
||||
ReturnEnergy *float64 `json:"returnEnergy,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"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Power float64 `json:"power"`
|
||||
Energy *float64 `json:"energy,omitempty"`
|
||||
ReturnEnergy *float64 `json:"returnEnergy,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"`
|
||||
Suggestion *Suggestion `json:"suggestion,omitempty"`
|
||||
}
|
||||
|
||||
// Suggestion is the optimizer's advisory action for the current slot
|
||||
type Suggestion struct {
|
||||
// Action is the recommended action for the current slot.
|
||||
// home battery: normal|hold|charge|holdcharge; loadpoint/vehicle: charge|stop
|
||||
Action string `json:"action"`
|
||||
Charge float64 `json:"charge"` // recommended charge power, W
|
||||
Discharge float64 `json:"discharge"` // recommended discharge power, W
|
||||
}
|
||||
|
||||
type BatteryForecast struct {
|
||||
|
|
|
|||
15
i18n/de.json
15
i18n/de.json
|
|
@ -48,17 +48,16 @@
|
|||
},
|
||||
"optimizer": {
|
||||
"action": {
|
||||
"charge": "Netzladen",
|
||||
"hold": "Entladung gesperrt",
|
||||
"holdcharge": "Laden pausiert",
|
||||
"normal": "PV-Überschuss folgen"
|
||||
"charge": "Aus dem Netz laden",
|
||||
"hold": "Entladen verhindern",
|
||||
"holdcharge": "Laden verhindern",
|
||||
"normal": "Normaler Betrieb"
|
||||
},
|
||||
"empty": "leer",
|
||||
"full": "voll",
|
||||
"highest": "Höchststand ({value})",
|
||||
"lowest": "Tiefststand ({value})",
|
||||
"suggestion": "Vorschlag",
|
||||
"title": "Optimizer"
|
||||
"suggestion": "Vorschlag"
|
||||
}
|
||||
},
|
||||
"batterySettings": {
|
||||
|
|
@ -1297,6 +1296,8 @@
|
|||
"heatingStatus": {
|
||||
"charging": "Heize …",
|
||||
"connected": "Standby.",
|
||||
"suggestionStartTooltip": "Der Optimizer empfiehlt, jetzt zu heizen.",
|
||||
"suggestionStopTooltip": "Der Optimizer empfiehlt, das Heizen zu pausieren.",
|
||||
"vehicleLimit": "Heizungslimit",
|
||||
"waitForVehicle": "Bereit zum Heizen …"
|
||||
},
|
||||
|
|
@ -1487,6 +1488,8 @@
|
|||
"pvEnable": "Überschuss verfügbar. Ladung wird gleich fortgesetzt.",
|
||||
"scale1p": "Reduziere gleich auf 1-phasiges Laden.",
|
||||
"scale3p": "Erhöhe gleich auf 3-phasiges Laden.",
|
||||
"suggestionStartTooltip": "Der Optimizer empfiehlt, jetzt zu laden.",
|
||||
"suggestionStopTooltip": "Der Optimizer empfiehlt, das Laden zu pausieren.",
|
||||
"targetChargeActive": "Ladeplan aktiv. Geschätztes Ende in {duration}.",
|
||||
"targetChargePlanned": "Ladeplan startet in {duration}.",
|
||||
"targetChargeWaitForVehicle": "Ladeplan bereit. Warte auf Fahrzeug …",
|
||||
|
|
|
|||
15
i18n/en.json
15
i18n/en.json
|
|
@ -48,17 +48,16 @@
|
|||
},
|
||||
"optimizer": {
|
||||
"action": {
|
||||
"charge": "Grid charging",
|
||||
"hold": "Discharge locked",
|
||||
"holdcharge": "Charging paused",
|
||||
"normal": "Following solar surplus"
|
||||
"charge": "Charge from grid",
|
||||
"hold": "Prevent discharging",
|
||||
"holdcharge": "Prevent charging",
|
||||
"normal": "Normal operation"
|
||||
},
|
||||
"empty": "empty",
|
||||
"full": "full",
|
||||
"highest": "Highest ({value})",
|
||||
"lowest": "Lowest ({value})",
|
||||
"suggestion": "Suggestion",
|
||||
"title": "Optimizer"
|
||||
"suggestion": "Suggestion"
|
||||
}
|
||||
},
|
||||
"batterySettings": {
|
||||
|
|
@ -1297,6 +1296,8 @@
|
|||
"heatingStatus": {
|
||||
"charging": "Heating…",
|
||||
"connected": "Standby.",
|
||||
"suggestionStartTooltip": "Optimizer recommends heating now.",
|
||||
"suggestionStopTooltip": "Optimizer recommends pausing the heating.",
|
||||
"vehicleLimit": "Heater limit",
|
||||
"waitForVehicle": "Ready to heat…"
|
||||
},
|
||||
|
|
@ -1487,6 +1488,8 @@
|
|||
"pvEnable": "Surplus available. Starting soon.",
|
||||
"scale1p": "Reducing to 1-phase charging soon.",
|
||||
"scale3p": "Increasing to 3-phase charging soon.",
|
||||
"suggestionStartTooltip": "Optimizer recommends charging now.",
|
||||
"suggestionStopTooltip": "Optimizer recommends pausing the charge.",
|
||||
"targetChargeActive": "Charging plan active. Estimated finish in {duration}.",
|
||||
"targetChargePlanned": "Charging plan starts in {duration}.",
|
||||
"targetChargeWaitForVehicle": "Charging plan ready. Waiting for vehicle…",
|
||||
|
|
|
|||
|
|
@ -106,32 +106,32 @@ func (suite *mqttSuite) TestNilInterface() {
|
|||
|
||||
func (suite *mqttSuite) TestMeasurement() {
|
||||
topics := lo.Map([]string{
|
||||
"name", "title", "icon", "power", "energy", "returnEnergy", "powers", "currents", "excessDCPower", "capacity", "soc", "controllable",
|
||||
"name", "title", "icon", "power", "energy", "returnEnergy", "powers", "currents", "excessDCPower", "capacity", "soc", "controllable", "suggestion",
|
||||
}, 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: new(1.0)})
|
||||
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/name", "devices/1/title", "devices/1/icon", "devices/1/power", "devices/1/energy", "devices/1/returnEnergy", "devices/1/powers", "devices/1/currents", "devices/1/excessDCPower", "devices/1/capacity", "devices/1/soc", "devices/1/controllable",
|
||||
"devices", "devices/1/name", "devices/1/title", "devices/1/icon", "devices/1/power", "devices/1/energy", "devices/1/returnEnergy", "devices/1/powers", "devices/1/currents", "devices/1/excessDCPower", "devices/1/capacity", "devices/1/soc", "devices/1/controllable", "devices/1/suggestion",
|
||||
"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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue