Add smart feed-in priority (#21813)
This commit is contained in:
parent
6ba804b106
commit
42ed7da2e9
63 changed files with 1645 additions and 1290 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -1,169 +1,141 @@
|
|||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
:id="`loadpointSettingsModal_${id}`"
|
||||
ref="modal"
|
||||
class="modal fade text-dark modal-xl"
|
||||
data-bs-backdrop="true"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-hidden="true"
|
||||
data-testid="loadpoint-settings-modal"
|
||||
>
|
||||
<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"
|
||||
:possible="smartCostAvailable"
|
||||
:smartCostLimit="smartCostLimit"
|
||||
class="mt-2"
|
||||
/>
|
||||
<LoadpointSettingsBatteryBoost
|
||||
v-if="batteryBoostAvailable"
|
||||
v-bind="batteryBoostProps"
|
||||
class="mt-2"
|
||||
@batteryboost-updated="changeBatteryBoost"
|
||||
/>
|
||||
<h6>
|
||||
{{ $t("main.loadpointSettings.currents") }}
|
||||
</h6>
|
||||
<div v-if="phasesOptions.length" class="mb-3 row">
|
||||
<label
|
||||
:for="formId(`phases_${phasesOptions[0]}`)"
|
||||
class="col-sm-4 col-form-label pt-0"
|
||||
>
|
||||
{{ $t("main.loadpointSettings.phasesConfigured.label") }}
|
||||
</label>
|
||||
<div class="col-sm-8 pe-0">
|
||||
<p v-if="!chargerPhases1p3p" class="mt-0 mb-2">
|
||||
<small>
|
||||
{{
|
||||
$t(
|
||||
"main.loadpointSettings.phasesConfigured.no1p3pSupport"
|
||||
)
|
||||
}}</small
|
||||
>
|
||||
</p>
|
||||
<div
|
||||
v-for="phases in phasesOptions"
|
||||
:key="phases"
|
||||
class="form-check"
|
||||
>
|
||||
<input
|
||||
:id="formId(`phases_${phases}`)"
|
||||
v-model.number="selectedPhases"
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
:name="formId('phases')"
|
||||
:value="phases"
|
||||
@change="changePhasesConfigured"
|
||||
/>
|
||||
<label
|
||||
class="form-check-label"
|
||||
:for="formId(`phases_${phases}`)"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
`main.loadpointSettings.phasesConfigured.phases_${phases}`
|
||||
)
|
||||
}}
|
||||
<small v-if="phases > 0">
|
||||
{{
|
||||
$t(
|
||||
`main.loadpointSettings.phasesConfigured.phases_${phases}_hint`,
|
||||
{
|
||||
min: minPowerPhases(phases),
|
||||
max: maxPowerPhases(phases),
|
||||
}
|
||||
)
|
||||
}}
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 row">
|
||||
<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">
|
||||
<select
|
||||
:id="formId('maxcurrent')"
|
||||
v-model.number="selectedMaxCurrent"
|
||||
class="form-select form-select-sm w-50"
|
||||
@change="changeMaxCurrent"
|
||||
>
|
||||
<option
|
||||
v-for="{ value, name } in maxCurrentOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="ms-3">~ {{ maxPower }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 row">
|
||||
<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">
|
||||
<select
|
||||
:id="formId('mincurrent')"
|
||||
v-model.number="selectedMinCurrent"
|
||||
class="form-select form-select-sm w-50"
|
||||
@change="changeMinCurrent"
|
||||
>
|
||||
<option
|
||||
v-for="{ value, name } in minCurrentOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="ms-3">~ {{ minPower }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GenericModal
|
||||
:id="`loadpointSettingsModal_${id}`"
|
||||
:title="$t('main.loadpointSettings.title', [title])"
|
||||
size="xl"
|
||||
data-testid="loadpoint-settings-modal"
|
||||
@opened="modalVisible"
|
||||
@closed="modalInvisible"
|
||||
>
|
||||
<div class="container">
|
||||
<SmartCostLimit
|
||||
:current-limit="smartCostLimit"
|
||||
:smart-cost-type="smartCostType"
|
||||
:currency="currency"
|
||||
is-loadpoint
|
||||
:loadpoint-id="Number(loadpointId)"
|
||||
:multiple-loadpoints="multipleLoadpoints"
|
||||
:possible="smartCostAvailable"
|
||||
: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
|
||||
v-if="batteryBoostAvailable"
|
||||
v-bind="batteryBoostProps"
|
||||
class="mt-2"
|
||||
@batteryboost-updated="changeBatteryBoost"
|
||||
/>
|
||||
<h6>
|
||||
{{ $t("main.loadpointSettings.currents") }}
|
||||
</h6>
|
||||
<div v-if="phasesOptions.length" class="mb-3 row">
|
||||
<label
|
||||
:for="formId(`phases_${phasesOptions[0]}`)"
|
||||
class="col-sm-4 col-form-label pt-0"
|
||||
>
|
||||
{{ $t("main.loadpointSettings.phasesConfigured.label") }}
|
||||
</label>
|
||||
<div class="col-sm-8 pe-0">
|
||||
<p v-if="!chargerPhases1p3p" class="mt-0 mb-2">
|
||||
<small>
|
||||
{{ $t("main.loadpointSettings.phasesConfigured.no1p3pSupport") }}</small
|
||||
>
|
||||
</p>
|
||||
<div v-for="phases in phasesOptions" :key="phases" class="form-check">
|
||||
<input
|
||||
:id="formId(`phases_${phases}`)"
|
||||
v-model.number="selectedPhases"
|
||||
class="form-check-input"
|
||||
type="radio"
|
||||
:name="formId('phases')"
|
||||
:value="phases"
|
||||
@change="changePhasesConfigured"
|
||||
/>
|
||||
<label class="form-check-label" :for="formId(`phases_${phases}`)">
|
||||
{{ $t(`main.loadpointSettings.phasesConfigured.phases_${phases}`) }}
|
||||
<small v-if="phases > 0">
|
||||
{{
|
||||
$t(
|
||||
`main.loadpointSettings.phasesConfigured.phases_${phases}_hint`,
|
||||
{
|
||||
min: minPowerPhases(phases),
|
||||
max: maxPowerPhases(phases),
|
||||
}
|
||||
)
|
||||
}}
|
||||
</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 row">
|
||||
<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">
|
||||
<select
|
||||
:id="formId('maxcurrent')"
|
||||
v-model.number="selectedMaxCurrent"
|
||||
class="form-select form-select-sm w-50"
|
||||
@change="changeMaxCurrent"
|
||||
>
|
||||
<option
|
||||
v-for="{ value, name } in maxCurrentOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="ms-3">~ {{ maxPower }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 row">
|
||||
<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">
|
||||
<select
|
||||
:id="formId('mincurrent')"
|
||||
v-model.number="selectedMinCurrent"
|
||||
class="form-select form-select-sm w-50"
|
||||
@change="changeMinCurrent"
|
||||
>
|
||||
<option
|
||||
v-for="{ value, name } in minCurrentOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
>
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="ms-3">~ {{ minPower }}</small>
|
||||
</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);
|
||||
|
|
|
|||
129
assets/js/components/MaterialIcon/MaterialIcon.story.ts
Normal file
129
assets/js/components/MaterialIcon/MaterialIcon.story.ts
Normal 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>
|
||||
`,
|
||||
});
|
||||
19
assets/js/components/MaterialIcon/SunPause.vue
Normal file
19
assets/js/components/MaterialIcon/SunPause.vue
Normal 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>
|
||||
|
|
@ -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>,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
</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>
|
||||
<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"
|
||||
/>
|
||||
</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);
|
||||
if (limit === "null") {
|
||||
await api.delete(url);
|
||||
} else {
|
||||
await api.post(`${url}/${encodeURIComponent(limit)}`);
|
||||
}
|
||||
},
|
||||
async applyToAll() {
|
||||
try {
|
||||
if (this.selectedSmartCostLimit === null) {
|
||||
await api.delete("smartcostlimit");
|
||||
} else {
|
||||
await api.post(
|
||||
`smartcostlimit/${encodeURIComponent(this.selectedSmartCostLimit)}`
|
||||
);
|
||||
}
|
||||
this.applyToAllVisible = false;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
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(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>
|
||||
|
|
|
|||
87
assets/js/components/Tariff/SmartFeedInPriority.vue
Normal file
87
assets/js/components/Tariff/SmartFeedInPriority.vue
Normal 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>
|
||||
394
assets/js/components/Tariff/SmartTariffBase.vue
Normal file
394
assets/js/components/Tariff/SmartTariffBase.vue
Normal 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>
|
||||
|
|
@ -8,171 +8,41 @@
|
|||
{{ 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>
|
||||
<div>
|
||||
<span v-if="smartCostNowVisible">{{ smartCostNow }}</span>
|
||||
≤ <span class="text-decoration-underline">{{ smartCostLimitFmt }}</span>
|
||||
<span v-if="smartCostNextStart">
|
||||
({{ 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)) : "" }}
|
||||
</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>
|
||||
<!-- 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>
|
||||
<span v-if="smartCostNextStart">
|
||||
({{ fmtAbsoluteDate(new Date(smartCostNextStart)) }})
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
</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");
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
planActiveTooltipContent() {
|
||||
this.$nextTick(this.updatePlanActiveTooltip);
|
||||
},
|
||||
planStartTooltipContent() {
|
||||
this.$nextTick(this.updatePlanStartTooltip);
|
||||
},
|
||||
minSocTooltipContent() {
|
||||
this.$nextTick(this.updateMinSocTooltip);
|
||||
},
|
||||
phaseTimerContent() {
|
||||
this.$nextTick(this.updatePhaseTooltip);
|
||||
},
|
||||
pvTimerContent() {
|
||||
this.$nextTick(this.updatePvTooltip);
|
||||
},
|
||||
vehicleClimaterTooltipContent() {
|
||||
this.$nextTick(this.updateVehicleClimaterTooltip);
|
||||
},
|
||||
vehicleWelcomeTooltipContent() {
|
||||
this.$nextTick(this.updateVehicleWelcomeTooltip);
|
||||
},
|
||||
smartCostTooltipContent() {
|
||||
this.$nextTick(this.updateSmartCostTooltip);
|
||||
},
|
||||
vehicleLimitTooltipContent() {
|
||||
this.$nextTick(this.updateVehicleLimitTooltip);
|
||||
},
|
||||
tempLimitTooltipContent() {
|
||||
this.$nextTick(this.updateTempLimitTooltip);
|
||||
},
|
||||
awaitingAuthorizationTooltipContent() {
|
||||
this.$nextTick(this.updateAwaitingAuthorizationTooltip);
|
||||
},
|
||||
disconnectRequiredTooltipContent() {
|
||||
this.$nextTick(this.updateDisconnectRequiredTooltip);
|
||||
},
|
||||
batteryBoostTooltipContent() {
|
||||
this.$nextTick(this.updateBatteryBoostTooltip);
|
||||
},
|
||||
planProjectedStart() {
|
||||
this.updateDurations();
|
||||
},
|
||||
planProjectedEnd() {
|
||||
this.updateDurations();
|
||||
},
|
||||
smartCostNextStart() {
|
||||
this.updateDurations();
|
||||
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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
id: "tempLimit",
|
||||
visible: this.heating && this.vehicleLimitSoc > 0,
|
||||
content: this.fmtTemperature(this.vehicleLimitSoc),
|
||||
tooltipContent: t("vehicleLimit"),
|
||||
iconComponent: TempLimitIcon,
|
||||
testId: "vehicle-status-limit",
|
||||
},
|
||||
{
|
||||
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(),
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
id: "vehicleClimater",
|
||||
visible: !this.heating && this.vehicleClimaterActive,
|
||||
tooltipContent: t("climating"),
|
||||
iconComponent: ClimaterIcon,
|
||||
testId: "vehicle-status-climater",
|
||||
},
|
||||
{
|
||||
id: "vehicleWelcome",
|
||||
visible: !this.heating && this.vehicleWelcomeActive,
|
||||
tooltipContent: t("welcome"),
|
||||
iconComponent: WelcomeIcon,
|
||||
testId: "vehicle-status-welcome",
|
||||
},
|
||||
{
|
||||
id: "awaitingAuthorization",
|
||||
visible: !this.heating && this.chargerStatusReason === REASON_AUTH,
|
||||
tooltipContent: t("awaitingAuthorization"),
|
||||
iconComponent: RfidWaitIcon,
|
||||
testId: "vehicle-status-awaiting-authorization",
|
||||
},
|
||||
{
|
||||
id: "disconnectRequired",
|
||||
visible: !this.heating && this.chargerStatusReason === REASON_DISCONNECT,
|
||||
tooltipContent: t("disconnectRequired"),
|
||||
iconComponent: ReconnectIcon,
|
||||
itemClass: "text-warning",
|
||||
testId: "vehicle-status-disconnect-required",
|
||||
},
|
||||
{
|
||||
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(),
|
||||
},
|
||||
{
|
||||
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(),
|
||||
},
|
||||
{
|
||||
id: "batteryBoost",
|
||||
visible: this.batteryBoostActive,
|
||||
tooltipContent: t("batteryBoost"),
|
||||
iconComponent: BatteryBoostIcon,
|
||||
testId: "vehicle-status-batteryboost",
|
||||
clickable: true,
|
||||
clickHandler: () => this.openLoadpointSettings(),
|
||||
},
|
||||
{
|
||||
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(),
|
||||
},
|
||||
{
|
||||
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(),
|
||||
},
|
||||
];
|
||||
|
||||
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();
|
||||
}
|
||||
return null;
|
||||
if (this.smartCostNextStart) {
|
||||
return this.$t(`${prefix}EnergyNextStart`, {
|
||||
duration: this.fmtDurationToTime(new Date(this.smartCostNextStart)),
|
||||
});
|
||||
}
|
||||
let newInstance = instance;
|
||||
if (!newInstance) {
|
||||
const trigger = hoverOnly ? "hover" : "hover focus";
|
||||
newInstance = new Tooltip(ref, { title: " ", trigger });
|
||||
return this.$t(`${prefix}EnergySet`);
|
||||
},
|
||||
getSmartFeedInPriorityTooltip() {
|
||||
const prefix = "main.vehicleStatus.feedinPriority";
|
||||
if (this.smartFeedInPriorityActive) {
|
||||
return this.$t(`${prefix}Pausing`);
|
||||
}
|
||||
newInstance.setContent({ ".tooltip-inner": content });
|
||||
return newInstance;
|
||||
if (this.smartFeedInPriorityNextStart) {
|
||||
return this.$t(`${prefix}NextStart`, {
|
||||
duration: this.fmtDurationToTime(new Date(this.smartFeedInPriorityNextStart)),
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
107
assets/js/components/Vehicles/StatusItem.vue
Normal file
107
assets/js/components/Vehicles/StatusItem.vue
Normal 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>
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ export interface Forecast {
|
|||
co2?: ForecastSlot[];
|
||||
solar?: SolarDetails;
|
||||
planner?: ForecastSlot[];
|
||||
feedin?: ForecastSlot[];
|
||||
}
|
||||
|
||||
export interface SelectOption<T> {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -56,4 +56,8 @@ const (
|
|||
|
||||
// external battery control
|
||||
BatteryModeExternal = "batteryModeExternal"
|
||||
|
||||
// smart charging
|
||||
SmartCostAvailable = "smartCostAvailable" // smart cost available
|
||||
SmartFeedInPriorityAvailable = "smartFeedInPriorityAvailable" // smart feed-in priority available
|
||||
)
|
||||
|
|
|
|||
|
|
@ -107,15 +107,16 @@ type Loadpoint struct {
|
|||
MinCurrent_ float64 `mapstructure:"minCurrent"` // ignored, present for compatibility
|
||||
MaxCurrent_ float64 `mapstructure:"maxCurrent"` // ignored, present for compatibility
|
||||
|
||||
title string // UI title
|
||||
priority int // Priority
|
||||
minCurrent float64 // PV mode: start current Min+PV mode: min current
|
||||
maxCurrent float64 // Max allowed current. Physically ensured by the charger
|
||||
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
|
||||
batteryBoost int // battery boost state
|
||||
title string // UI title
|
||||
priority int // Priority
|
||||
minCurrent float64 // PV mode: start current Min+PV mode: min current
|
||||
maxCurrent float64 // Max allowed current. Physically ensured by the charger
|
||||
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 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
|
||||
enabled bool // Charger enabled state
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,18 +17,19 @@ type StaticConfig struct {
|
|||
|
||||
type DynamicConfig struct {
|
||||
// dynamic config
|
||||
Title string `json:"title"`
|
||||
DefaultMode string `json:"defaultMode"`
|
||||
Priority int `json:"priority"`
|
||||
PhasesConfigured int `json:"phasesConfigured"`
|
||||
MinCurrent float64 `json:"minCurrent"`
|
||||
MaxCurrent float64 `json:"maxCurrent"`
|
||||
SmartCostLimit *float64 `json:"smartCostLimit"`
|
||||
PlanEnergy float64 `json:"planEnergy"`
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
PlanPrecondition int64 `json:"planPrecondition"`
|
||||
LimitEnergy float64 `json:"limitEnergy"`
|
||||
LimitSoc int `json:"limitSoc"`
|
||||
Title string `json:"title"`
|
||||
DefaultMode string `json:"defaultMode"`
|
||||
Priority int `json:"priority"`
|
||||
PhasesConfigured int `json:"phasesConfigured"`
|
||||
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"`
|
||||
LimitEnergy float64 `json:"limitEnergy"`
|
||||
LimitSoc int `json:"limitSoc"`
|
||||
|
||||
Thresholds ThresholdsConfig `json:"thresholds"`
|
||||
Soc SocConfig `json:"soc"`
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
return active, nextStart
|
||||
}
|
||||
|
||||
// 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()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
28
core/site.go
28
core/site.go
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -944,7 +944,7 @@
|
|||
}
|
||||
},
|
||||
"smartCost": {
|
||||
"activeHours": "{charging} от {total}",
|
||||
"activeHours": "{active} от {total}",
|
||||
"activeHoursLabel": "Активни часове",
|
||||
"applyToAll": "Приложи навсякъде?",
|
||||
"batteryDescription": "Зарежда домашната батерия с енергия от мрежата.",
|
||||
|
|
|
|||
|
|
@ -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₂",
|
||||
|
|
|
|||
|
|
@ -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ě.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -956,7 +956,7 @@
|
|||
}
|
||||
},
|
||||
"smartCost": {
|
||||
"activeHours": "{charging} από {total}",
|
||||
"activeHours": "{active} από {total}",
|
||||
"activeHoursLabel": "Ενεργές ώρες",
|
||||
"applyToAll": "Εφαρμογή παντού;",
|
||||
"batteryDescription": "Φορτίζει την οικιακή μπαταρία με ενέργεια από το δίκτυο.",
|
||||
|
|
|
|||
12
i18n/en.json
12
i18n/en.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@
|
|||
}
|
||||
},
|
||||
"smartCost": {
|
||||
"activeHours": "{charging} av {total}",
|
||||
"activeHours": "{active} av {total}",
|
||||
"activeHoursLabel": "Aktive timer",
|
||||
"co2Label": "CO₂-utslipp",
|
||||
"co2Limit": "CO₂-grense",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -429,7 +429,7 @@
|
|||
}
|
||||
},
|
||||
"smartCost": {
|
||||
"activeHours": "{charging} din {total}",
|
||||
"activeHours": "{active} din {total}",
|
||||
"activeHoursLabel": "Ore de functionare",
|
||||
"co2Label": "emisii CO₂",
|
||||
"co2Limit": "limita CO₂",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -723,7 +723,7 @@
|
|||
}
|
||||
},
|
||||
"smartCost": {
|
||||
"activeHours": "{charging} இன் {total} \"",
|
||||
"activeHours": "{active} இன் {total} \"",
|
||||
"activeHoursLabel": "செயலில் நேரம்",
|
||||
"applyToAll": "எல்லா இடங்களிலும் விண்ணப்பிக்கவா?",
|
||||
"batteryDescription": "கட்டத்தில் இருந்து ஆற்றலுடன் வீட்டு பேட்டரியை வசூலிக்கிறது.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -957,7 +957,7 @@
|
|||
}
|
||||
},
|
||||
"smartCost": {
|
||||
"activeHours": "{charging} зі {total}",
|
||||
"activeHours": "{active} зі {total}",
|
||||
"activeHoursLabel": "Активні години",
|
||||
"applyToAll": "Застосовувати всюди?",
|
||||
"batteryDescription": "Заряджає домашній акумулятор енергією з мережі.",
|
||||
|
|
|
|||
|
|
@ -956,7 +956,7 @@
|
|||
}
|
||||
},
|
||||
"smartCost": {
|
||||
"activeHours": "{charging} / {total}",
|
||||
"activeHours": "{active} / {total}",
|
||||
"activeHoursLabel": "有效时段",
|
||||
"applyToAll": "应用于所有地方?",
|
||||
"batteryDescription": "使用电网能源为住宅电池充电。",
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
@ -177,29 +187,31 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
|
|||
api := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id+1)).Subrouter()
|
||||
|
||||
routes := map[string]route{
|
||||
"mode": {"POST", "/mode/{value:[a-z]+}", handler(eapi.ChargeModeString, pass(lp.SetMode), lp.GetMode)},
|
||||
"limitsoc": {"POST", "/limitsoc/{value:[0-9]+}", intHandler(pass(lp.SetLimitSoc), lp.GetLimitSoc)},
|
||||
"limitenergy": {"POST", "/limitenergy/{value:[0-9.]+}", floatHandler(pass(lp.SetLimitEnergy), lp.GetLimitEnergy)},
|
||||
"mincurrent": {"POST", "/mincurrent/{value:[0-9.]+}", floatHandler(lp.SetMinCurrent, lp.GetMinCurrent)},
|
||||
"maxcurrent": {"POST", "/maxcurrent/{value:[0-9.]+}", floatHandler(lp.SetMaxCurrent, lp.GetMaxCurrent)},
|
||||
"phases": {"POST", "/phases/{value:[0-9]+}", intHandler(lp.SetPhasesConfigured, lp.GetPhasesConfigured)},
|
||||
"plan": {"GET", "/plan", planHandler(lp)},
|
||||
"staticPlanPreview": {"GET", "/plan/static/preview/{type:(?:soc|energy)}/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", staticPlanPreviewHandler(lp)},
|
||||
"repeatingPlanPreview": {"GET", "/plan/repeating/preview/{soc:[0-9]+}/{weekdays:[0-6,]+}/{time:[0-2][0-9]:[0-5][0-9]}/{tz:[a-zA-Z0-9_./:-]+}", repeatingPlanPreviewHandler(lp)},
|
||||
"planenergy": {"POST", "/plan/energy/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", planEnergyHandler(lp)},
|
||||
"planenergy2": {"DELETE", "/plan/energy", planRemoveHandler(lp)},
|
||||
"vehicle": {"POST", "/vehicle/{name:[a-zA-Z0-9_.:-]+}", vehicleSelectHandler(site, lp)},
|
||||
"vehicle2": {"DELETE", "/vehicle", vehicleRemoveHandler(lp)},
|
||||
"vehicleDetect": {"PATCH", "/vehicle", vehicleDetectHandler(lp)},
|
||||
"remotedemand": {"POST", "/remotedemand/{demand:[a-z]+}/{source:[0-9a-zA-Z_-]+}", remoteDemandHandler(lp)},
|
||||
"enableThreshold": {"POST", "/enable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetEnableThreshold), lp.GetEnableThreshold)},
|
||||
"enableDelay": {"POST", "/enable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetEnableDelay), lp.GetEnableDelay)},
|
||||
"disableThreshold": {"POST", "/disable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetDisableThreshold), lp.GetDisableThreshold)},
|
||||
"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)},
|
||||
"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 })},
|
||||
"mode": {"POST", "/mode/{value:[a-z]+}", handler(eapi.ChargeModeString, pass(lp.SetMode), lp.GetMode)},
|
||||
"limitsoc": {"POST", "/limitsoc/{value:[0-9]+}", intHandler(pass(lp.SetLimitSoc), lp.GetLimitSoc)},
|
||||
"limitenergy": {"POST", "/limitenergy/{value:[0-9.]+}", floatHandler(pass(lp.SetLimitEnergy), lp.GetLimitEnergy)},
|
||||
"mincurrent": {"POST", "/mincurrent/{value:[0-9.]+}", floatHandler(lp.SetMinCurrent, lp.GetMinCurrent)},
|
||||
"maxcurrent": {"POST", "/maxcurrent/{value:[0-9.]+}", floatHandler(lp.SetMaxCurrent, lp.GetMaxCurrent)},
|
||||
"phases": {"POST", "/phases/{value:[0-9]+}", intHandler(lp.SetPhasesConfigured, lp.GetPhasesConfigured)},
|
||||
"plan": {"GET", "/plan", planHandler(lp)},
|
||||
"staticPlanPreview": {"GET", "/plan/static/preview/{type:(?:soc|energy)}/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", staticPlanPreviewHandler(lp)},
|
||||
"repeatingPlanPreview": {"GET", "/plan/repeating/preview/{soc:[0-9]+}/{weekdays:[0-6,]+}/{time:[0-2][0-9]:[0-5][0-9]}/{tz:[a-zA-Z0-9_./:-]+}", repeatingPlanPreviewHandler(lp)},
|
||||
"planenergy": {"POST", "/plan/energy/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", planEnergyHandler(lp)},
|
||||
"planenergy2": {"DELETE", "/plan/energy", planRemoveHandler(lp)},
|
||||
"vehicle": {"POST", "/vehicle/{name:[a-zA-Z0-9_.:-]+}", vehicleSelectHandler(site, lp)},
|
||||
"vehicle2": {"DELETE", "/vehicle", vehicleRemoveHandler(lp)},
|
||||
"vehicleDetect": {"PATCH", "/vehicle", vehicleDetectHandler(lp)},
|
||||
"remotedemand": {"POST", "/remotedemand/{demand:[a-z]+}/{source:[0-9a-zA-Z_-]+}", remoteDemandHandler(lp)},
|
||||
"enableThreshold": {"POST", "/enable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetEnableThreshold), lp.GetEnableThreshold)},
|
||||
"enableDelay": {"POST", "/enable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetEnableDelay), lp.GetEnableDelay)},
|
||||
"disableThreshold": {"POST", "/disable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetDisableThreshold), lp.GetDisableThreshold)},
|
||||
"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 })},
|
||||
}
|
||||
|
||||
for _, r := range routes {
|
||||
|
|
|
|||
|
|
@ -29,20 +29,21 @@ func getLoadpointStaticConfig(lp loadpoint.API) loadpoint.StaticConfig {
|
|||
func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig {
|
||||
planTime, planPrecondition, planEnergy := lp.GetPlanEnergy()
|
||||
return loadpoint.DynamicConfig{
|
||||
Title: lp.GetTitle(),
|
||||
DefaultMode: string(lp.GetDefaultMode()),
|
||||
Priority: lp.GetPriority(),
|
||||
PhasesConfigured: lp.GetPhasesConfigured(),
|
||||
MinCurrent: lp.GetMinCurrent(),
|
||||
MaxCurrent: lp.GetMaxCurrent(),
|
||||
SmartCostLimit: lp.GetSmartCostLimit(),
|
||||
Thresholds: lp.GetThresholds(),
|
||||
Soc: lp.GetSocConfig(),
|
||||
PlanEnergy: planEnergy,
|
||||
PlanTime: planTime,
|
||||
PlanPrecondition: int64(planPrecondition.Seconds()),
|
||||
LimitEnergy: lp.GetLimitEnergy(),
|
||||
LimitSoc: lp.GetLimitSoc(),
|
||||
Title: lp.GetTitle(),
|
||||
DefaultMode: string(lp.GetDefaultMode()),
|
||||
Priority: lp.GetPriority(),
|
||||
PhasesConfigured: lp.GetPhasesConfigured(),
|
||||
MinCurrent: lp.GetMinCurrent(),
|
||||
MaxCurrent: lp.GetMaxCurrent(),
|
||||
SmartCostLimit: lp.GetSmartCostLimit(),
|
||||
SmartFeedInPriorityLimit: lp.GetSmartFeedInPriorityLimit(),
|
||||
Thresholds: lp.GetThresholds(),
|
||||
Soc: lp.GetSocConfig(),
|
||||
PlanEnergy: planEnergy,
|
||||
PlanTime: planTime,
|
||||
PlanPrecondition: int64(planPrecondition.Seconds()),
|
||||
LimitEnergy: lp.GetLimitEnergy(),
|
||||
LimitSoc: lp.GetLimitSoc(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
45
tests/smart-feedin.evcc.yaml
Executable 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
|
||||
74
tests/smart-feedin.spec.ts
Normal file
74
tests/smart-feedin.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue