Add smart feed-in priority (#21813)

This commit is contained in:
andig 2025-06-27 09:03:46 +02:00 • committed by GitHub
parent 6ba804b106
commit 42ed7da2e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1645 additions and 1290 deletions

View file

@ -244,7 +244,10 @@
<SmartCostLimit
v-if="isModalVisible"
v-show="gridTabActive"
v-bind="smartCostLimitProps"
:current-limit="batteryGridChargeLimit"
:smart-cost-type="smartCostType"
:currency="currency"
:tariff="forecast?.grid"
:possible="gridChargePossible"
/>
</GenericModal>
@ -260,9 +263,9 @@ import GenericModal from "../Helper/GenericModal.vue";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import collector from "@/mixins/collector.js";
import api from "@/api";
import smartCostAvailable from "@/utils/smartCostAvailable";
import { defineComponent, type PropType } from "vue";
import type { Battery, SelectOption, SMART_COST_TYPE } from "@/types/evcc";
import type { Battery, SelectOption, CURRENCY, Forecast } from "@/types/evcc";
import { SMART_COST_TYPE } from "@/types/evcc";
export default defineComponent({
name: "BatterySettingsModal",
@ -276,9 +279,11 @@ export default defineComponent({
batteryDischargeControl: Boolean,
battery: { type: Array as PropType<Battery[]>, default: () => [] },
batteryGridChargeLimit: { type: Number, default: null },
smartCostAvailable: Boolean,
smartCostType: String as PropType<SMART_COST_TYPE>,
tariffGrid: Number,
currency: String,
currency: String as PropType<CURRENCY>,
forecast: Object as PropType<Forecast>,
},
data() {
return {
@ -383,15 +388,6 @@ export default defineComponent({
return `${name}${formattedEnergy}${formattedSoc}`;
});
},
smartCostLimitProps() {
return {
...this.collectProps(SmartCostLimit),
smartCostLimit: this.batteryGridChargeLimit,
};
},
smartCostAvailable() {
return smartCostAvailable(this.smartCostType);
},
},
watch: {
prioritySoc(soc) {

View file

@ -105,7 +105,6 @@ import SettingsButton from "./SettingsButton.vue";
import SettingsModal from "./SettingsModal.vue";
import VehicleIcon from "../VehicleIcon";
import SessionInfo from "./SessionInfo.vue";
import smartCostAvailable from "@/utils/smartCostAvailable";
import Modal from "bootstrap/js/dist/modal";
import { defineComponent, type PropType } from "vue";
import type {
@ -207,9 +206,15 @@ export default defineComponent({
pvAction: String,
smartCostLimit: { type: Number, default: null },
smartCostType: String as PropType<SMART_COST_TYPE>,
smartCostAvailable: Boolean,
smartCostActive: Boolean,
smartCostNextStart: String,
smartFeedInPriorityLimit: { type: Number, default: null },
smartFeedInPriorityAvailable: Boolean,
smartFeedInPriorityActive: Boolean,
smartFeedInPriorityNextStart: String,
tariffGrid: Number,
tariffFeedIn: Number,
tariffCo2: Number,
currency: String,
multipleLoadpoints: Boolean,
@ -284,9 +289,6 @@ export default defineComponent({
pvPossible() {
return this.pvConfigured || this.gridConfigured;
},
hasSmartCost() {
return smartCostAvailable(this.smartCostType);
},
batteryBoostAvailable() {
return this.batteryConfigured && this.$hiddenFeatures();
},

View file

@ -18,8 +18,11 @@
data-testid="loadpoint"
:vehicles="vehicles"
:smartCostType="smartCostType"
:smartCostAvailable="smartCostAvailable"
:smartFeedInPriorityAvailable="smartFeedInPriorityAvailable"
:tariffGrid="tariffGrid"
:tariffCo2="tariffCo2"
:tariffFeedIn="tariffFeedIn"
:currency="currency"
:multipleLoadpoints="loadpoints.length > 1"
:gridConfigured="gridConfigured"
@ -70,8 +73,11 @@ export default defineComponent({
loadpoints: { type: Array as PropType<LoadpointCompact[]>, default: () => [] },
vehicles: { type: Array as PropType<Vehicle[]> },
smartCostType: String as PropType<SMART_COST_TYPE>,
smartCostAvailable: Boolean,
smartFeedInPriorityAvailable: Boolean,
tariffGrid: Number,
tariffCo2: Number,
tariffFeedIn: Number,
currency: String,
selectedIndex: { type: Number, default: 0 },
gridConfigured: Boolean,

View file

@ -11,7 +11,7 @@ export default {
description: "Charging mode",
},
pvPossible: { control: "boolean", description: "Whether PV is possible" },
hasSmartCost: { control: "boolean", description: "Whether smart cost is available" },
smartCostAvailable: { control: "boolean", description: "Whether smart cost is available" },
},
parameters: {
layout: "centered",
@ -37,12 +37,12 @@ export const Full = Template.bind({});
Full.args = {
mode: "pv",
pvPossible: true,
hasSmartCost: true,
smartCostAvailable: true,
};
export const SmartGridOnly = Template.bind({});
SmartGridOnly.args = {
mode: "pv",
pvPossible: false,
hasSmartCost: true,
smartCostAvailable: true,
};

View file

@ -25,7 +25,7 @@ export default defineComponent({
props: {
mode: String,
pvPossible: Boolean,
hasSmartCost: Boolean,
smartCostAvailable: Boolean,
},
emits: ["updated"],
@ -34,7 +34,7 @@ export default defineComponent({
if (this.pvPossible) {
return [OFF, PV, MINPV, NOW];
}
if (this.hasSmartCost) {
if (this.smartCostAvailable) {
return [OFF, PV, NOW];
}
return [OFF, NOW];
@ -44,7 +44,7 @@ export default defineComponent({
label(mode: CHARGE_MODE) {
// rename pv mode to smart for non-pv and dynamic tariffs scenarios
// TODO: rollout smart name for everyting later
if (mode === PV && !this.pvPossible && this.hasSmartCost) {
if (mode === PV && !this.pvPossible && this.smartCostAvailable) {
return this.$t("main.mode.smart");
}
return this.$t(`main.mode.${mode}`);

View file

@ -1,35 +1,31 @@
<template>
<Teleport to="body">
<div
<GenericModal
:id="`loadpointSettingsModal_${id}`"
ref="modal"
class="modal fade text-dark modal-xl"
data-bs-backdrop="true"
tabindex="-1"
role="dialog"
aria-hidden="true"
:title="$t('main.loadpointSettings.title', [title])"
size="xl"
data-testid="loadpoint-settings-modal"
@opened="modalVisible"
@closed="modalInvisible"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ $t("main.loadpointSettings.title", [title]) }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<div class="container">
<SmartCostLimit
v-if="isModalVisible"
v-bind="smartCostLimitProps"
:current-limit="smartCostLimit"
:smart-cost-type="smartCostType"
:currency="currency"
is-loadpoint
:loadpoint-id="Number(loadpointId)"
:multiple-loadpoints="multipleLoadpoints"
:possible="smartCostAvailable"
:smartCostLimit="smartCostLimit"
:tariff="forecast?.planner"
class="mt-2"
/>
<SmartFeedInPriority
:current-limit="smartFeedInPriorityLimit"
:currency="currency"
:loadpoint-id="Number(loadpointId)"
:multiple-loadpoints="multipleLoadpoints"
:possible="smartFeedInPriorityAvailable"
:tariff="forecast?.feedin"
class="mt-2"
/>
<LoadpointSettingsBatteryBoost
@ -51,18 +47,10 @@
<div class="col-sm-8 pe-0">
<p v-if="!chargerPhases1p3p" class="mt-0 mb-2">
<small>
{{
$t(
"main.loadpointSettings.phasesConfigured.no1p3pSupport"
)
}}</small
{{ $t("main.loadpointSettings.phasesConfigured.no1p3pSupport") }}</small
>
</p>
<div
v-for="phases in phasesOptions"
:key="phases"
class="form-check"
>
<div v-for="phases in phasesOptions" :key="phases" class="form-check">
<input
:id="formId(`phases_${phases}`)"
v-model.number="selectedPhases"
@ -72,15 +60,8 @@
:value="phases"
@change="changePhasesConfigured"
/>
<label
class="form-check-label"
:for="formId(`phases_${phases}`)"
>
{{
$t(
`main.loadpointSettings.phasesConfigured.phases_${phases}`
)
}}
<label class="form-check-label" :for="formId(`phases_${phases}`)">
{{ $t(`main.loadpointSettings.phasesConfigured.phases_${phases}`) }}
<small v-if="phases > 0">
{{
$t(
@ -98,10 +79,7 @@
</div>
<div class="mb-3 row">
<label
:for="formId('maxcurrent')"
class="col-sm-4 col-form-label pt-0 pt-sm-2"
>
<label :for="formId('maxcurrent')" class="col-sm-4 col-form-label pt-0 pt-sm-2">
{{ $t("main.loadpointSettings.maxCurrent.label") }}
</label>
<div class="col-sm-8 pe-0 d-flex align-items-center">
@ -124,10 +102,7 @@
</div>
<div class="mb-3 row">
<label
:for="formId('mincurrent')"
class="col-sm-4 col-form-label pt-0 pt-sm-2"
>
<label :for="formId('mincurrent')" class="col-sm-4 col-form-label pt-0 pt-sm-2">
{{ $t("main.loadpointSettings.minCurrent.label") }}
</label>
<div class="col-sm-8 pe-0 d-flex align-items-center">
@ -149,21 +124,18 @@
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</GenericModal>
</template>
<script lang="ts">
import collector from "@/mixins/collector.ts";
import formatter from "@/mixins/formatter";
import GenericModal from "../Helper/GenericModal.vue";
import SmartCostLimit from "../Tariff/SmartCostLimit.vue";
import smartCostAvailable from "@/utils/smartCostAvailable";
import SmartFeedInPriority from "../Tariff/SmartFeedInPriority.vue";
import SettingsBatteryBoost from "./SettingsBatteryBoost.vue";
import { defineComponent, type PropType } from "vue";
import { PHASES, CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
import { PHASES, CURRENCY, SMART_COST_TYPE, type Forecast } from "@/types/evcc";
const V = 230;
@ -183,7 +155,12 @@ const { AUTO, THREE_PHASES, ONE_PHASE } = PHASES;
export default defineComponent({
name: "LoadpointSettingsModal",
components: { SmartCostLimit, LoadpointSettingsBatteryBoost: SettingsBatteryBoost },
components: {
GenericModal,
SmartCostLimit,
SmartFeedInPriority,
LoadpointSettingsBatteryBoost: SettingsBatteryBoost,
},
mixins: [formatter, collector],
props: {
id: [String, Number],
@ -199,9 +176,13 @@ export default defineComponent({
title: String,
smartCostLimit: { type: Number as PropType<number | null>, default: null },
smartCostType: String as PropType<SMART_COST_TYPE>,
smartCostAvailable: Boolean,
smartFeedInPriorityLimit: { type: Number as PropType<number | null>, default: null },
smartFeedInPriorityAvailable: Boolean,
tariffGrid: Number,
currency: String as PropType<CURRENCY>,
multipleLoadpoints: Boolean,
forecast: Object as PropType<Forecast>,
},
emits: [
"phasesconfigured-updated",
@ -266,15 +247,10 @@ export default defineComponent({
const opt2 = insertSorted(opt1, this.maxCurrent);
return opt2.map((value) => this.currentOption(value, value === 16));
},
smartCostLimitProps() {
return this.collectProps(SmartCostLimit);
},
loadpointId() {
return this.id;
},
smartCostAvailable() {
return smartCostAvailable(this.smartCostType);
},
},
watch: {
maxCurrent(value) {
@ -287,14 +263,6 @@ export default defineComponent({
this.selectedPhases = value;
},
},
mounted() {
this.$refs["modal"]?.addEventListener("show.bs.modal", this.modalVisible);
this.$refs["modal"]?.addEventListener("hidden.bs.modal", this.modalInvisible);
},
unmounted() {
this.$refs["modal"]?.removeEventListener("show.bs.modal", this.modalVisible);
this.$refs["modal"]?.removeEventListener("hidden.bs.modal", this.modalInvisible);
},
methods: {
maxPowerPhases(phases: PHASES) {
return this.fmtW(this.maxCurrent * V * phases);

View file

@ -0,0 +1,129 @@
import type { Meta, StoryFn } from "@storybook/vue3";
import { ICON_SIZE } from "@/types/evcc";
// Auto-discover all MaterialIcon components
const iconModules = import.meta.glob("./*.vue", { eager: true });
const iconComponents: Record<string, any> = {};
const iconNames: string[] = [];
Object.keys(iconModules).forEach((path) => {
const iconName = path.replace("./", "").replace(".vue", "");
iconComponents[iconName] = (iconModules[path] as any).default;
iconNames.push(iconName);
});
// Sort icon names alphabetically
iconNames.sort();
export default {
title: "MaterialIcon/MaterialIcon",
argTypes: {
size: {
control: "select",
options: Object.values(ICON_SIZE),
defaultValue: ICON_SIZE.L,
},
},
} as Meta;
// Template for a single icon
const SingleIconTemplate: StoryFn = (args) => ({
components: iconComponents,
setup() {
return { args, iconNames };
},
template: `
<div>
<component :is="args.iconName" v-bind="args" />
</div>
`,
});
// Single icon story (controllable via Storybook controls)
export const SingleIcon = SingleIconTemplate.bind({});
SingleIcon.args = {
iconName: iconNames[0],
size: ICON_SIZE.L,
};
SingleIcon.argTypes = {
iconName: {
control: "select",
options: iconNames,
},
};
// All icons in a simple grid
export const AllIcons = () => ({
components: iconComponents,
setup() {
return { iconNames };
},
template: `
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 20px;">
<div v-for="iconName in iconNames" :key="iconName" style="display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 15px;">
<component :is="iconName" size="xl" />
<small style="font-family: monospace; color: #666; text-align: center; font-size: 12px;">{{ iconName }}</small>
</div>
</div>
`,
});
// Color variations story - table format with all icons
export const ColorVariations = () => ({
components: iconComponents,
setup() {
return { iconNames };
},
template: `
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
<thead>
<tr style="border-bottom: 2px solid #ddd;">
<th style="padding: 12px; text-align: left; font-weight: normal;">Icon Name</th>
<th style="padding: 12px; text-align: left; font-weight: normal;">Default</th>
<th style="padding: 12px; text-align: left; font-weight: normal; color: var(--bs-primary);">Primary</th>
<th style="padding: 12px; text-align: left; font-weight: normal; color: var(--bs-danger);">Danger</th>
<th style="padding: 12px; text-align: left; font-weight: normal; color: var(--bs-warning);">Warning</th>
</tr>
</thead>
<tbody>
<tr v-for="iconName in iconNames" :key="iconName" style="border-bottom: 1px solid #eee;">
<td style="padding: 12px; font-family: monospace; color: #666;">{{ iconName }}</td>
<td style="padding: 12px;">
<component :is="iconName" size="l" />
</td>
<td style="padding: 12px;">
<component :is="iconName" size="l" class="text-primary" />
</td>
<td style="padding: 12px;">
<component :is="iconName" size="l" class="text-danger" />
</td>
<td style="padding: 12px;">
<component :is="iconName" size="l" class="text-warning" />
</td>
</tr>
</tbody>
</table>
`,
});
// Size variations story - all icons
export const SizeVariations = () => ({
components: iconComponents,
setup() {
const sizes = Object.values(ICON_SIZE);
return { iconNames, sizes };
},
template: `
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 30px;">
<div v-for="iconName in iconNames" :key="iconName" style="display: flex; flex-direction: column; align-items: center; gap: 20px; padding: 20px; border: 1px solid #e0e0e0; border-radius: 8px;">
<h3 style="margin: 0; font-size: 16px; color: #333; font-family: monospace;">{{ iconName }}</h3>
<div style="display: flex; align-items: end; gap: 20px;">
<div v-for="size in sizes" :key="size" style="display: flex; flex-direction: column; align-items: center; gap: 8px;">
<component :is="iconName" :size="size" />
<small style="color: #999; font-size: 11px; font-family: monospace;">{{ size }}</small>
</div>
</div>
</div>
</div>
`,
});

View file

@ -0,0 +1,19 @@
<template>
<svg :style="svgStyle" viewBox="0 0 48 48">
>
<path
fill="currentColor"
d="M14.216,26.071c-0.144,-0.681 -0.217,-1.375 -0.216,-2.071c-0,-5.514 4.486,-10 10,-10c5.514,0 10,4.486 10,10c0,0.71 -0.074,1.403 -0.216,2.071l-4.152,0c0.242,-0.662 0.366,-1.361 0.366,-2.065c0,-3.292 -2.706,-6.002 -5.998,-6.006c-3.292,0.004 -5.998,2.714 -5.998,6.006c-0,0.704 0.124,1.403 0.366,2.065l-4.152,0Zm7.784,-22.071l4,0l0,6l-4,0l0,-6Zm-18,18l6,0l0,4l-6,0l0,-4Zm34,0l6,0l0,4l-6,0l0,-4Zm-22.268,-16.32l3,5.196l-3.464,2l-3,-5.196l3.464,-2Zm24.589,6.589l2,3.466l-5.196,2.999l-2,-3.465l5.196,-3Zm-32.642,-0.002l5.197,2.999l-2,3.466l-5.197,-3l2,-3.465Zm24.59,-6.587l3.465,2.001l-3,5.195l-3.465,-2.001l3,-5.195Zm-10.225,35.581l-4.088,-0l0,-10.261l4.088,-0l0,10.261Zm8.088,-0l-4.088,-0l0,-10.261l4.088,-0l0,10.261Z"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "SunPause",
mixins: [icon],
});
</script>

View file

@ -50,8 +50,11 @@
:loadpoints="loadpoints"
:vehicles="vehicleList"
:smartCostType="smartCostType"
:smartCostAvailable="smartCostAvailable"
:smartFeedInPriorityAvailable="smartFeedInPriorityAvailable"
:tariffGrid="tariffGrid"
:tariffCo2="tariffCo2"
:tariffFeedIn="tariffFeedIn"
:currency="currency"
:gridConfigured="gridConfigured"
:pvConfigured="pvConfigured"
@ -146,6 +149,8 @@ export default defineComponent({
uploadProgress: Number,
sponsor: { type: Object as PropType<Sponsor>, default: () => ({}) },
smartCostType: String as PropType<SMART_COST_TYPE>,
smartCostAvailable: Boolean,
smartFeedInPriorityAvailable: Boolean,
fatal: Object,
forecast: Object as PropType<Forecast>,
},

View file

@ -1,382 +1,99 @@
<template>
<div v-if="possible">
<h6 v-if="isLoadpoint" class="mt-0">{{ title }}</h6>
<p>
{{ description }}
</p>
<div class="row mb-3">
<label :for="formId" class="col-sm-4 col-form-label pt-0 pt-sm-2">
{{ isCo2 ? $t("smartCost.co2Limit") : $t("smartCost.priceLimit") }}
</label>
<div class="col-sm-8 col-lg-4 pe-0">
<select
:id="formId"
v-model.number="selectedSmartCostLimit"
class="form-select form-select-sm mb-1"
@change="changeSmartCostLimit"
>
<option value="null">{{ $t("smartCost.none") }}</option>
<option v-for="{ value, name } in costOptions" :key="value" :value="value">
{{ name }}
</option>
</select>
</div>
<div
v-if="applyToAllVisible"
class="col-sm-8 offset-sm-4 offset-lg-0 col-lg-4 d-flex align-items-baseline"
>
<div class="text-primary">{{ $t("smartCost.saved") }}</div>
<button class="ms-1 btn btn-sm btn-link text-muted p-0" @click="applyToAll">
{{ $t("smartCost.applyToAll") }}
</button>
</div>
</div>
<div class="justify-content-between mb-2 d-flex justify-content-between">
<div class="text-start">
<div class="label">
{{ $t("smartCost.activeHoursLabel") }}
</div>
<div
class="value"
:class="chargingSlots.length ? 'text-primary' : 'value-inactive'"
>
{{
$t("smartCost.activeHours", {
total: totalSlots.length,
charging: chargingSlots.length,
})
}}
</div>
</div>
<div class="text-end">
<div class="label">
<span v-if="activeSlot">{{ activeSlotName }}</span>
<span v-else-if="isCo2">{{ $t("smartCost.co2Label") }}</span>
<span v-else>{{ $t("smartCost.priceLabel") }}</span>
</div>
<div v-if="activeSlot" class="value text-primary">
{{ activeSlotCost }}
</div>
<div v-else-if="chargingSlots.length" class="value text-primary">
{{ chargingCostRange }}
</div>
<div v-else class="value value-inactive">
{{ totalCostRange }}
</div>
</div>
</div>
<TariffChart
v-if="tariff"
:slots="slots"
@slot-hovered="slotHovered"
@slot-selected="slotSelected"
<SmartTariffBase
v-bind="labels"
:current-limit="currentLimit"
:is-co2="isCo2"
:currency="currency"
:apply-all="multipleLoadpoints && isLoadpoint"
:possible="possible"
:tariff="tariff"
:form-id="formId"
:is-slot-active="isSlotActive"
limit-direction="below"
:options-start-at-zero="isCo2"
@save-limit="saveLimit"
@delete-limit="deleteLimit"
@apply-to-all="applyToAll"
/>
</div>
<div v-else-if="smartCostLimit !== null">
<div class="alert alert-warning">
{{ $t("smartCost.resetWarning", { limit: fmtSmartCostLimit(smartCostLimit) }) }}
<a href="#" class="text-warning" @click.prevent="saveSmartCostLimit('null')">
{{ $t("smartCost.resetAction") }}
</a>
</div>
</div>
</template>
<script lang="ts">
import formatter from "@/mixins/formatter";
import TariffChart from "./TariffChart.vue";
import api, { allowClientError } from "@/api.ts";
import convertRates from "@/utils/convertRates";
import SmartTariffBase from "./SmartTariffBase.vue";
import { defineComponent, type PropType } from "vue";
import {
type Tariff,
type CURRENCY,
type Rate,
type SelectOption,
type Slot,
SMART_COST_TYPE,
} from "@/types/evcc";
import api from "@/api";
import { type CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
import { type ForecastSlot } from "../Forecast/types";
export default defineComponent({
name: "SmartCostLimit",
components: { TariffChart },
mixins: [formatter],
components: { SmartTariffBase },
props: {
smartCostLimit: {
currentLimit: {
type: [Number, null] as PropType<number | null>,
required: true,
},
smartCostType: String as PropType<SMART_COST_TYPE>,
tariffGrid: Number,
currency: String as PropType<CURRENCY>,
loadpointId: Number,
multipleLoadpoints: Boolean,
isLoadpoint: Boolean,
loadpointId: Number,
possible: Boolean,
},
data() {
return {
selectedSmartCostLimit: null as number | null,
tariff: null as Tariff | null,
startTime: null as Date | null,
activeIndex: null as number | null,
applyToAllVisible: false,
};
tariff: Array as PropType<ForecastSlot[]>,
},
computed: {
isCo2(): boolean {
return this.smartCostType === SMART_COST_TYPE.CO2;
},
costOptions(): SelectOption<number>[] {
const { max } = this.costRange(this.totalSlots);
const values = [] as number[];
const stepSize = this.optionStepSize;
for (let i = 1; i <= 100; i++) {
const value = this.optionStartValue + stepSize * i;
if (max !== undefined && value > max + stepSize) break;
values.push(this.roundLimit(value) as number);
}
// add special entry if currently selected value is not in the scale
const selected = this.selectedSmartCostLimit;
if (selected && !values.includes(selected)) {
values.push(selected);
}
values.sort((a, b) => a - b);
return values.map((value) => ({ value, name: this.fmtSmartCostLimit(value) }));
},
optionStartValue() {
if (!this.tariff) {
return 0;
}
const { min } = this.costRange(this.totalSlots);
const stepSize = this.optionStepSize;
// always show some negative values for price
const start = this.isCo2 ? 0 : stepSize * -11;
const minValue = min !== undefined ? Math.min(start, min) : start;
return Math.floor(minValue / stepSize) * stepSize;
},
optionStepSize() {
if (!this.tariff) {
return 1;
}
const { min, max } = this.costRange(this.totalSlots);
if (min === undefined || max === undefined) {
return 1;
}
const baseSteps = [0.001, 0.002, 0.005];
const range = max - Math.min(0, min);
for (let scale = 1; scale <= 10000; scale *= 10) {
for (const baseStep of baseSteps) {
const step = baseStep * scale;
if (range < step * 100) return step;
}
}
return 1;
},
slots(): Slot[] {
const result: Slot[] = [];
if (!this.tariff?.rates || !this.startTime) return result;
const { rates } = this.tariff;
const oneHour = 60 * 60 * 1000;
for (let i = 0; i < 42; i++) {
const start = new Date(this.startTime.getTime() + oneHour * i);
const startHour = start.getHours();
start.setMinutes(0);
start.setSeconds(0);
start.setMilliseconds(0);
const end = new Date(start.getTime());
end.setHours(startHour + 1);
const endHour = end.getHours();
const day = this.weekdayShort(start);
// TODO: handle multiple matching time slots
const value = this.findRateInRange(start, end, rates)?.value;
const charging =
this.selectedSmartCostLimit !== null &&
value !== undefined &&
value <= this.selectedSmartCostLimit;
const selectable = value !== undefined;
result.push({ day, value, startHour, endHour, charging, selectable });
}
return result;
},
totalSlots() {
return this.slots.filter((s) => s.value !== undefined);
},
chargingSlots() {
return this.totalSlots.filter((s) => s.charging);
},
totalCostRange() {
return this.fmtCostRange(this.costRange(this.totalSlots));
},
chargingCostRange() {
return this.fmtCostRange(this.costRange(this.chargingSlots));
},
activeSlot(): Slot | null {
return this.activeIndex !== null ? this.slots[this.activeIndex] : null;
},
activeSlotName() {
if (this.activeSlot) {
const { day, startHour, endHour } = this.activeSlot;
const range = `${startHour}–${endHour}`;
return this.$t("main.targetChargePlan.timeRange", { day, range });
}
return null;
},
activeSlotCost() {
const value = this.activeSlot?.value;
if (value === undefined) {
return this.$t("main.targetChargePlan.unknownPrice");
}
if (this.isCo2) {
return this.fmtCo2Medium(value);
}
return this.fmtPricePerKWh(value, this.currency);
},
title() {
return this.$t(`smartCost.${this.isCo2 ? "clean" : "cheap"}Title`);
},
description() {
return this.$t(`smartCost.${this.loadpointId ? "loadpoint" : "battery"}Description`);
},
formId() {
formId(): string {
return `smartCostLimit-${this.loadpointId || "battery"}`;
},
isLoadpoint() {
return !!this.loadpointId;
labels() {
const t = (key: string) => this.$t(`smartCost.${key}`);
const co2 = this.isCo2;
const lp = this.isLoadpoint;
return {
title: lp ? (co2 ? t("cleanTitle") : t("cheapTitle")) : "",
description: lp ? t("loadpointDescription") : t("batteryDescription"),
limitLabel: co2 ? t("co2Limit") : t("priceLimit"),
currentPriceLabel: co2 ? t("co2Label") : t("priceLabel"),
resetWarningText: t("resetWarning"),
activeHoursLabel: t("activeHoursLabel"),
};
},
},
watch: {
tariffGrid() {
this.updateTariff();
},
smartCostLimit(limit) {
this.selectedSmartCostLimit = this.roundLimit(limit);
},
},
mounted() {
this.updateTariff();
this.selectedSmartCostLimit = this.roundLimit(this.smartCostLimit);
},
methods: {
roundLimit(limit: number | null): number | null {
return limit === null ? null : Math.round(limit * 1000) / 1000;
},
fmtSmartCostLimit(limit: number | null): string {
if (limit === null) {
return this.$t("smartCost.none");
isSlotActive(value: number | undefined): boolean {
if (value === undefined || this.currentLimit === null) {
return false;
}
return `≤ ${
this.isCo2 ? this.fmtCo2Medium(limit) : this.fmtPricePerKWh(limit, this.currency)
}`;
// Smart cost: charge when costs are below or equal to limit
return value <= this.currentLimit;
},
async updateTariff() {
try {
const res = await api.get(`tariff/planner`, allowClientError);
if (res.status === 200) {
this.tariff = {
rates: convertRates(res.data.result.rates),
lastUpdate: new Date(),
};
this.startTime = new Date();
}
} catch (e) {
console.error(e);
}
},
findRateInRange(start: Date, end: Date, rates: Rate[]) {
return rates.find((r) => {
if (r.start.getTime() < start.getTime()) {
return r.end.getTime() > start.getTime();
}
return r.start.getTime() < end.getTime();
});
},
costRange(slots: Slot[]): { min: number | undefined; max: number | undefined } {
let min = undefined as number | undefined;
let max = undefined as number | undefined;
slots.forEach((slot) => {
if (slot.value === undefined) return;
min = min === undefined ? slot.value : Math.min(min, slot.value);
max = max === undefined ? slot.value : Math.max(max, slot.value);
});
return { min, max };
},
fmtCostRange({ min, max }: { min: number | undefined; max: number | undefined }): string {
if (min === undefined || max === undefined) return "";
const fmtMin = this.isCo2
? this.fmtCo2Short(min)
: this.fmtPricePerKWh(min, this.currency, true);
const fmtMax = this.isCo2
? this.fmtCo2Short(max)
: this.fmtPricePerKWh(max, this.currency, true);
return `${fmtMin} – ${fmtMax}`;
},
slotHovered(index: number) {
this.activeIndex = index;
},
slotSelected(index: number) {
const value = this.slots[index].value;
if (value !== undefined) {
// 3 decimal precision
const valueRounded = Math.ceil(value * 1000) / 1000;
this.saveSmartCostLimit(`${valueRounded}`);
}
},
changeSmartCostLimit($event: Event) {
const value = ($event.target as HTMLSelectElement).value;
this.saveSmartCostLimit(value);
},
async saveSmartCostLimit(limit: string) {
async saveLimit(limit: string) {
const url = this.isLoadpoint
? `loadpoints/${this.loadpointId}/smartcostlimit`
: "batterygridchargelimit";
// delete
try {
if (limit === "null") {
await api.delete(url);
} else {
await api.post(`${url}/${encodeURIComponent(limit)}`);
}
if (this.isLoadpoint && this.multipleLoadpoints) {
this.applyToAllVisible = true;
}
} catch (err) {
console.error(err);
}
},
async applyToAll() {
try {
if (this.selectedSmartCostLimit === null) {
async deleteLimit() {
const url = this.isLoadpoint
? `loadpoints/${this.loadpointId}/smartcostlimit`
: "batterygridchargelimit";
await api.delete(url);
},
async applyToAll(selectedLimit: number | null) {
if (selectedLimit === null) {
await api.delete("smartcostlimit");
} else {
await api.post(
`smartcostlimit/${encodeURIComponent(this.selectedSmartCostLimit)}`
);
}
this.applyToAllVisible = false;
} catch (err) {
console.error(err);
await api.post(`smartcostlimit/${encodeURIComponent(selectedLimit)}`);
}
},
},
});
</script>
<style scoped>
.value {
font-size: 18px;
font-weight: bold;
}
.label {
color: var(--evcc-gray);
text-transform: uppercase;
}
.value-inactive {
color: var(--evcc-gray);
}
</style>

View file

@ -0,0 +1,87 @@
<template>
<SmartTariffBase
v-bind="labels"
:current-limit="currentLimit"
:currency="currency"
:apply-all="multipleLoadpoints"
:possible="possible"
:tariff="tariff"
:form-id="formId"
:is-slot-active="isSlotActive"
options-extra-high
options-start-at-zero
limit-direction="above"
highlight-color="text-warning"
@save-limit="saveLimit"
@delete-limit="deleteLimit"
@apply-to-all="applyToAll"
/>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import SmartTariffBase from "./SmartTariffBase.vue";
import api from "@/api";
import { type CURRENCY } from "@/types/evcc";
export default defineComponent({
name: "SmartFeedInPriority",
components: { SmartTariffBase },
props: {
currentLimit: {
type: [Number, null] as PropType<number | null>,
required: true,
},
currency: String as PropType<CURRENCY>,
loadpointId: { type: Number, required: true },
multipleLoadpoints: Boolean,
possible: Boolean,
tariff: Array,
},
computed: {
formId(): string {
return `smartFeedInPriority-${this.loadpointId}`;
},
labels() {
const t = (key: string) => this.$t(`smartFeedInPriority.${key}`);
return {
title: t("title"),
description: t("description"),
limitLabel: t("priceLimit"),
activeHoursLabel: t("activeHoursLabel"),
currentPriceLabel: t("priceLabel"),
resetWarningText: t("resetWarning"),
};
},
},
methods: {
isSlotActive(value: number | undefined): boolean {
if (value === undefined || this.currentLimit === null) {
return false;
}
// Smart feed-in priority: pause when rates are above or equal to limit
return value >= this.currentLimit;
},
async saveLimit(limit: string) {
const url = `loadpoints/${this.loadpointId}/smartfeedinprioritylimit`;
if (limit === "null") {
await api.delete(url);
} else {
await api.post(`${url}/${encodeURIComponent(limit)}`);
}
},
async deleteLimit() {
const url = `loadpoints/${this.loadpointId}/smartfeedinprioritylimit`;
await api.delete(url);
},
async applyToAll(selectedLimit: number | null) {
if (selectedLimit === null) {
await api.delete("smartfeedinprioritylimit");
} else {
await api.post(`smartfeedinprioritylimit/${encodeURIComponent(selectedLimit)}`);
}
},
},
});
</script>

View file

@ -0,0 +1,394 @@
<template>
<div v-if="possible">
<h6 v-if="title" class="mt-0">{{ title }}</h6>
<p>
{{ description }}
</p>
<div class="row mb-3">
<label :for="formId" class="col-sm-4 col-form-label pt-0 pt-sm-2">
{{ limitLabel }}
</label>
<div class="col-sm-8 col-lg-4 pe-0">
<select
:id="formId"
v-model.number="selectedLimit"
class="form-select form-select-sm mb-1"
@change="changeLimit"
>
<option value="null">{{ $t("smartCost.none") }}</option>
<option v-for="{ value, name } in limitOptions" :key="value" :value="value">
{{ name }}
</option>
</select>
</div>
<div
v-if="applyToAllVisible"
class="col-sm-8 offset-sm-4 offset-lg-0 col-lg-4 d-flex align-items-baseline"
>
<div class="text-primary">{{ $t("smartCost.saved") }}</div>
<button class="ms-1 btn btn-sm btn-link text-muted p-0" @click="applyToAll">
{{ $t("smartCost.applyToAll") }}
</button>
</div>
</div>
<div class="justify-content-between mb-2 d-flex justify-content-between">
<div class="text-start">
<div class="label">
{{ activeHoursLabel }}
</div>
<div class="value" :class="activeHoursClass">
{{ activeHoursText }}
</div>
</div>
<div class="text-end">
<div class="label">
<span v-if="activeSlot">{{ activeSlotName }}</span>
<span v-else>{{ currentPriceLabel }}</span>
</div>
<div v-if="activeSlot" class="value text-primary">
{{ activeSlotCost }}
</div>
<div v-else-if="activeSlots.length" class="value text-primary">
{{ fmtActiveCostRange }}
</div>
<div v-else class="value value-inactive">
{{ fmtTotalCostRange }}
</div>
</div>
</div>
<TariffChart
v-if="rates.length"
:slots="slots"
@slot-hovered="slotHovered"
@slot-selected="slotSelected"
/>
</div>
<div v-else-if="currentLimit !== null">
<div class="alert alert-warning">
{{ resetWarningText }}
<a href="#" class="text-warning" @click.prevent="resetLimit">
{{ $t("smartCost.resetAction") }}
</a>
</div>
</div>
</template>
<script lang="ts">
import formatter from "@/mixins/formatter";
import TariffChart from "./TariffChart.vue";
import { defineComponent, type PropType } from "vue";
import { type CURRENCY, type Rate, type SelectOption, type Slot } from "@/types/evcc";
type LimitDirection = "above" | "below";
type HighlightColor = "text-primary" | "text-warning";
export default defineComponent({
name: "SmartTariffBase",
components: { TariffChart },
mixins: [formatter],
props: {
currentLimit: {
type: [Number, null] as PropType<number | null>,
required: true,
},
isCo2: Boolean,
currency: String as PropType<CURRENCY>,
applyAll: Boolean,
possible: Boolean,
tariff: Array,
formId: String,
title: String,
description: String,
limitLabel: String,
optionsExtraHigh: Boolean,
optionsStartAtZero: Boolean,
activeHoursLabel: { type: String, required: true },
currentPriceLabel: String,
resetWarningText: String,
limitDirection: { type: String as PropType<LimitDirection>, default: "below" },
highlightColor: { type: String as PropType<HighlightColor>, default: "text-primary" },
isSlotActive: {
type: Function as PropType<(value: number | undefined) => boolean>,
required: true,
},
},
emits: ["save-limit", "delete-limit", "apply-to-all"],
data() {
return {
selectedLimit: null as number | null,
activeIndex: null as number | null,
applyToAllVisible: false,
};
},
computed: {
rates(): Rate[] {
if (this.tariff?.length) {
return this.tariff.map((slot: any) => ({
start: new Date(slot.start),
end: new Date(slot.end),
value: slot.value,
}));
}
return [];
},
limitOptions(): SelectOption<number>[] {
const { max } = this.optionsCostRange;
const values = [] as number[];
const stepSize = this.optionStepSize;
for (let i = 1; i <= 100; i++) {
const value = this.optionStartValue + stepSize * i;
if (max !== undefined && value > max + stepSize) break;
values.push(this.roundLimit(value) as number);
}
// add special entry if currently selected value is not in the scale
const selected = this.selectedLimit;
if (selected && !values.includes(selected)) {
values.push(selected);
}
values.sort((a, b) => a - b);
return values.map((value) => ({ value, name: this.formatLimit(value) }));
},
optionStartValue() {
if (!this.rates?.length) {
return 0;
}
const { min } = this.optionsCostRange;
const stepSize = this.optionStepSize;
// always show some negative values for price
const start = this.optionsStartAtZero ? 0 : stepSize * -11;
const minValue = min !== undefined ? Math.min(start, min) : start;
return Math.floor(minValue / stepSize) * stepSize;
},
optionStepSize() {
if (!this.rates?.length) {
return 0.001;
}
const { min, max } = this.optionsCostRange;
if (min === undefined || max === undefined) {
return 0.001;
}
const baseSteps = [0.001, 0.002, 0.005];
const range = max - Math.min(0, min);
for (let scale = 1; scale <= 10000; scale *= 10) {
for (const baseStep of baseSteps) {
const step = baseStep * scale;
if (range < step * 100) return step;
}
}
return 1;
},
optionsCostRange() {
const { min, max } = this.costRange(this.totalSlots);
if (this.optionsExtraHigh && max) {
return { min, max: max * 2 };
}
return { min, max };
},
slots(): Slot[] {
if (!this.rates?.length) {
return [];
}
const result = [] as Slot[];
const rates = this.rates;
const startTime = new Date();
const oneHour = 3600000;
for (let i = 0; i < 42; i++) {
const start = new Date(startTime.getTime() + oneHour * i);
const startHour = start.getHours();
start.setMinutes(0);
start.setSeconds(0);
start.setMilliseconds(0);
const end = new Date(start.getTime());
end.setHours(startHour + 1);
const endHour = end.getHours();
const day = this.weekdayShort(start);
const value = this.findRateInRange(start, end, rates)?.value;
const active =
this.limitDirection === "below" &&
this.selectedLimit !== null &&
value !== undefined &&
value <= this.selectedLimit;
const warning =
this.limitDirection === "above" &&
this.selectedLimit !== null &&
value !== undefined &&
value >= this.selectedLimit;
const selectable = value !== undefined;
result.push({
day,
value,
startHour,
endHour,
charging: active,
selectable,
warning,
});
}
return result;
},
totalSlots() {
return this.slots.filter((s) => s.value !== undefined);
},
activeSlots() {
return this.totalSlots.filter((s) => s.charging);
},
warningSlots() {
return this.totalSlots.filter((s) => s.warning);
},
fmtTotalCostRange() {
return this.fmtCostRange(this.costRange(this.totalSlots));
},
fmtActiveCostRange() {
return this.fmtCostRange(this.costRange(this.activeSlots));
},
activeSlot(): Slot | null {
return this.activeIndex !== null ? this.slots[this.activeIndex] : null;
},
activeSlotCost() {
const value = this.activeSlot?.value;
if (value === undefined) {
return this.$t("main.targetChargePlan.unknownPrice");
}
return this.formatValue(value);
},
activeSlotName() {
if (this.activeSlot) {
const { day, startHour, endHour } = this.activeSlot;
const range = `${startHour}–${endHour}`;
return this.$t("main.targetChargePlan.timeRange", { day, range });
}
return null;
},
activeHoursClass() {
if (this.limitDirection === "below") {
return this.activeSlots.length ? "text-primary" : "value-inactive";
}
return this.warningSlots.length ? "text-warning" : "value-inactive";
},
activeHoursText() {
const params = {
active:
this.limitDirection === "below"
? this.activeSlots.length
: this.warningSlots.length,
total: this.totalSlots.length,
};
return this.$t("smartCost.activeHours", params);
},
limitOperator() {
return this.limitDirection === "below" ? "≤" : "≥";
},
},
watch: {
currentLimit(limit) {
this.selectedLimit = this.roundLimit(limit);
},
},
mounted() {
this.selectedLimit = this.roundLimit(this.currentLimit);
},
methods: {
roundLimit(limit: number | null): number | null {
return limit === null ? null : Math.round(limit * 1000) / 1000;
},
formatLimit(limit: number | null): string {
if (limit === null) {
return this.$t("smartCost.none");
}
return `${this.limitOperator} ${this.formatValue(limit)}`;
},
formatValue(value: number): string {
if (this.isCo2) {
return this.fmtCo2Medium(value);
}
return this.fmtPricePerKWh(value, this.currency);
},
findRateInRange(start: Date, end: Date, rates: Rate[]) {
return rates.find((r) => {
if (r.start.getTime() < start.getTime()) {
return r.end.getTime() > start.getTime();
}
return r.start.getTime() < end.getTime();
});
},
costRange(slots: Slot[]): { min: number | undefined; max: number | undefined } {
let min = undefined as number | undefined;
let max = undefined as number | undefined;
slots.forEach((slot) => {
if (slot.value === undefined) return;
min = min === undefined ? slot.value : Math.min(min, slot.value);
max = max === undefined ? slot.value : Math.max(max, slot.value);
});
return { min, max };
},
fmtCostRange({ min, max }: { min: number | undefined; max: number | undefined }): string {
if (min === undefined || max === undefined) return "";
const fmtMin = this.formatShortValue(min);
const fmtMax = this.formatShortValue(max);
return `${fmtMin} – ${fmtMax}`;
},
formatShortValue(value: number): string {
if (this.isCo2) {
return this.fmtCo2Short(value);
}
return this.fmtPricePerKWh(value, this.currency, true);
},
slotHovered(index: number) {
this.activeIndex = index;
},
slotSelected(index: number) {
const value = this.slots[index].value;
if (value !== undefined) {
// 3 decimal precision
const valueRounded = Math.ceil(value * 1000) / 1000;
this.selectedLimit = valueRounded;
this.saveLimit(`${valueRounded}`);
}
},
changeLimit($event: Event) {
const value = ($event.target as HTMLSelectElement).value;
if (value === "null") {
this.resetLimit();
} else {
this.saveLimit(value);
}
},
saveLimit(limit: string) {
this.$emit("save-limit", limit);
if (this.applyAll) {
this.applyToAllVisible = true;
}
},
resetLimit() {
this.$emit("delete-limit");
if (this.applyAll) {
this.applyToAllVisible = true;
}
},
applyToAll() {
this.$emit("apply-to-all", this.selectedLimit);
this.applyToAllVisible = false;
},
},
});
</script>
<style scoped>
.value {
font-size: 18px;
font-weight: bold;
}
.label {
color: var(--evcc-gray);
text-transform: uppercase;
}
.value-inactive {
color: var(--evcc-gray);
}
</style>

View file

@ -8,122 +8,20 @@
{{ chargerStatus }}
</div>
<div class="d-flex flex-wrap justify-content-end gap-3 flex-grow-1">
<!-- pv/phase timer -->
<div
v-if="pvTimerVisible"
ref="pvTimer"
class="entry"
data-testid="vehicle-status-pvtimer"
data-bs-toggle="tooltip"
<StatusItem
v-for="item in statusItems"
:key="item.id"
:content="item.content"
:tooltip-content="item.tooltipContent"
:icon-component="item.iconComponent"
:icon-class="item.iconClass"
:data-testid="item.testId"
:class="item.itemClass"
:clickable="item.clickable"
@click="item.clickHandler"
>
<SunUpIcon v-if="pvAction === 'enable'" />
<SunDownIcon v-else />
<div class="tabular">{{ fmtDuration(pvRemainingInterpolated) }}</div>
</div>
<div
v-else-if="phaseTimerVisible"
ref="phaseTimer"
class="entry"
data-testid="vehicle-status-phasetimer"
data-bs-toggle="tooltip"
>
<shopicon-regular-angledoublerightsmall
:class="phaseIconClass"
></shopicon-regular-angledoublerightsmall>
<div class="tabular">{{ fmtDuration(phaseRemainingInterpolated) }}</div>
</div>
<template v-if="heating">
<div
v-if="tempLimitVisible"
ref="tempLimit"
class="entry"
data-bs-toggle="tooltip"
data-testid="vehicle-status-limit"
>
<TempLimitIcon />
{{ fmtTemperature(vehicleLimitSoc) }}
</div>
</template>
<template v-else>
<!-- vehicle -->
<button
v-if="minSocVisible"
ref="minSoc"
type="button"
class="entry text-danger text-decoration-underline"
data-testid="vehicle-status-minsoc"
data-bs-toggle="tooltip"
@click="openMinSocSettings"
>
<VehicleMinSocIcon />
{{ fmtPercentage(minSoc) }}
</button>
<div
v-else-if="vehicleLimitVisible"
ref="vehicleLimit"
class="entry"
:class="vehicleLimitClass"
data-bs-toggle="tooltip"
data-testid="vehicle-status-limit"
:role="vehicleLimitWarning ? 'button' : undefined"
@click="vehicleLimitClicked"
>
<component :is="vehicleLimitIconComponent" />
{{ fmtPercentage(vehicleLimitSoc) }}
</div>
<div
v-if="vehicleClimaterActive"
ref="vehicleClimater"
data-bs-toggle="tooltip"
class="entry"
data-testid="vehicle-status-climater"
>
<ClimaterIcon />
</div>
<div
v-if="vehicleWelcomeActive"
ref="vehicleWelcome"
data-bs-toggle="tooltip"
class="entry"
data-testid="vehicle-status-welcome"
>
<WelcomeIcon />
</div>
<div
v-if="awaitingAuthorizationVisible"
ref="awaitingAuthorization"
data-bs-toggle="tooltip"
class="entry"
data-testid="vehicle-status-awaiting-authorization"
>
<RfidWaitIcon />
</div>
<div
v-if="disconnectRequiredVisible"
ref="disconnectRequired"
class="entry text-warning"
data-bs-toggle="tooltip"
data-testid="vehicle-status-disconnect-required"
>
<ReconnectIcon />
</div>
</template>
<!-- smart cost -->
<button
v-if="smartCostVisible"
ref="smartCost"
type="button"
class="entry"
:class="smartCostClass"
data-testid="vehicle-status-smartcost"
data-bs-toggle="tooltip"
data-bs-trigger="hover"
@click="smartCostClicked"
>
<DynamicPriceIcon v-if="smartCostPrice" />
<shopicon-regular-eco1 v-else></shopicon-regular-eco1>
<!-- items with complex content -->
<template v-if="item.id === 'smartCost'" #default>
<div>
<span v-if="smartCostNowVisible">{{ smartCostNow }}</span>
≤ <span class="text-decoration-underline">{{ smartCostLimitFmt }}</span>
@ -131,48 +29,20 @@
({{ fmtAbsoluteDate(new Date(smartCostNextStart)) }})
</span>
</div>
</button>
<!-- battery boost -->
<button
v-if="batteryBoostVisible"
ref="batteryBoost"
type="button"
class="entry"
data-testid="vehicle-status-batteryboost"
data-bs-toggle="tooltip"
@click="batteryBoostClicked"
>
<BatteryBoostIcon />
</button>
<!-- plan -->
<button
v-if="planActiveVisible"
ref="planActive"
type="button"
class="entry"
:class="planActiveClass"
data-testid="vehicle-status-planactive"
data-bs-toggle="tooltip"
@click="planActiveClicked"
>
<PlanEndIcon />
<span>
{{ planProjectedEnd ? fmtAbsoluteDate(new Date(planProjectedEnd)) : "" }}
</template>
<template v-else-if="item.id === 'smartFeedInPriority'" #default>
<div>
<span v-if="smartFeedInPriorityActive">{{ feedInNow }}</span>
≥
<span class="text-decoration-underline">{{
smartFeedInPriorityLimitFmt
}}</span>
<span v-if="smartFeedInPriorityNextStart">
({{ fmtAbsoluteDate(new Date(smartFeedInPriorityNextStart)) }})
</span>
</button>
<button
v-else-if="planStartVisible"
ref="planStart"
type="button"
class="entry"
data-testid="vehicle-status-planstart"
data-bs-toggle="tooltip"
@click="planStartClicked"
>
<PlanStartIcon />
{{ planProjectedStart ? fmtAbsoluteDate(new Date(planProjectedStart)) : "" }}
</button>
</div>
</template>
</StatusItem>
</div>
</div>
</template>
@ -182,10 +52,13 @@ import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/eco1";
import "@h2d2/shopicons/es/regular/angledoublerightsmall";
import "@h2d2/shopicons/es/regular/clock";
import DynamicPriceIcon from "../MaterialIcon/DynamicPrice.vue";
import { DEFAULT_LOCALE } from "@/i18n.ts";
import formatter from "@/mixins/formatter";
import { defineComponent, type PropType } from "vue";
import { SMART_COST_TYPE, type CURRENCY, type Timeout } from "@/types/evcc";
import ClimaterIcon from "../MaterialIcon/Climater.vue";
import DynamicPriceIcon from "../MaterialIcon/DynamicPrice.vue";
import PlanEndIcon from "../MaterialIcon/PlanEnd.vue";
import PlanStartIcon from "../MaterialIcon/PlanStart.vue";
import ReconnectIcon from "../MaterialIcon/Reconnect.vue";
@ -193,34 +66,23 @@ import RfidWaitIcon from "../MaterialIcon/RfidWait.vue";
import SunDownIcon from "../MaterialIcon/SunDown.vue";
import SunUpIcon from "../MaterialIcon/SunUp.vue";
import TempLimitIcon from "../MaterialIcon/TempLimit.vue";
import Tooltip from "bootstrap/js/dist/tooltip";
import VehicleLimitIcon from "../MaterialIcon/VehicleLimit.vue";
import VehicleLimitReachedIcon from "../MaterialIcon/VehicleLimitReached.vue";
import VehicleLimitWarningIcon from "../MaterialIcon/VehicleLimitWarning.vue";
import VehicleMinSocIcon from "../MaterialIcon/VehicleMinSoc.vue";
import WelcomeIcon from "../MaterialIcon/Welcome.vue";
import BatteryBoostIcon from "../MaterialIcon/BatteryBoost.vue";
import { defineComponent, type PropType } from "vue";
import { SMART_COST_TYPE, type CURRENCY, type Timeout } from "@/types/evcc";
import SunPauseIcon from "../MaterialIcon/SunPause.vue";
import StatusItem from "./StatusItem.vue";
const REASON_AUTH = "waitingforauthorization";
const REASON_DISCONNECT = "disconnectrequired";
export default defineComponent({
name: "VehicleStatus",
components: {
ClimaterIcon,
DynamicPriceIcon,
PlanEndIcon,
PlanStartIcon,
ReconnectIcon,
RfidWaitIcon,
SunDownIcon,
SunUpIcon,
VehicleLimitIcon,
VehicleMinSocIcon,
WelcomeIcon,
TempLimitIcon,
BatteryBoostIcon,
StatusItem,
},
mixins: [formatter],
props: {
@ -251,8 +113,13 @@ export default defineComponent({
smartCostLimit: { type: Number, default: null },
smartCostNextStart: String,
smartCostType: String,
smartFeedInPriorityActive: Boolean,
smartFeedInPriorityDisabled: Boolean,
smartFeedInPriorityLimit: { type: Number, default: null },
smartFeedInPriorityNextStart: String,
tariffCo2: { type: Number, default: 0 },
tariffGrid: { type: Number, default: 0 },
tariffFeedIn: { type: Number, default: 0 },
vehicleClimaterActive: Boolean,
vehicleWelcomeActive: Boolean,
vehicleLimitSoc: { type: Number, default: 0 },
@ -260,33 +127,10 @@ export default defineComponent({
emits: ["open-loadpoint-settings", "open-minsoc-settings", "open-plan-modal"],
data() {
return {
pvTooltip: null as Tooltip | null,
phaseTooltip: null as Tooltip | null,
planStartTooltip: null as Tooltip | null,
planActiveTooltip: null as Tooltip | null,
minSocTooltip: null as Tooltip | null,
vehicleClimaterTooltip: null as Tooltip | null,
vehicleWelcomeTooltip: null as Tooltip | null,
smartCostTooltip: null as Tooltip | null,
vehicleLimitTooltip: null as Tooltip | null,
tempLimitTooltip: null as Tooltip | null,
awaitingAuthorizationTooltip: null as Tooltip | null,
disconnectRequiredTooltip: null as Tooltip | null,
batteryBoostTooltip: null as Tooltip | null,
interval: null as Timeout,
planProjectedEndDuration: null as string | null,
smartCostNextStartDuration: null as string | null,
planProjectedStartDuration: null as string | null,
};
},
computed: {
phaseTimerActive() {
return (
this.phaseRemainingInterpolated &&
this.phaseRemainingInterpolated > 0 &&
["scale1p", "scale3p"].includes(this.phaseAction)
);
},
pvTimerActive() {
return (
this.pvRemainingInterpolated &&
@ -294,78 +138,12 @@ export default defineComponent({
["enable", "disable"].includes(this.pvAction)
);
},
pvTimerVisible() {
return this.pvTimerActive;
},
pvTimerContent() {
if (!this.pvTimerVisible) {
return "";
}
const key = this.pvAction === "enable" ? "pvEnable" : "pvDisable";
return this.$t(`main.vehicleStatus.${key}`);
},
phaseTimerVisible() {
return !this.pvTimerActive && this.charging && this.phaseTimerActive;
},
phaseIconClass() {
return this.phaseAction === "scale1p" ? "phaseUp" : "phaseDown";
},
phaseTimerContent() {
if (!this.phaseTimerVisible) {
return "";
}
return this.$t(`main.vehicleStatus.${this.phaseAction}`);
},
vehicleLimitVisible() {
const limit = this.effectiveLimitSoc || 100;
return this.connected && this.vehicleLimitSoc > 0 && this.vehicleLimitSoc < limit;
},
awaitingAuthorizationVisible() {
return this.chargerStatusReason === REASON_AUTH;
},
awaitingAuthorizationTooltipContent() {
if (!this.awaitingAuthorizationVisible) {
return "";
}
return this.$t("main.vehicleStatus.awaitingAuthorization");
},
disconnectRequiredVisible() {
return this.chargerStatusReason === REASON_DISCONNECT;
},
disconnectRequiredTooltipContent() {
if (!this.disconnectRequiredVisible) {
return "";
}
return this.$t("main.vehicleStatus.disconnectRequired");
},
vehicleLimitTooltipContent() {
if (!this.vehicleLimitVisible) {
return "";
}
if (this.vehicleLimitReached) {
return this.$t("main.vehicleStatus.vehicleLimitReached");
}
if (this.vehicleLimitWarning) {
return this.$t("main.targetCharge.targetIsAboveVehicleLimit");
}
return this.$t("main.vehicleStatus.vehicleLimit");
},
tempLimitTooltipContent() {
if (!this.tempLimitVisible) {
return "";
}
return this.$t("main.heatingStatus.vehicleLimit");
},
minSocVisible() {
return this.connected && this.minSoc > 0 && this.vehicleSoc < this.minSoc;
},
minSocTooltipContent() {
if (!this.minSocVisible) {
return "";
}
return this.$t("main.vehicleStatus.minCharge", {
soc: this.fmtPercentage(this.minSoc),
});
phaseTimerActive() {
return (
this.phaseRemainingInterpolated &&
this.phaseRemainingInterpolated > 0 &&
["scale1p", "scale3p"].includes(this.phaseAction)
);
},
vehicleLimitReached() {
return (
@ -378,81 +156,6 @@ export default defineComponent({
vehicleLimitWarning() {
return this.effectivePlanSoc > this.vehicleLimitSoc;
},
vehicleLimitClass() {
if (this.vehicleLimitWarning) {
return "text-warning";
}
return "";
},
tempLimitVisible() {
return this.heating && this.vehicleLimitSoc > 0;
},
vehicleLimitIconComponent() {
if (this.vehicleLimitReached) {
return VehicleLimitReachedIcon;
}
if (this.vehicleLimitWarning) {
return VehicleLimitWarningIcon;
}
return VehicleLimitIcon;
},
planStartVisible() {
return this.planProjectedStart && !this.planActive && !this.chargingPlanDisabled;
},
batteryBoostVisible() {
return this.batteryBoostActive;
},
batteryBoostTooltipContent() {
if (!this.batteryBoostVisible) {
return "";
}
return this.$t("main.vehicleStatus.batteryBoost");
},
planStartTooltipContent() {
if (!this.planStartVisible) {
return "";
}
return this.$t("main.vehicleStatus.targetChargePlanned", {
duration: this.planProjectedStartDuration,
});
},
planActiveVisible() {
return this.planProjectedEnd && this.planActive && !this.chargingPlanDisabled;
},
planActiveClass() {
return this.planTimeUnreachable ? "text-warning" : "text-primary";
},
planActiveTooltipContent() {
if (!this.planActiveVisible) {
return "";
}
if (this.planTimeUnreachable) {
return this.$t("main.targetCharge.notReachableInTime", {
overrun: this.fmtDuration(this.planOverrun, true, "h"),
});
}
return this.$t("main.vehicleStatus.targetChargeActive", {
duration: this.planProjectedEndDuration,
});
},
smartCostVisible() {
return this.smartCostLimit !== null;
},
smartCostTooltipContent() {
if (!this.smartCostVisible) {
return "";
}
const prefix = `main.vehicleStatus.${this.smartCostPrice ? "cheap" : "clean"}`;
if (this.smartCostNowVisible) {
return this.$t(`${prefix}EnergyCharging`);
}
if (this.smartCostNextStart) {
return this.$t(`${prefix}EnergyNextStart`, {
duration: this.smartCostNextStartDuration,
});
}
return this.$t(`${prefix}EnergySet`);
},
smartCostPrice() {
return this.smartCostType !== SMART_COST_TYPE.CO2;
},
@ -474,26 +177,11 @@ export default defineComponent({
}
return this.fmtCo2Short(this.smartCostLimit);
},
smartCostClass() {
if (this.smartCostDisabled) {
return "opacity-25";
}
if (this.smartCostActive) {
return "text-primary";
}
return "";
feedInNow() {
return this.fmtPricePerKWh(this.tariffFeedIn, this.currency, true);
},
vehicleClimaterTooltipContent() {
if (!this.vehicleClimaterActive) {
return "";
}
return this.$t("main.vehicleStatus.climating");
},
vehicleWelcomeTooltipContent() {
if (!this.vehicleWelcomeActive) {
return "";
}
return this.$t("main.vehicleStatus.welcome");
smartFeedInPriorityLimitFmt() {
return this.fmtPricePerKWh(this.smartFeedInPriorityLimit, this.currency, true);
},
chargerStatus() {
const t = (key: string) => {
@ -524,74 +212,190 @@ export default defineComponent({
return t("connected");
},
statusItems() {
const t = (key: string, params?: Record<string, unknown>) =>
this.$t(`main.vehicleStatus.${key}`, params ?? {});
const items = [
{
id: "pvTimer",
visible: Boolean(this.pvTimerActive),
content: this.fmtDuration(this.pvRemainingInterpolated),
tooltipContent: this.pvAction === "enable" ? t("pvEnable") : t("pvDisable"),
iconComponent: this.pvAction === "enable" ? SunUpIcon : SunDownIcon,
testId: "vehicle-status-pvtimer",
},
watch: {
planActiveTooltipContent() {
this.$nextTick(this.updatePlanActiveTooltip);
{
id: "phaseTimer",
visible: !this.pvTimerActive && this.charging && this.phaseTimerActive,
content: this.fmtDuration(this.phaseRemainingInterpolated),
tooltipContent: t(this.phaseAction),
iconComponent: "shopicon-regular-angledoublerightsmall",
iconClass: this.phaseAction === "scale1p" ? "phaseUp" : "phaseDown",
testId: "vehicle-status-phasetimer",
},
planStartTooltipContent() {
this.$nextTick(this.updatePlanStartTooltip);
{
id: "tempLimit",
visible: this.heating && this.vehicleLimitSoc > 0,
content: this.fmtTemperature(this.vehicleLimitSoc),
tooltipContent: t("vehicleLimit"),
iconComponent: TempLimitIcon,
testId: "vehicle-status-limit",
},
minSocTooltipContent() {
this.$nextTick(this.updateMinSocTooltip);
{
id: "minSoc",
visible:
!this.heating &&
this.connected &&
this.minSoc > 0 &&
this.vehicleSoc < this.minSoc,
content: this.fmtPercentage(this.minSoc),
tooltipContent: t("minCharge", {
soc: this.fmtPercentage(this.minSoc),
}),
iconComponent: VehicleMinSocIcon,
itemClass: "text-danger text-decoration-underline",
testId: "vehicle-status-minsoc",
clickable: true,
clickHandler: () => this.openMinSocSettings(),
},
phaseTimerContent() {
this.$nextTick(this.updatePhaseTooltip);
{
id: "vehicleLimit",
visible:
!this.heating &&
this.connected &&
this.vehicleLimitSoc > 0 &&
this.vehicleLimitSoc < (this.effectiveLimitSoc || 100),
content: this.fmtPercentage(this.vehicleLimitSoc),
tooltipContent: this.vehicleLimitReached
? t("vehicleLimitReached")
: this.vehicleLimitWarning
? t("targetIsAboveVehicleLimit")
: t("vehicleLimit"),
iconComponent: this.vehicleLimitReached
? VehicleLimitReachedIcon
: this.vehicleLimitWarning
? VehicleLimitWarningIcon
: VehicleLimitIcon,
itemClass: this.vehicleLimitWarning ? "text-warning" : "",
testId: "vehicle-status-limit",
clickable: Boolean(this.vehicleLimitWarning),
clickHandler: this.vehicleLimitWarning ? () => this.openPlanModal() : undefined,
},
pvTimerContent() {
this.$nextTick(this.updatePvTooltip);
{
id: "vehicleClimater",
visible: !this.heating && this.vehicleClimaterActive,
tooltipContent: t("climating"),
iconComponent: ClimaterIcon,
testId: "vehicle-status-climater",
},
vehicleClimaterTooltipContent() {
this.$nextTick(this.updateVehicleClimaterTooltip);
{
id: "vehicleWelcome",
visible: !this.heating && this.vehicleWelcomeActive,
tooltipContent: t("welcome"),
iconComponent: WelcomeIcon,
testId: "vehicle-status-welcome",
},
vehicleWelcomeTooltipContent() {
this.$nextTick(this.updateVehicleWelcomeTooltip);
{
id: "awaitingAuthorization",
visible: !this.heating && this.chargerStatusReason === REASON_AUTH,
tooltipContent: t("awaitingAuthorization"),
iconComponent: RfidWaitIcon,
testId: "vehicle-status-awaiting-authorization",
},
smartCostTooltipContent() {
this.$nextTick(this.updateSmartCostTooltip);
{
id: "disconnectRequired",
visible: !this.heating && this.chargerStatusReason === REASON_DISCONNECT,
tooltipContent: t("disconnectRequired"),
iconComponent: ReconnectIcon,
itemClass: "text-warning",
testId: "vehicle-status-disconnect-required",
},
vehicleLimitTooltipContent() {
this.$nextTick(this.updateVehicleLimitTooltip);
{
id: "smartCost",
visible: this.smartCostLimit !== null,
tooltipContent: this.getSmartCostTooltip(),
iconComponent: this.smartCostPrice ? DynamicPriceIcon : "shopicon-regular-eco1",
itemClass: this.smartCostDisabled
? "opacity-25"
: this.smartCostActive
? "text-primary"
: "",
testId: "vehicle-status-smartcost",
clickable: true,
clickHandler: () => this.openLoadpointSettings(),
},
tempLimitTooltipContent() {
this.$nextTick(this.updateTempLimitTooltip);
{
id: "smartFeedInPriority",
visible: this.smartFeedInPriorityActive || this.smartFeedInPriorityNextStart,
tooltipContent: this.getSmartFeedInPriorityTooltip(),
iconComponent: SunPauseIcon,
itemClass: this.smartFeedInPriorityDisabled
? "opacity-25"
: this.smartFeedInPriorityActive
? "text-warning"
: "",
testId: "vehicle-status-smartfeedinpriority",
clickable: true,
clickHandler: () => this.openLoadpointSettings(),
},
awaitingAuthorizationTooltipContent() {
this.$nextTick(this.updateAwaitingAuthorizationTooltip);
{
id: "batteryBoost",
visible: this.batteryBoostActive,
tooltipContent: t("batteryBoost"),
iconComponent: BatteryBoostIcon,
testId: "vehicle-status-batteryboost",
clickable: true,
clickHandler: () => this.openLoadpointSettings(),
},
disconnectRequiredTooltipContent() {
this.$nextTick(this.updateDisconnectRequiredTooltip);
{
id: "planActive",
visible: this.planProjectedEnd && this.planActive && !this.chargingPlanDisabled,
content: this.planProjectedEnd
? this.fmtAbsoluteDate(new Date(this.planProjectedEnd))
: "",
tooltipContent: this.planTimeUnreachable
? this.$t("main.targetCharge.notReachableInTime", {
overrun: this.fmtDuration(this.planOverrun, true, "h"),
})
: t("targetChargeActive", {
duration: this.planProjectedEnd
? this.fmtDurationToTime(new Date(this.planProjectedEnd))
: "",
}),
iconComponent: PlanEndIcon,
itemClass: this.planTimeUnreachable ? "text-warning" : "text-primary",
testId: "vehicle-status-planactive",
clickable: true,
clickHandler: () => this.openPlanModal(),
},
batteryBoostTooltipContent() {
this.$nextTick(this.updateBatteryBoostTooltip);
{
id: "planStart",
visible:
this.planProjectedStart && !this.planActive && !this.chargingPlanDisabled,
content: this.planProjectedStart
? this.fmtAbsoluteDate(new Date(this.planProjectedStart))
: "",
tooltipContent: t("targetChargePlanned", {
duration: this.planProjectedStart
? this.fmtDurationToTime(new Date(this.planProjectedStart))
: "",
}),
iconComponent: PlanStartIcon,
testId: "vehicle-status-planstart",
clickable: true,
clickHandler: () => this.openPlanModal(),
},
planProjectedStart() {
this.updateDurations();
},
planProjectedEnd() {
this.updateDurations();
},
smartCostNextStart() {
this.updateDurations();
];
return items.filter((item) => item.visible);
},
},
mounted() {
this.updatePlanStartTooltip();
this.updatePlanActiveTooltip();
this.updateMinSocTooltip();
this.updatePhaseTooltip();
this.updatePvTooltip();
this.updateVehicleClimaterTooltip();
this.updateVehicleWelcomeTooltip();
this.updateSmartCostTooltip();
this.updateVehicleLimitTooltip();
this.updateTempLimitTooltip();
this.updateAwaitingAuthorizationTooltip();
this.updateDisconnectRequiredTooltip();
this.updateDurations();
this.interval = setInterval(this.updateDurations, 1000 * 60);
this.updateBatteryBoostTooltip();
this.interval = setInterval(() => {
// Force reactivity update for time-dependent content
this.$forceUpdate();
}, 1000 * 60);
},
beforeUnmount() {
if (this.interval) {
@ -599,167 +403,38 @@ export default defineComponent({
}
},
methods: {
updateDurations() {
if (this.planProjectedStart) {
this.planProjectedStartDuration = this.fmtDurationToTime(
new Date(this.planProjectedStart)
);
}
if (this.planProjectedEnd) {
this.planProjectedEndDuration = this.fmtDurationToTime(
new Date(this.planProjectedEnd)
);
}
if (this.smartCostNextStart) {
this.smartCostNextStartDuration = this.fmtDurationToTime(
new Date(this.smartCostNextStart)
);
}
},
openLoadpointSettings() {
this.$emit("open-loadpoint-settings");
},
openMinSocSettings() {
this.minSocTooltip?.hide();
this.$emit("open-minsoc-settings");
},
planStartClicked() {
this.planStartTooltip?.hide();
openPlanModal() {
this.$emit("open-plan-modal");
},
planActiveClicked() {
this.planActiveTooltip?.hide();
this.$emit("open-plan-modal");
},
vehicleLimitClicked() {
if (this.vehicleLimitWarning) {
this.vehicleLimitTooltip?.hide();
this.$emit("open-plan-modal");
getSmartCostTooltip() {
const prefix = `main.vehicleStatus.${this.smartCostPrice ? "cheap" : "clean"}`;
if (this.smartCostNowVisible) {
return this.$t(`${prefix}EnergyCharging`);
}
},
smartCostClicked() {
this.openLoadpointSettings();
this.smartCostTooltip?.hide();
},
batteryBoostClicked() {
this.batteryBoostTooltip?.hide();
this.openLoadpointSettings();
},
updatePvTooltip() {
this.pvTooltip = this.updateTooltip(
this.pvTooltip,
this.pvTimerContent,
this.$refs["pvTimer"]
);
},
updatePhaseTooltip() {
this.phaseTooltip = this.updateTooltip(
this.phaseTooltip,
this.phaseTimerContent,
this.$refs["phaseTimer"]
);
},
updatePlanStartTooltip() {
this.planStartTooltip = this.updateTooltip(
this.planStartTooltip,
this.planStartTooltipContent,
this.$refs["planStart"],
true
);
},
updatePlanActiveTooltip() {
this.planActiveTooltip = this.updateTooltip(
this.planActiveTooltip,
this.planActiveTooltipContent,
this.$refs["planActive"],
true
);
},
updateMinSocTooltip() {
this.minSocTooltip = this.updateTooltip(
this.minSocTooltip,
this.minSocTooltipContent,
this.$refs["minSoc"],
true
);
},
updateVehicleClimaterTooltip() {
this.vehicleClimaterTooltip = this.updateTooltip(
this.vehicleClimaterTooltip,
this.vehicleClimaterTooltipContent,
this.$refs["vehicleClimater"]
);
},
updateBatteryBoostTooltip() {
this.batteryBoostTooltip = this.updateTooltip(
this.batteryBoostTooltip,
this.batteryBoostTooltipContent,
this.$refs["batteryBoost"]
);
},
updateVehicleWelcomeTooltip() {
this.vehicleWelcomeTooltip = this.updateTooltip(
this.vehicleWelcomeTooltip,
this.vehicleWelcomeTooltipContent,
this.$refs["vehicleWelcome"]
);
},
updateSmartCostTooltip() {
this.smartCostTooltip = this.updateTooltip(
this.smartCostTooltip,
this.smartCostTooltipContent,
this.$refs["smartCost"],
true
);
},
updateVehicleLimitTooltip() {
this.vehicleLimitTooltip = this.updateTooltip(
this.vehicleLimitTooltip,
this.vehicleLimitTooltipContent,
this.$refs["vehicleLimit"],
true
);
},
updateTempLimitTooltip() {
this.tempLimitTooltip = this.updateTooltip(
this.tempLimitTooltip,
this.tempLimitTooltipContent,
this.$refs["tempLimit"]
);
},
updateAwaitingAuthorizationTooltip() {
this.awaitingAuthorizationTooltip = this.updateTooltip(
this.awaitingAuthorizationTooltip,
this.awaitingAuthorizationTooltipContent,
this.$refs["awaitingAuthorization"]
);
},
updateDisconnectRequiredTooltip() {
this.disconnectRequiredTooltip = this.updateTooltip(
this.disconnectRequiredTooltip,
this.disconnectRequiredTooltipContent,
this.$refs["disconnectRequired"]
);
},
updateTooltip(
instance: Tooltip | null,
content: string,
ref: HTMLElement | undefined,
hoverOnly = false
): Tooltip | null {
if (!content || !ref) {
if (instance) {
instance.dispose();
if (this.smartCostNextStart) {
return this.$t(`${prefix}EnergyNextStart`, {
duration: this.fmtDurationToTime(new Date(this.smartCostNextStart)),
});
}
return null;
return this.$t(`${prefix}EnergySet`);
},
getSmartFeedInPriorityTooltip() {
const prefix = "main.vehicleStatus.feedinPriority";
if (this.smartFeedInPriorityActive) {
return this.$t(`${prefix}Pausing`);
}
let newInstance = instance;
if (!newInstance) {
const trigger = hoverOnly ? "hover" : "hover focus";
newInstance = new Tooltip(ref, { title: " ", trigger });
if (this.smartFeedInPriorityNextStart) {
return this.$t(`${prefix}NextStart`, {
duration: this.fmtDurationToTime(new Date(this.smartFeedInPriorityNextStart)),
});
}
newInstance.setContent({ ".tooltip-inner": content });
return newInstance;
return "";
},
},
});
@ -769,26 +444,7 @@ export default defineComponent({
.charger-status {
padding-top: 2px;
}
.entry {
display: flex;
align-items: center;
flex-wrap: nowrap;
text-wrap: nowrap;
border: none;
color: inherit;
background: none;
padding: 0;
gap: 0.5rem;
transition:
color var(--evcc-transition-medium) linear,
opacity var(--evcc-transition-medium) linear;
}
.phaseUp {
transform: rotate(90deg);
}
.phaseDown {
transform: rotate(-90deg);
}
.tabular {
font-variant-numeric: tabular-nums;
}

View file

@ -0,0 +1,107 @@
<template>
<button v-if="clickable" type="button" class="entry" @click="handleClick">
<component :is="iconComponent" v-if="iconComponent" :class="iconClass" />
<div v-if="hasContent">
<slot>
{{ content }}
</slot>
</div>
</button>
<div v-else class="entry">
<component :is="iconComponent" v-if="iconComponent" :class="iconClass" />
<div v-if="hasContent">
<slot>
{{ content }}
</slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import Tooltip from "bootstrap/js/dist/tooltip";
export default defineComponent({
name: "StatusItem",
props: {
content: { type: String, default: "" },
tooltipContent: { type: String, default: "" },
iconComponent: { type: [String, Object], default: null },
iconClass: { type: String, default: "" },
clickable: { type: Boolean, default: false },
},
emits: ["click"],
data() {
return {
tooltip: null as Tooltip | null,
};
},
computed: {
hasContent() {
return this.content || this.$slots["default"];
},
},
watch: {
tooltipContent() {
this.$nextTick(this.updateTooltip);
},
},
mounted() {
this.updateTooltip();
},
beforeUnmount() {
if (this.tooltip) {
this.tooltip.dispose();
}
},
methods: {
handleClick() {
this.tooltip?.hide();
this.$emit("click");
},
updateTooltip() {
if (!this.tooltipContent || !this.$el) {
if (this.tooltip) {
this.tooltip.dispose();
this.tooltip = null;
}
return;
}
if (!this.tooltip) {
this.tooltip = new Tooltip(this.$el, {
title: " ",
trigger: "hover",
});
}
this.tooltip.setContent({ ".tooltip-inner": this.tooltipContent });
},
},
});
</script>
<style scoped>
.entry {
display: flex;
align-items: center;
flex-wrap: nowrap;
text-wrap: nowrap;
border: none;
color: inherit;
background: none;
padding: 0;
gap: 0.5rem;
transition:
color var(--evcc-transition-medium) linear,
opacity var(--evcc-transition-medium) linear;
}
.phaseUp {
transform: rotate(90deg);
}
.phaseDown {
transform: rotate(-90deg);
}
</style>

View file

@ -129,10 +129,14 @@ export default defineComponent({
smartCostNextStart: String,
smartCostLimit: Number,
smartCostType: String,
smartFeedInPriorityActive: Boolean,
smartFeedInPriorityNextStart: String,
smartFeedInPriorityLimit: Number,
socBasedCharging: Boolean,
socBasedPlanning: Boolean,
tariffCo2: Number,
tariffGrid: Number,
tariffFeedIn: Number,
vehicle: Object as PropType<Vehicle>,
vehicleDetectionActive: Boolean,
vehicleName: String,
@ -222,6 +226,9 @@ export default defineComponent({
smartCostDisabled() {
return this.chargingPlanDisabled;
},
smartFeedInPriorityDisabled() {
return this.chargingPlanDisabled;
},
},
watch: {
effectiveLimitSoc() {

View file

@ -187,6 +187,7 @@ export interface Forecast {
co2?: ForecastSlot[];
solar?: SolarDetails;
planner?: ForecastSlot[];
feedin?: ForecastSlot[];
}
export interface SelectOption<T> {

View file

@ -1,5 +0,0 @@
import { SMART_COST_TYPE } from "@/types/evcc";
export default function (smartCostType?: SMART_COST_TYPE) {
return smartCostType && Object.values(SMART_COST_TYPE).includes(smartCostType);
}

View file

@ -42,8 +42,12 @@ const (
// smart charging
SmartCostActive = "smartCostActive" // smart cost active
SmartCostLimit = "smartCostLimit" // smart cost limit
SmartCostNextStart = "smartCostNextStart" // smart cost next start
SmartCostLimit = "smartCostLimit" // smart cost limit, fast charge when costs are below
SmartCostNextStart = "smartCostNextStart" // smart cost next start, time of next fast charging
SmartFeedInPriorityActive = "smartFeedInPriorityActive" // smart feed-in priority active
SmartFeedInPriorityLimit = "smartFeedInPriorityLimit" // smart feed-in priority limit, pause self-consumption when feed-in rates are above
SmartFeedInPriorityNextStart = "smartFeedInPriorityNextStart" // smart feed-in priority next start, time of next pause
// effective values
EffectivePriority = "effectivePriority" // effective priority

View file

@ -56,4 +56,8 @@ const (
// external battery control
BatteryModeExternal = "batteryModeExternal"
// smart charging
SmartCostAvailable = "smartCostAvailable" // smart cost available
SmartFeedInPriorityAvailable = "smartFeedInPriorityAvailable" // smart feed-in priority available
)

View file

@ -114,7 +114,8 @@ type Loadpoint struct {
phasesConfigured int // Charger configured phase mode 0/1/3
limitSoc int // Session limit for soc
limitEnergy float64 // Session limit for energy
smartCostLimit *float64 // always charge if cost is below this value
smartCostLimit *float64 // always charge if consumption cost is below this value
smartFeedInPriorityLimit *float64 // prevent charging if feed-in cost is above this value
batteryBoost int // battery boost state
mode api.ChargeMode
@ -332,6 +333,9 @@ func (lp *Loadpoint) restoreSettings() {
if v, err := lp.settings.Float(keys.SmartCostLimit); err == nil {
lp.SetSmartCostLimit(&v)
}
if v, err := lp.settings.Float(keys.SmartFeedInPriorityLimit); err == nil {
lp.SetSmartFeedInPriorityLimit(&v)
}
var thresholds loadpoint.ThresholdsConfig
if err := lp.settings.Json(keys.Thresholds, &thresholds); err == nil {
@ -632,6 +636,7 @@ func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan c
lp.publish(keys.ChargerSinglePhase, lp.getChargerPhysicalPhases() == 1)
lp.publish(keys.PhasesActive, lp.ActivePhases())
lp.publish(keys.SmartCostLimit, lp.smartCostLimit)
lp.publish(keys.SmartFeedInPriorityLimit, lp.smartFeedInPriorityLimit)
lp.publishTimer(phaseTimer, 0, timerInactive)
lp.publishTimer(pvTimer, 0, timerInactive)
@ -1781,17 +1786,16 @@ func (lp *Loadpoint) phaseSwitchCompleted() bool {
}
// Update is the main control function. It reevaluates meters and charger state
func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, rates api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64) {
func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64) {
// smart cost
smartCostActive := lp.smartCostActive(rates)
smartCostActive, smartCostNextStart := lp.checkSmartLimit(lp.GetSmartCostLimit(), consumption, true)
lp.publish(keys.SmartCostActive, smartCostActive)
var smartCostNextStart time.Time
if !smartCostActive {
smartCostNextStart = lp.smartCostNextStart(rates)
}
lp.publish(keys.SmartCostNextStart, smartCostNextStart)
smartFeedInPriorityActive, smartFeedInPriorityNextStart := lp.checkSmartLimit(lp.GetSmartFeedInPriorityLimit(), feedin, false)
lp.publish(keys.SmartFeedInPriorityActive, smartFeedInPriorityActive)
lp.publish(keys.SmartFeedInPriorityNextStart, smartFeedInPriorityNextStart)
// long-running tasks
lp.processTasks()
@ -1902,14 +1906,30 @@ func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, rates api.Rate
case mode == api.ModeMinPV || mode == api.ModePV:
// cheap tariff
if smartCostActive {
rate, _ := rates.At(time.Now())
lp.log.DEBUG.Printf("smart cost active: %.2f", rate.Value)
rate, _ := consumption.At(time.Now())
lp.log.DEBUG.Printf("smart consumption active: %.2f", rate.Value)
err = lp.fastCharging()
lp.resetPhaseTimer()
lp.elapsePVTimer() // let PV mode disable immediately afterwards
break
}
// attractive feedin
if smartFeedInPriorityActive {
rate, _ := feedin.At(time.Now())
lp.log.DEBUG.Printf("smart feed-in active: %.2f", rate.Value)
var targetCurrent float64
if mode == api.ModeMinPV {
targetCurrent = lp.GetMinCurrent()
}
err = lp.setLimit(targetCurrent)
lp.resetPhaseTimer()
lp.elapsePVTimer() // let PV mode disable immediately afterwards
break
}
targetCurrent := lp.pvMaxCurrent(mode, sitePower, batteryBoostPower, batteryBuffered, batteryStart)
if targetCurrent == 0 && lp.vehicleClimateActive() {

View file

@ -167,10 +167,14 @@ type API interface {
// smart grid charging
//
// GetSmartChargingActive determines if smart charging is active
// GetSmartCostLimit return the smart cost limit
GetSmartCostLimit() *float64
// SetSmartCostLimit sets the smart cost limit
SetSmartCostLimit(limit *float64)
// GetSmartFeedInPriorityLimit return the smart feed-in limit
GetSmartFeedInPriorityLimit() *float64
// SetSmartFeedInPriorityLimit sets the smart feed-in limit
SetSmartFeedInPriorityLimit(limit *float64)
//
// power and energy

View file

@ -24,6 +24,7 @@ type DynamicConfig struct {
MinCurrent float64 `json:"minCurrent"`
MaxCurrent float64 `json:"maxCurrent"`
SmartCostLimit *float64 `json:"smartCostLimit"`
SmartFeedInPriorityLimit *float64 `json:"smartFeedInPriorityLimit"`
PlanEnergy float64 `json:"planEnergy"`
PlanTime time.Time `json:"planTime"`
PlanPrecondition int64 `json:"planPrecondition"`
@ -56,6 +57,7 @@ func (payload DynamicConfig) Apply(lp API) error {
lp.SetTitle(payload.Title)
lp.SetPriority(payload.Priority)
lp.SetSmartCostLimit(payload.SmartCostLimit)
lp.SetSmartFeedInPriorityLimit(payload.SmartFeedInPriorityLimit)
lp.SetThresholds(payload.Thresholds)
lp.SetPlanEnergy(payload.PlanTime, time.Duration(payload.PlanPrecondition)*time.Second, payload.PlanEnergy)
lp.SetLimitEnergy(payload.LimitEnergy)

View file

@ -548,6 +548,20 @@ func (mr *MockAPIMockRecorder) GetSmartCostLimit() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSmartCostLimit", reflect.TypeOf((*MockAPI)(nil).GetSmartCostLimit))
}
// GetSmartFeedInPriorityLimit mocks base method.
func (m *MockAPI) GetSmartFeedInPriorityLimit() *float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSmartFeedInPriorityLimit")
ret0, _ := ret[0].(*float64)
return ret0
}
// GetSmartFeedInPriorityLimit indicates an expected call of GetSmartFeedInPriorityLimit.
func (mr *MockAPIMockRecorder) GetSmartFeedInPriorityLimit() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSmartFeedInPriorityLimit", reflect.TypeOf((*MockAPI)(nil).GetSmartFeedInPriorityLimit))
}
// GetSocConfig mocks base method.
func (m *MockAPI) GetSocConfig() SocConfig {
m.ctrl.T.Helper()
@ -908,6 +922,18 @@ func (mr *MockAPIMockRecorder) SetSmartCostLimit(limit any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetSmartCostLimit", reflect.TypeOf((*MockAPI)(nil).SetSmartCostLimit), limit)
}
// SetSmartFeedInPriorityLimit mocks base method.
func (m *MockAPI) SetSmartFeedInPriorityLimit(limit *float64) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetSmartFeedInPriorityLimit", limit)
}
// SetSmartFeedInPriorityLimit indicates an expected call of SetSmartFeedInPriorityLimit.
func (mr *MockAPIMockRecorder) SetSmartFeedInPriorityLimit(limit any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetSmartFeedInPriorityLimit", reflect.TypeOf((*MockAPI)(nil).SetSmartFeedInPriorityLimit), limit)
}
// SetSocConfig mocks base method.
func (m *MockAPI) SetSocConfig(soc SocConfig) {
m.ctrl.T.Helper()

View file

@ -608,7 +608,8 @@ func (lp *Loadpoint) GetChargePower() float64 {
// GetChargePowerFlexibility returns the flexible amount of current charging power
func (lp *Loadpoint) GetChargePowerFlexibility(rates api.Rates) float64 {
mode := lp.GetMode()
if mode == api.ModeNow || !lp.charging() || lp.minSocNotReached() || lp.smartCostActive(rates) {
if mode == api.ModeNow || !lp.charging() || lp.minSocNotReached() ||
lp.smartLimitActive(lp.GetSmartCostLimit(), rates, true) {
return 0
}
@ -807,6 +808,28 @@ func (lp *Loadpoint) SetSmartCostLimit(val *float64) {
}
}
// GetSmartFeedInPriorityLimit gets the smart feed-in limit
func (lp *Loadpoint) GetSmartFeedInPriorityLimit() *float64 {
lp.RLock()
defer lp.RUnlock()
return lp.smartFeedInPriorityLimit
}
// SetSmartFeedInPriorityLimit sets the smart cost feed-in
func (lp *Loadpoint) SetSmartFeedInPriorityLimit(val *float64) {
lp.Lock()
defer lp.Unlock()
lp.log.DEBUG.Println("set smart feed-in limit:", printPtr("%.1f", val))
if !ptrValueEqual(lp.smartFeedInPriorityLimit, val) {
lp.smartFeedInPriorityLimit = val
lp.settings.SetFloatPtr(keys.SmartFeedInPriorityLimit, val)
lp.publish(keys.SmartFeedInPriorityLimit, val)
}
}
// GetCircuit returns the assigned circuit
func (lp *Loadpoint) GetCircuit() api.Circuit {
lp.RLock()

View file

@ -6,22 +6,40 @@ import (
"github.com/evcc-io/evcc/api"
)
func (lp *Loadpoint) smartCostActive(rates api.Rates) bool {
rate, err := rates.At(time.Now())
limit := lp.GetSmartCostLimit()
return err == nil && limit != nil && rate.Value <= *limit
// checkSmartLimit checks if current rate meets smart limit and returns next start time if not active.
// checkBelow: true for rate <= limit, false for rate >= limit
func (lp *Loadpoint) checkSmartLimit(limit *float64, rates api.Rates, checkBelow bool) (bool, time.Time) {
var nextStart time.Time
active := lp.smartLimitActive(limit, rates, checkBelow)
if !active {
nextStart = lp.smartLimitNextStart(limit, rates, checkBelow)
}
// smartCostNextStart returns the next start time for a smart cost rate below the limit
func (lp *Loadpoint) smartCostNextStart(rates api.Rates) time.Time {
limit := lp.GetSmartCostLimit()
return active, nextStart
}
func (lp *Loadpoint) smartLimitActive(limit *float64, rates api.Rates, checkBelow bool) bool {
rate, err := rates.At(time.Now())
if err != nil || limit == nil {
return false
}
if checkBelow {
return rate.Value <= *limit
}
return rate.Value >= *limit
}
// smartLimitNextStart returns the next start time when the smart limit condition will be met
func (lp *Loadpoint) smartLimitNextStart(limit *float64, rates api.Rates, checkBelow bool) time.Time {
if limit == nil || rates == nil {
return time.Time{}
}
now := time.Now()
for _, slot := range rates {
if slot.Start.After(now) && slot.Value <= *limit {
if slot.Start.After(now) && (checkBelow && slot.Value <= *limit || !checkBelow && slot.Value >= *limit) {
return slot.Start
}
}

View file

@ -183,7 +183,7 @@ func TestUpdatePowerZero(t *testing.T) {
}
lp.mode = tc.mode
lp.Update(0, 0, nil, false, false, 0, nil, nil) // false,sitePower false,0
lp.Update(0, 0, nil, nil, false, false, 0, nil, nil) // false,sitePower false,0
ctrl.Finish()
}
@ -428,7 +428,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
lp.Update(500, 0, nil, false, false, 0, nil, nil)
lp.Update(500, 0, nil, nil, false, false, 0, nil, nil)
ctrl.Finish()
t.Log("charging above target - soc deactivates charger")
@ -437,7 +437,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Enable(false).Return(nil)
lp.Update(500, 0, nil, false, false, 0, nil, nil)
lp.Update(500, 0, nil, nil, false, false, 0, nil, nil)
ctrl.Finish()
t.Log("deactivated charger changes status to B")
@ -445,14 +445,14 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
vehicle.EXPECT().Soc().Return(95.0, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
lp.Update(-500, 0, nil, false, false, 0, nil, nil)
lp.Update(-500, 0, nil, nil, false, false, 0, nil, nil)
ctrl.Finish()
t.Log("soc has risen below target - soc update prevented by timer")
clock.Add(5 * time.Minute)
charger.EXPECT().Status().Return(api.StatusB, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
lp.Update(-500, 0, nil, false, false, 0, nil, nil)
lp.Update(-500, 0, nil, nil, false, false, 0, nil, nil)
ctrl.Finish()
t.Log("soc has fallen below target - soc update timer expired")
@ -462,7 +462,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
charger.EXPECT().Enable(true).Return(nil)
lp.Update(-500, 0, nil, false, false, 0, nil, nil)
lp.Update(-500, 0, nil, nil, false, false, 0, nil, nil)
ctrl.Finish()
}
@ -497,14 +497,14 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
lp.Update(500, 0, nil, false, false, 0, nil, nil)
lp.Update(500, 0, nil, nil, false, false, 0, nil, nil)
t.Log("switch off when disconnected")
clock.Add(5 * time.Minute)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusA, nil)
charger.EXPECT().Enable(false).Return(nil)
lp.Update(-300, 0, nil, false, false, 0, nil, nil)
lp.Update(-300, 0, nil, nil, false, false, 0, nil, nil)
if mode := lp.GetMode(); mode != api.ModeOff {
t.Error("unexpected mode", mode)
@ -566,14 +566,14 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(0.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, 0, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil)
t.Log("at 1:00h charging at 5 kWh")
clock.Add(time.Hour)
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, 0, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:00h stop charging at 5 kWh")
@ -581,7 +581,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(-1, 0, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:00h restart charging at 5 kWh")
@ -589,7 +589,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, 0, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:30h continue charging at 7.5 kWh")
@ -597,7 +597,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(7.5, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, 0, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil)
expectCache("chargedEnergy", 7500.0)
t.Log("at 2:00h stop charging at 10 kWh")
@ -605,7 +605,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(10.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(-1, 0, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil)
expectCache("chargedEnergy", 10000.0)
ctrl.Finish()

View file

@ -269,7 +269,7 @@ func TestReconnectVehicle(t *testing.T) {
// vehicle not updated yet
vehicle.MockChargeState.EXPECT().Status().Return(api.StatusA, nil)
lp.Update(0, 0, nil, false, false, 0, nil, nil)
lp.Update(0, 0, nil, nil, false, false, 0, nil, nil)
ctrl.Finish()
// detection started
@ -283,7 +283,7 @@ func TestReconnectVehicle(t *testing.T) {
// vehicle not updated yet
vehicle.MockChargeState.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(0, 0, nil, false, false, 0, nil, nil)
lp.Update(0, 0, nil, nil, false, false, 0, nil, nil)
ctrl.Finish()
// vehicle detected

View file

@ -42,7 +42,7 @@ const standbyPower = 10 // consider less than 10W as charger in standby
// updater abstracts the Loadpoint implementation for testing
type updater interface {
loadpoint.API
Update(sitePower, batteryBoostPower float64, rates api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64)
Update(sitePower, batteryBoostPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64)
}
// measurement is used as slice element for publishing structured data
@ -871,13 +871,18 @@ func (site *Site) update(lp updater) {
site.log.DEBUG.Println("----")
// smart cost and battery mode handling
rates, err := site.plannerRates()
consumption, err := site.tariffRates(api.TariffUsagePlanner)
if err != nil {
site.log.WARN.Println("planner:", err)
}
feedin, err := site.tariffRates(api.TariffUsageFeedIn)
if err != nil {
site.log.WARN.Println("feed-in:", err)
}
// update loadpoints
totalChargePower := site.updateLoadpoints(rates)
totalChargePower := site.updateLoadpoints(consumption)
// update all circuits' power and currents
if site.circuit != nil {
@ -894,13 +899,13 @@ func (site *Site) update(lp updater) {
flexiblePower = site.prioritizer.GetChargePowerFlexibility(lp)
}
rate, err := rates.At(time.Now())
if rates != nil && err != nil {
rate, err := consumption.At(time.Now())
if consumption != nil && err != nil {
msg := fmt.Sprintf("no matching rate for: %s", time.Now().Format(time.RFC3339))
if len(rates) > 0 {
msg += fmt.Sprintf(", %d rates (%s to %s)", len(rates),
rates[0].Start.Local().Format(time.RFC3339),
rates[len(rates)-1].End.Local().Format(time.RFC3339),
if len(consumption) > 0 {
msg += fmt.Sprintf(", %d consumption rates (%s to %s)", len(consumption),
consumption[0].Start.Local().Format(time.RFC3339),
consumption[len(consumption)-1].End.Local().Format(time.RFC3339),
)
}
@ -923,8 +928,9 @@ func (site *Site) update(lp updater) {
greenShareHome := site.greenShare(0, homePower)
greenShareLoadpoints := site.greenShare(nonChargePower, nonChargePower+totalChargePower)
// TODO
lp.Update(
sitePower, max(0, site.batteryPower), rates, batteryBuffered, batteryStart,
sitePower, max(0, site.batteryPower), consumption, feedin, batteryBuffered, batteryStart,
greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints),
)
@ -962,6 +968,8 @@ func (site *Site) prepare() {
site.publish(keys.BatteryMode, site.batteryMode)
site.publish(keys.BatteryDischargeControl, site.batteryDischargeControl)
site.publish(keys.ResidualPower, site.GetResidualPower())
site.publish(keys.SmartCostAvailable, site.isDynamicTariff(api.TariffUsagePlanner))
site.publish(keys.SmartFeedInPriorityAvailable, site.isDynamicTariff(api.TariffUsageFeedIn))
site.publish(keys.Currency, site.tariffs.Currency)
if tariff := site.GetTariff(api.TariffUsagePlanner); tariff != nil {

View file

@ -106,8 +106,8 @@ func (site *Site) applyBatteryMode(mode api.BatteryMode) error {
return nil
}
func (site *Site) plannerRates() (api.Rates, error) {
tariff := site.GetTariff(api.TariffUsagePlanner)
func (site *Site) tariffRates(usage api.TariffUsage) (api.Rates, error) {
tariff := site.GetTariff(usage)
if tariff == nil || tariff.Type() == api.TariffTypePriceStatic {
return nil, nil
}

View file

@ -167,3 +167,8 @@ func (site *Site) solarDetails(solar timeseries) solarDetails {
return res
}
func (site *Site) isDynamicTariff(usage api.TariffUsage) bool {
tariff := site.GetTariff(usage)
return tariff != nil && tariff.Type() != api.TariffTypePriceStatic
}

View file

@ -944,7 +944,7 @@
}
},
"smartCost": {
"activeHours": "{charging} от {total}",
"activeHours": "{active} от {total}",
"activeHoursLabel": "Активни часове",
"applyToAll": "Приложи навсякъде?",
"batteryDescription": "Зарежда домашната батерия с енергия от мрежата.",

View file

@ -300,7 +300,7 @@
}
},
"smartCost": {
"activeHours": "{charging} de {total}",
"activeHours": "{active} de {total}",
"activeHoursLabel": "Hores funcionant",
"co2Label": "Emissions de CO₂",
"co2Limit": "Límit de CO₂",

View file

@ -957,7 +957,7 @@
}
},
"smartCost": {
"activeHours": "{charging} z {total}",
"activeHours": "{active} z {total}",
"activeHoursLabel": "Aktivních hodin",
"applyToAll": "Aplikovat všude?",
"batteryDescription": "Nabíjí baterii FVE ze distribuční sítě.",

View file

@ -957,7 +957,7 @@
}
},
"smartCost": {
"activeHours": "{charging} af {total}",
"activeHours": "{active} af {total}",
"activeHoursLabel": "Aktive timer",
"applyToAll": "Anvendes overalt?",
"batteryDescription": "Oplader hjemmebatteriet med energi fra elnettet.",

View file

@ -957,7 +957,7 @@
}
},
"smartCost": {
"activeHours": "{charging} von {total}",
"activeHours": "{active} von {total}",
"activeHoursLabel": "Aktive Stunden",
"applyToAll": "Überall anwenden?",
"batteryDescription": "Lädt die Hausbatterie aus dem Netz.",

View file

@ -956,7 +956,7 @@
}
},
"smartCost": {
"activeHours": "{charging} από {total}",
"activeHours": "{active} από {total}",
"activeHoursLabel": "Ενεργές ώρες",
"applyToAll": "Εφαρμογή παντού;",
"batteryDescription": "Φορτίζει την οικιακή μπαταρία με ενέργεια από το δίκτυο.",

View file

@ -779,6 +779,8 @@
"connected": "Connected.",
"disconnectRequired": "Session terminated. Please reconnect.",
"disconnected": "Disconnected.",
"feedinPriorityNextStart": "High feed-in rates start in {duration}.",
"feedinPriorityPausing": "Solar charging paused to maximize feed-in.",
"finished": "Finished.",
"minCharge": "Minimum charging to {soc}.",
"pvDisable": "Not enough surplus. Pausing soon.",
@ -957,7 +959,7 @@
}
},
"smartCost": {
"activeHours": "{charging} of {total}",
"activeHours": "{active} of {total}",
"activeHoursLabel": "Active hours",
"applyToAll": "Apply everywhere?",
"batteryDescription": "Charges the home battery with energy from the grid.",
@ -974,6 +976,14 @@
"resetWarning": "There is no dynamic grid price or CO₂-source configured. However, there is still a limit of {limit}. Clean up your configuration?",
"saved": "Saved."
},
"smartFeedInPriority": {
"activeHoursLabel": "Paused hours",
"description": "Pauses charging during high prices to prioritize profitable grid feed-in.",
"priceLabel": "Feed-in rate",
"priceLimit": "Feed-in limit",
"resetWarning": "There is no dynamic feed-in tariff configured. However, there is still a limit of {limit}. Clean up your configuration?",
"title": "Feed-in Priority"
},
"startupError": {
"configFile": "Configuration file used:",
"configuration": "Config",

View file

@ -723,7 +723,7 @@
}
},
"smartCost": {
"activeHours": "{charging} de {total}",
"activeHours": "{active} de {total}",
"activeHoursLabel": "Horas funcionando",
"applyToAll": "¿Aplicar en todas partes?",
"batteryDescription": "Carga la batería doméstica con la red eléctrica",

View file

@ -957,7 +957,7 @@
}
},
"smartCost": {
"activeHours": "{charging} / {total}",
"activeHours": "{active} / {total}",
"activeHoursLabel": "Aktiiviset tunnit",
"applyToAll": "Käytä kaikkialla?",
"batteryDescription": "Lataa kodin akun sähköverkosta.",

View file

@ -957,7 +957,7 @@
}
},
"smartCost": {
"activeHours": "{charging} sur {total}",
"activeHours": "{active} sur {total}",
"activeHoursLabel": "Heures actives",
"applyToAll": "Appliquer partout ?",
"batteryDescription": "Charge la batterie domestique avec du courant du réseau.",

View file

@ -957,7 +957,7 @@
}
},
"smartCost": {
"activeHours": "{charging} od {total}",
"activeHours": "{active} od {total}",
"activeHoursLabel": "Aktivno radno vrijeme",
"applyToAll": "Primijeniti svuda?",
"batteryDescription": "Puni kućnu bateriju energijom iz mreže.",

View file

@ -956,7 +956,7 @@
}
},
"smartCost": {
"activeHours": "{charging} / {total}",
"activeHours": "{active} / {total}",
"activeHoursLabel": "Aktív órák",
"applyToAll": "Minenhol alkalmazás?",
"batteryDescription": "Az otthoni töltéstároló töltése a hálózatról.",

View file

@ -944,7 +944,7 @@
}
},
"smartCost": {
"activeHours": "{charging} di {total}",
"activeHours": "{active} di {total}",
"activeHoursLabel": "Ore attive",
"applyToAll": "Applicare su tutti?",
"batteryDescription": "Carica la batteria di casa con la corrente della rete",

View file

@ -957,7 +957,7 @@
}
},
"smartCost": {
"activeHours": "{charging} vun {total}",
"activeHours": "{active} vun {total}",
"activeHoursLabel": "Aktiv Stonnen",
"applyToAll": "Iwwerall uwennen?",
"batteryDescription": "Luet d'Hausbatterie aus dem Netz.",

View file

@ -957,7 +957,7 @@
}
},
"smartCost": {
"activeHours": "{charging} iš {total}",
"activeHours": "{active} iš {total}",
"activeHoursLabel": "Aktyvumo valandos",
"applyToAll": "Pritaikyti visur?",
"batteryDescription": "Įkrauna kaupiklį energija iš tinklo.",

View file

@ -957,7 +957,7 @@
}
},
"smartCost": {
"activeHours": "{charging} van {total}",
"activeHours": "{active} van {total}",
"activeHoursLabel": "Actieve uren",
"applyToAll": "Overal toepassen?",
"batteryDescription": "Laadt de huisbatterij op met stroom uit het net.",

View file

@ -347,7 +347,7 @@
}
},
"smartCost": {
"activeHours": "{charging} av {total}",
"activeHours": "{active} av {total}",
"activeHoursLabel": "Aktive timer",
"co2Label": "CO₂-utslipp",
"co2Limit": "CO₂-grense",

View file

@ -539,7 +539,7 @@
}
},
"smartCost": {
"activeHours": "{charging} z {total}",
"activeHours": "{active} z {total}",
"activeHoursLabel": "Aktywne godziny",
"applyToAll": "Aplikować wszędzie?",
"batteryDescription": "Ładuje akumulator domu energią z sieci.",

View file

@ -957,7 +957,7 @@
}
},
"smartCost": {
"activeHours": "{charging} de {total}",
"activeHours": "{active} de {total}",
"activeHoursLabel": "Horas ativas",
"applyToAll": "Aplicar em todo o lado?",
"batteryDescription": "Carrega a bateria doméstica com energia da Rede.",

View file

@ -429,7 +429,7 @@
}
},
"smartCost": {
"activeHours": "{charging} din {total}",
"activeHours": "{active} din {total}",
"activeHoursLabel": "Ore de functionare",
"co2Label": "emisii CO₂",
"co2Limit": "limita CO₂",

View file

@ -888,7 +888,7 @@
}
},
"smartCost": {
"activeHours": "{charging} od {total}",
"activeHours": "{active} od {total}",
"activeHoursLabel": "Aktivne ure",
"applyToAll": "Uporabi povsod?",
"batteryDescription": "Napolni domačo baterijo z energijo iz omrežja.",

View file

@ -957,7 +957,7 @@
}
},
"smartCost": {
"activeHours": "{charging} av {total}",
"activeHours": "{active} av {total}",
"activeHoursLabel": "Aktiva timmar",
"applyToAll": "Tillämpa överallt?",
"batteryDescription": "Laddar batteriet med el från nätet.",

View file

@ -723,7 +723,7 @@
}
},
"smartCost": {
"activeHours": "{charging} இன் {total} \"",
"activeHours": "{active} இன் {total} \"",
"activeHoursLabel": "செயலில் நேரம்",
"applyToAll": "எல்லா இடங்களிலும் விண்ணப்பிக்கவா?",
"batteryDescription": "கட்டத்தில் இருந்து ஆற்றலுடன் வீட்டு பேட்டரியை வசூலிக்கிறது.",

View file

@ -957,7 +957,7 @@
}
},
"smartCost": {
"activeHours": "{total} saat içinde {charging}",
"activeHours": "{total} saat içinde {active}",
"activeHoursLabel": "Aktif saatler",
"applyToAll": "Heryerde uygulansın mı?",
"batteryDescription": "Ev enerji deposunu şebekeden doldurur.",

View file

@ -957,7 +957,7 @@
}
},
"smartCost": {
"activeHours": "{charging} зі {total}",
"activeHours": "{active} зі {total}",
"activeHoursLabel": "Активні години",
"applyToAll": "Застосовувати всюди?",
"batteryDescription": "Заряджає домашній акумулятор енергією з мережі.",

View file

@ -956,7 +956,7 @@
}
},
"smartCost": {
"activeHours": "{charging} / {total}",
"activeHours": "{active} / {total}",
"activeHoursLabel": "有效时段",
"applyToAll": "应用于所有地方?",
"batteryDescription": "使用电网能源为住宅电池充电。",

View file

@ -10,6 +10,7 @@ import (
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/assets"
"github.com/evcc-io/evcc/server/eebus"
@ -127,6 +128,13 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
))
// site api
smartCostLimit := func(lp loadpoint.API, limit *float64) {
lp.SetSmartCostLimit(limit)
}
smartFeedInPriorityLimit := func(lp loadpoint.API, limit *float64) {
lp.SetSmartFeedInPriorityLimit(limit)
}
routes := map[string]route{
"health": {"GET", "/health", healthHandler(site)},
"buffersoc": {"POST", "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
@ -138,8 +146,10 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
"batterymodedelete": {"DELETE", "/batterymode", updateBatteryMode(site)},
"prioritysoc": {"POST", "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
"residualpower": {"POST", "/residualpower/{value:-?[0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
"smartcost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", updateSmartCostLimit(site)},
"smartcostdelete": {"DELETE", "/smartcostlimit", updateSmartCostLimit(site)},
"smartcost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", updateSmartCostLimit(site, smartCostLimit)},
"smartcostdelete": {"DELETE", "/smartcostlimit", updateSmartCostLimit(site, smartCostLimit)},
"smartfeedin": {"POST", "/smartfeedinprioritylimit/{value:-?[0-9.]+}", updateSmartCostLimit(site, smartFeedInPriorityLimit)},
"smartfeedindelete": {"DELETE", "/smartfeedinprioritylimit", updateSmartCostLimit(site, smartFeedInPriorityLimit)},
"tariff": {"GET", "/tariff/{tariff:[a-z]+}", tariffHandler(site)},
"sessions": {"GET", "/sessions", sessionHandler},
"updatesession": {"PUT", "/session/{id:[0-9]+}", updateSessionHandler},
@ -198,6 +208,8 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
"disableDelay": {"POST", "/disable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetDisableDelay), lp.GetDisableDelay)},
"smartCost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
"smartCostDelete": {"DELETE", "/smartcostlimit", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
"smartFeedInPriority": {"POST", "/smartfeedinprioritylimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartFeedInPriorityLimit), lp.GetSmartFeedInPriorityLimit)},
"smartFeedInPriorityDelete": {"DELETE", "/smartfeedinprioritylimit", floatPtrHandler(pass(lp.SetSmartFeedInPriorityLimit), lp.GetSmartFeedInPriorityLimit)},
"priority": {"POST", "/priority/{value:[0-9]+}", intHandler(pass(lp.SetPriority), lp.GetPriority)},
"batteryBoost": {"POST", "/batteryboost/{value:[01truefalse]+}", boolHandler(lp.SetBatteryBoost, func() bool { return lp.GetBatteryBoost() > 0 })},
}

View file

@ -36,6 +36,7 @@ func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig {
MinCurrent: lp.GetMinCurrent(),
MaxCurrent: lp.GetMaxCurrent(),
SmartCostLimit: lp.GetSmartCostLimit(),
SmartFeedInPriorityLimit: lp.GetSmartFeedInPriorityLimit(),
Thresholds: lp.GetThresholds(),
Soc: lp.GetSocConfig(),
PlanEnergy: planEnergy,

View file

@ -12,6 +12,7 @@ import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/assets"
"github.com/evcc-io/evcc/util"
@ -174,7 +175,7 @@ func getHandler[T any](get func() T) http.HandlerFunc {
}
// updateSmartCostLimit sets the smart cost limit globally
func updateSmartCostLimit(site site.API) http.HandlerFunc {
func updateSmartCostLimit(site site.API, setLimit func(loadpoint.API, *float64)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
var val *float64
@ -190,7 +191,7 @@ func updateSmartCostLimit(site site.API) http.HandlerFunc {
}
for _, lp := range site.Loadpoints() {
lp.SetSmartCostLimit(val)
setLimit(lp, val)
}
jsonResult(w, val)

View file

@ -211,6 +211,11 @@ func (m *MQTT) listenSiteSetters(topic string, site site.API) error {
lp.SetSmartCostLimit(limit)
}
}))},
{"smartFeedInPriorityLimit", floatPtrSetter(pass(func(limit *float64) {
for _, lp := range site.Loadpoints() {
lp.SetSmartFeedInPriorityLimit(limit)
}
}))},
{"batteryGridChargeLimit", floatPtrSetter(pass(site.SetBatteryGridChargeLimit))},
{"batteryMode", ptrSetter(api.BatteryModeString, pass(func(m *api.BatteryMode) {
if m == nil {
@ -241,6 +246,7 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.
{"enableDelay", durationSetter(pass(lp.SetEnableDelay))},
{"disableDelay", durationSetter(pass(lp.SetDisableDelay))},
{"smartCostLimit", floatPtrSetter(pass(lp.SetSmartCostLimit))},
{"smartFeedInPriorityLimit", floatPtrSetter(pass(lp.SetSmartFeedInPriorityLimit))},
{"batteryBoost", boolSetter(lp.SetBatteryBoost)},
{"planEnergy", func(payload string) error {
var plan struct {

View file

@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { enableExperimental } from "./utils";
import { enableExperimental, expectModalVisible, expectModalHidden } from "./utils";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
@ -41,18 +41,21 @@ test.describe("battery settings", async () => {
await enableExperimental(page);
await page.getByTestId("topnavigation-button").click();
await page.getByTestId("topnavigation-battery").click();
const modal = page.getByTestId("battery-settings-modal");
await expectModalVisible(modal);
await page.getByRole("link", { name: "Grid charging 🧪" }).click();
await page.getByLabel("Price limit").selectOption({ label: "≤ 50.0 ct/kWh" });
await expect(page.getByTestId("battery-settings-modal")).toContainText("5.0 ct – 50.0 ct");
await modal.getByRole("link", { name: "Grid charging 🧪" }).click();
await modal.getByLabel("Price limit").selectOption({ label: "≤ 50.0 ct/kWh" });
await expect(modal).toContainText("5.0 ct – 50.0 ct");
await page.getByRole("button", { name: "Close" }).click();
await expect(page.getByTestId("battery-settings-modal")).not.toBeVisible();
await expectModalHidden(modal);
await page.getByTestId("energyflow").click();
await page.getByRole("button", { name: "grid charging active (≤ 50.0 ct)" }).click();
await expect(page.getByTestId("battery-settings-modal")).toBeVisible();
await page.getByLabel("Price limit").selectOption({ label: "≤ -10.0 ct/kWh" });
await page.getByRole("button", { name: "Close" }).click();
await expect(page.getByTestId("battery-settings-modal")).not.toBeVisible();
await expectModalVisible(modal);
await modal.getByLabel("Price limit").selectOption({ label: "≤ -10.0 ct/kWh" });
await modal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await expect(modal).not.toBeVisible();
await expect(page.getByRole("button", { name: "grid charging when ≤ -10.0 ct" })).toBeVisible();
});
});

45
tests/smart-feedin.evcc.yaml Executable file
View file

@ -0,0 +1,45 @@
interval: 0.1s
site:
title: Smart Feed-in
meters:
grid: grid
loadpoints:
- title: LP1
charger: charger1
mode: pv
- title: LP2
charger: charger2
mode: pv
meters:
- name: grid
type: template
template: demo-meter
power: -2000
chargers:
- name: charger1
type: template
template: demo-charger
status: C
power: 2000
- name: charger2
type: template
template: demo-charger
status: B
power: 0
tariffs:
currency: EUR
feedin:
type: fixed
price: 0.2 # EUR/kWh
zones:
- hours: 0-5
price: 0.4
- hours: 5-6
price: 0.6
- hours: 18-0
price: 0.4

View file

@ -0,0 +1,74 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { expectModalHidden, expectModalVisible } from "./utils";
const CONFIG = "smart-feedin.evcc.yaml";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
await start(CONFIG);
});
test.afterAll(async () => {
await stop();
});
test.describe("smart feed-in priority", async () => {
test("no limit, normal charging", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("vehicle-status-smartfeedinpriority")).not.toBeVisible();
});
test("feed-in above threshold, pause charging", async ({ page }) => {
await page.goto("/");
const lp = page.getByTestId("loadpoint").first();
// set limit
await lp.getByTestId("loadpoint-settings-button").nth(1).click();
const modal = page.getByTestId("loadpoint-settings-modal").first();
await expectModalVisible(modal);
await modal.getByLabel("Feed-in limit").selectOption("≥ 10.0 ct/kWh");
await modal.getByLabel("Close").click();
await expectModalHidden(modal);
// check status
await expect(lp.getByTestId("vehicle-status-smartfeedinpriority")).toBeVisible();
await expect(lp.getByTestId("vehicle-status-smartfeedinpriority")).toHaveText(/≥ 10\.0 ct/);
// remove limit
await lp.getByTestId("loadpoint-settings-button").nth(1).click();
await expectModalVisible(modal);
await modal.getByLabel("Feed-in limit").selectOption("none");
await modal.getByLabel("Close").click();
await expectModalHidden(modal);
// check status
await expect(lp.getByTestId("vehicle-status-smartfeedinpriority")).not.toBeVisible();
});
test("feed-in apply to all loadpoints", async ({ page }) => {
await page.goto("/");
const lp1 = page.getByTestId("loadpoint").first();
const lp2 = page.getByTestId("loadpoint").last();
await lp1.getByTestId("loadpoint-settings-button").nth(1).click();
const modal1 = page.getByTestId("loadpoint-settings-modal").first();
await expectModalVisible(modal1);
await modal1.getByLabel("Feed-in limit").selectOption("≥ 10.0 ct/kWh");
await modal1.getByRole("button", { name: "Apply everywhere?" }).click();
await modal1.getByLabel("Close").click();
await expectModalHidden(modal1);
// check lp1, lp2 status
await expect(lp1.getByTestId("vehicle-status-smartfeedinpriority")).toBeVisible();
await expect(lp1.getByTestId("vehicle-status-smartfeedinpriority")).toHaveText(/≥ 10\.0 ct/);
await expect(lp2.getByTestId("vehicle-status-smartfeedinpriority")).toBeVisible();
await expect(lp2.getByTestId("vehicle-status-smartfeedinpriority")).toHaveText(/≥ 10\.0 ct/);
// check lp2 setting
await lp2.getByTestId("loadpoint-settings-button").nth(1).click();
const modal2 = page.getByTestId("loadpoint-settings-modal").last();
await expectModalVisible(modal2);
await expect(modal2.getByLabel("Feed-in limit")).toHaveValue("0.1");
});
});