From 42ed7da2e9f3ea86fd5bc55692b88684fbbe21ea Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 27 Jun 2025 09:03:46 +0200 Subject: [PATCH] Add smart feed-in priority (#21813) --- .../Battery/BatterySettingsModal.vue | 22 +- assets/js/components/Loadpoints/Loadpoint.vue | 10 +- .../js/components/Loadpoints/Loadpoints.vue | 6 + .../js/components/Loadpoints/Mode.stories.ts | 6 +- assets/js/components/Loadpoints/Mode.vue | 6 +- .../components/Loadpoints/SettingsModal.vue | 304 +++--- .../MaterialIcon/MaterialIcon.story.ts | 129 +++ .../js/components/MaterialIcon/SunPause.vue | 19 + assets/js/components/Site/Site.vue | 5 + .../js/components/Tariff/SmartCostLimit.vue | 403 ++------ .../components/Tariff/SmartFeedInPriority.vue | 87 ++ .../js/components/Tariff/SmartTariffBase.vue | 394 ++++++++ assets/js/components/Vehicles/Status.vue | 866 ++++++------------ assets/js/components/Vehicles/StatusItem.vue | 107 +++ assets/js/components/Vehicles/Vehicle.vue | 7 + assets/js/types/evcc.ts | 1 + assets/js/utils/smartCostAvailable.ts | 5 - core/keys/loadpoint.go | 8 +- core/keys/site.go | 4 + core/loadpoint.go | 56 +- core/loadpoint/api.go | 6 +- core/loadpoint/config.go | 26 +- core/loadpoint/mock.go | 26 + core/loadpoint_api.go | 25 +- core/loadpoint_smartcost.go | 34 +- core/loadpoint_test.go | 28 +- core/loadpoint_vehicle_test.go | 4 +- core/site.go | 28 +- core/site_battery.go | 4 +- core/site_tariffs.go | 5 + i18n/bg.json | 2 +- i18n/ca.json | 2 +- i18n/cs.json | 2 +- i18n/da.json | 2 +- i18n/de.json | 2 +- i18n/el.json | 2 +- i18n/en.json | 12 +- i18n/es.json | 2 +- i18n/fi.json | 2 +- i18n/fr.json | 2 +- i18n/hr.json | 2 +- i18n/hu.json | 2 +- i18n/it.json | 2 +- i18n/lb.json | 2 +- i18n/lt.json | 2 +- i18n/nl.json | 2 +- i18n/no.json | 2 +- i18n/pl.json | 2 +- i18n/pt.json | 2 +- i18n/ro.json | 2 +- i18n/sl.json | 2 +- i18n/sv.json | 2 +- i18n/ta.json | 2 +- i18n/tr.json | 2 +- i18n/uk.json | 2 +- i18n/zh-Hans.json | 2 +- server/http.go | 62 +- server/http_config_loadpoint_handler.go | 29 +- server/http_site_handler.go | 5 +- server/mqtt.go | 6 + tests/battery-settings.spec.ts | 21 +- tests/smart-feedin.evcc.yaml | 45 + tests/smart-feedin.spec.ts | 74 ++ 63 files changed, 1645 insertions(+), 1290 deletions(-) create mode 100644 assets/js/components/MaterialIcon/MaterialIcon.story.ts create mode 100644 assets/js/components/MaterialIcon/SunPause.vue create mode 100644 assets/js/components/Tariff/SmartFeedInPriority.vue create mode 100644 assets/js/components/Tariff/SmartTariffBase.vue create mode 100644 assets/js/components/Vehicles/StatusItem.vue delete mode 100644 assets/js/utils/smartCostAvailable.ts create mode 100755 tests/smart-feedin.evcc.yaml create mode 100644 tests/smart-feedin.spec.ts diff --git a/assets/js/components/Battery/BatterySettingsModal.vue b/assets/js/components/Battery/BatterySettingsModal.vue index 76bac76e3..2a2d21803 100644 --- a/assets/js/components/Battery/BatterySettingsModal.vue +++ b/assets/js/components/Battery/BatterySettingsModal.vue @@ -244,7 +244,10 @@ @@ -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, default: () => [] }, batteryGridChargeLimit: { type: Number, default: null }, + smartCostAvailable: Boolean, smartCostType: String as PropType, tariffGrid: Number, - currency: String, + currency: String as PropType, + forecast: Object as PropType, }, 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) { diff --git a/assets/js/components/Loadpoints/Loadpoint.vue b/assets/js/components/Loadpoints/Loadpoint.vue index b35bc7cab..413aaf9ae 100644 --- a/assets/js/components/Loadpoints/Loadpoint.vue +++ b/assets/js/components/Loadpoints/Loadpoint.vue @@ -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, + 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(); }, diff --git a/assets/js/components/Loadpoints/Loadpoints.vue b/assets/js/components/Loadpoints/Loadpoints.vue index ab3d75456..059f69f46 100644 --- a/assets/js/components/Loadpoints/Loadpoints.vue +++ b/assets/js/components/Loadpoints/Loadpoints.vue @@ -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, default: () => [] }, vehicles: { type: Array as PropType }, smartCostType: String as PropType, + smartCostAvailable: Boolean, + smartFeedInPriorityAvailable: Boolean, tariffGrid: Number, tariffCo2: Number, + tariffFeedIn: Number, currency: String, selectedIndex: { type: Number, default: 0 }, gridConfigured: Boolean, diff --git a/assets/js/components/Loadpoints/Mode.stories.ts b/assets/js/components/Loadpoints/Mode.stories.ts index 9e6ac753e..c86a408aa 100644 --- a/assets/js/components/Loadpoints/Mode.stories.ts +++ b/assets/js/components/Loadpoints/Mode.stories.ts @@ -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, }; diff --git a/assets/js/components/Loadpoints/Mode.vue b/assets/js/components/Loadpoints/Mode.vue index c43cd0a45..fd9884966 100644 --- a/assets/js/components/Loadpoints/Mode.vue +++ b/assets/js/components/Loadpoints/Mode.vue @@ -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}`); diff --git a/assets/js/components/Loadpoints/SettingsModal.vue b/assets/js/components/Loadpoints/SettingsModal.vue index a6b783024..e433ff4b9 100644 --- a/assets/js/components/Loadpoints/SettingsModal.vue +++ b/assets/js/components/Loadpoints/SettingsModal.vue @@ -1,169 +1,141 @@ diff --git a/assets/js/components/Site/Site.vue b/assets/js/components/Site/Site.vue index e7d2dd686..7b90e3f7e 100644 --- a/assets/js/components/Site/Site.vue +++ b/assets/js/components/Site/Site.vue @@ -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, default: () => ({}) }, smartCostType: String as PropType, + smartCostAvailable: Boolean, + smartFeedInPriorityAvailable: Boolean, fatal: Object, forecast: Object as PropType, }, diff --git a/assets/js/components/Tariff/SmartCostLimit.vue b/assets/js/components/Tariff/SmartCostLimit.vue index 8f86d386e..1ee997f75 100644 --- a/assets/js/components/Tariff/SmartCostLimit.vue +++ b/assets/js/components/Tariff/SmartCostLimit.vue @@ -1,382 +1,99 @@ - - diff --git a/assets/js/components/Tariff/SmartFeedInPriority.vue b/assets/js/components/Tariff/SmartFeedInPriority.vue new file mode 100644 index 000000000..4f1b0896e --- /dev/null +++ b/assets/js/components/Tariff/SmartFeedInPriority.vue @@ -0,0 +1,87 @@ + + + diff --git a/assets/js/components/Tariff/SmartTariffBase.vue b/assets/js/components/Tariff/SmartTariffBase.vue new file mode 100644 index 000000000..91af519d6 --- /dev/null +++ b/assets/js/components/Tariff/SmartTariffBase.vue @@ -0,0 +1,394 @@ + + + + + diff --git a/assets/js/components/Vehicles/Status.vue b/assets/js/components/Vehicles/Status.vue index 51a991250..8871b0929 100644 --- a/assets/js/components/Vehicles/Status.vue +++ b/assets/js/components/Vehicles/Status.vue @@ -8,171 +8,41 @@ {{ chargerStatus }}
- -
- - -
{{ fmtDuration(pvRemainingInterpolated) }}
-
-
- -
{{ fmtDuration(phaseRemainingInterpolated) }}
-
- - - - - - - - - - - - + + + +
@@ -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) => + 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; } diff --git a/assets/js/components/Vehicles/StatusItem.vue b/assets/js/components/Vehicles/StatusItem.vue new file mode 100644 index 000000000..bd059dbd2 --- /dev/null +++ b/assets/js/components/Vehicles/StatusItem.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/assets/js/components/Vehicles/Vehicle.vue b/assets/js/components/Vehicles/Vehicle.vue index 251841209..d2bdfe30a 100644 --- a/assets/js/components/Vehicles/Vehicle.vue +++ b/assets/js/components/Vehicles/Vehicle.vue @@ -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, vehicleDetectionActive: Boolean, vehicleName: String, @@ -222,6 +226,9 @@ export default defineComponent({ smartCostDisabled() { return this.chargingPlanDisabled; }, + smartFeedInPriorityDisabled() { + return this.chargingPlanDisabled; + }, }, watch: { effectiveLimitSoc() { diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index ded90e70c..fd14988d8 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -187,6 +187,7 @@ export interface Forecast { co2?: ForecastSlot[]; solar?: SolarDetails; planner?: ForecastSlot[]; + feedin?: ForecastSlot[]; } export interface SelectOption { diff --git a/assets/js/utils/smartCostAvailable.ts b/assets/js/utils/smartCostAvailable.ts deleted file mode 100644 index 6e71504fa..000000000 --- a/assets/js/utils/smartCostAvailable.ts +++ /dev/null @@ -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); -} diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go index 76620aac0..ac72074f1 100644 --- a/core/keys/loadpoint.go +++ b/core/keys/loadpoint.go @@ -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 diff --git a/core/keys/site.go b/core/keys/site.go index de780b2a6..45593e4d0 100644 --- a/core/keys/site.go +++ b/core/keys/site.go @@ -56,4 +56,8 @@ const ( // external battery control BatteryModeExternal = "batteryModeExternal" + + // smart charging + SmartCostAvailable = "smartCostAvailable" // smart cost available + SmartFeedInPriorityAvailable = "smartFeedInPriorityAvailable" // smart feed-in priority available ) diff --git a/core/loadpoint.go b/core/loadpoint.go index 6ef271d2d..e0e4b81bd 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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() { diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index 6908ecbbe..93f539217 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -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 diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go index 6357ea3d2..3bc38d83d 100644 --- a/core/loadpoint/config.go +++ b/core/loadpoint/config.go @@ -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) diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index e098a02cd..63c75498e 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -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() diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index 049021ca5..a45089461 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -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() diff --git a/core/loadpoint_smartcost.go b/core/loadpoint_smartcost.go index 6c7f767de..5d794c3e0 100644 --- a/core/loadpoint_smartcost.go +++ b/core/loadpoint_smartcost.go @@ -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 } } diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index 68aa9347d..fe8afb9cf 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -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() diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go index 1905c1d2b..4c1ea4490 100644 --- a/core/loadpoint_vehicle_test.go +++ b/core/loadpoint_vehicle_test.go @@ -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 diff --git a/core/site.go b/core/site.go index ea3a9fa37..de00de765 100644 --- a/core/site.go +++ b/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 { diff --git a/core/site_battery.go b/core/site_battery.go index 3ad41120d..61cede82f 100644 --- a/core/site_battery.go +++ b/core/site_battery.go @@ -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 } diff --git a/core/site_tariffs.go b/core/site_tariffs.go index 270b65a61..a91289929 100644 --- a/core/site_tariffs.go +++ b/core/site_tariffs.go @@ -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 +} diff --git a/i18n/bg.json b/i18n/bg.json index 5bbf499ba..9f386263b 100644 --- a/i18n/bg.json +++ b/i18n/bg.json @@ -944,7 +944,7 @@ } }, "smartCost": { - "activeHours": "{charging} от {total}", + "activeHours": "{active} от {total}", "activeHoursLabel": "Активни часове", "applyToAll": "Приложи навсякъде?", "batteryDescription": "Зарежда домашната батерия с енергия от мрежата.", diff --git a/i18n/ca.json b/i18n/ca.json index 3655f36b9..d91fdf277 100644 --- a/i18n/ca.json +++ b/i18n/ca.json @@ -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₂", diff --git a/i18n/cs.json b/i18n/cs.json index 796119e6b..fe89aa31d 100644 --- a/i18n/cs.json +++ b/i18n/cs.json @@ -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ě.", diff --git a/i18n/da.json b/i18n/da.json index 26c1f921d..6d07123da 100644 --- a/i18n/da.json +++ b/i18n/da.json @@ -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.", diff --git a/i18n/de.json b/i18n/de.json index 5c453858f..1ef7451c3 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -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.", diff --git a/i18n/el.json b/i18n/el.json index 7b13a0745..2e03facd6 100644 --- a/i18n/el.json +++ b/i18n/el.json @@ -956,7 +956,7 @@ } }, "smartCost": { - "activeHours": "{charging} από {total}", + "activeHours": "{active} από {total}", "activeHoursLabel": "Ενεργές ώρες", "applyToAll": "Εφαρμογή παντού;", "batteryDescription": "Φορτίζει την οικιακή μπαταρία με ενέργεια από το δίκτυο.", diff --git a/i18n/en.json b/i18n/en.json index 1623071f7..27d89abcb 100644 --- a/i18n/en.json +++ b/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", diff --git a/i18n/es.json b/i18n/es.json index e063c6aee..3edbaabd9 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -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", diff --git a/i18n/fi.json b/i18n/fi.json index 7058197cd..efc492086 100644 --- a/i18n/fi.json +++ b/i18n/fi.json @@ -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.", diff --git a/i18n/fr.json b/i18n/fr.json index 7b0ed2198..1a66c79ff 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -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.", diff --git a/i18n/hr.json b/i18n/hr.json index 4419ee5bb..3679f15ce 100644 --- a/i18n/hr.json +++ b/i18n/hr.json @@ -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.", diff --git a/i18n/hu.json b/i18n/hu.json index c3872436e..7adf54954 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -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.", diff --git a/i18n/it.json b/i18n/it.json index 5a6d9e5f4..931255f52 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -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", diff --git a/i18n/lb.json b/i18n/lb.json index 387fddd79..8e26a5093 100644 --- a/i18n/lb.json +++ b/i18n/lb.json @@ -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.", diff --git a/i18n/lt.json b/i18n/lt.json index 5f9b255d4..1aaad2df2 100644 --- a/i18n/lt.json +++ b/i18n/lt.json @@ -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.", diff --git a/i18n/nl.json b/i18n/nl.json index b682e6d2c..2d1b253af 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -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.", diff --git a/i18n/no.json b/i18n/no.json index 7dda10a87..a813a8de3 100644 --- a/i18n/no.json +++ b/i18n/no.json @@ -347,7 +347,7 @@ } }, "smartCost": { - "activeHours": "{charging} av {total}", + "activeHours": "{active} av {total}", "activeHoursLabel": "Aktive timer", "co2Label": "CO₂-utslipp", "co2Limit": "CO₂-grense", diff --git a/i18n/pl.json b/i18n/pl.json index f4635ab8a..047d16608 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -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.", diff --git a/i18n/pt.json b/i18n/pt.json index c50a3fad9..fea167cfc 100644 --- a/i18n/pt.json +++ b/i18n/pt.json @@ -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.", diff --git a/i18n/ro.json b/i18n/ro.json index a2c4af09d..f82e052ac 100644 --- a/i18n/ro.json +++ b/i18n/ro.json @@ -429,7 +429,7 @@ } }, "smartCost": { - "activeHours": "{charging} din {total}", + "activeHours": "{active} din {total}", "activeHoursLabel": "Ore de functionare", "co2Label": "emisii CO₂", "co2Limit": "limita CO₂", diff --git a/i18n/sl.json b/i18n/sl.json index 8bca6aee6..d50b97097 100644 --- a/i18n/sl.json +++ b/i18n/sl.json @@ -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.", diff --git a/i18n/sv.json b/i18n/sv.json index 336698395..7afe547bc 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -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.", diff --git a/i18n/ta.json b/i18n/ta.json index d2388c010..e5d58ce05 100644 --- a/i18n/ta.json +++ b/i18n/ta.json @@ -723,7 +723,7 @@ } }, "smartCost": { - "activeHours": "{charging} இன் {total} \"", + "activeHours": "{active} இன் {total} \"", "activeHoursLabel": "செயலில் நேரம்", "applyToAll": "எல்லா இடங்களிலும் விண்ணப்பிக்கவா?", "batteryDescription": "கட்டத்தில் இருந்து ஆற்றலுடன் வீட்டு பேட்டரியை வசூலிக்கிறது.", diff --git a/i18n/tr.json b/i18n/tr.json index a14f1ce76..653551267 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -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.", diff --git a/i18n/uk.json b/i18n/uk.json index f914c46e0..4e92ae3e3 100644 --- a/i18n/uk.json +++ b/i18n/uk.json @@ -957,7 +957,7 @@ } }, "smartCost": { - "activeHours": "{charging} зі {total}", + "activeHours": "{active} зі {total}", "activeHoursLabel": "Активні години", "applyToAll": "Застосовувати всюди?", "batteryDescription": "Заряджає домашній акумулятор енергією з мережі.", diff --git a/i18n/zh-Hans.json b/i18n/zh-Hans.json index 1a5100eb9..943d0acb1 100644 --- a/i18n/zh-Hans.json +++ b/i18n/zh-Hans.json @@ -956,7 +956,7 @@ } }, "smartCost": { - "activeHours": "{charging} / {total}", + "activeHours": "{active} / {total}", "activeHoursLabel": "有效时段", "applyToAll": "应用于所有地方?", "batteryDescription": "使用电网能源为住宅电池充电。", diff --git a/server/http.go b/server/http.go index 77302f9d3..161fa3ed2 100644 --- a/server/http.go +++ b/server/http.go @@ -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 { diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go index 5957b495d..baa45626b 100644 --- a/server/http_config_loadpoint_handler.go +++ b/server/http_config_loadpoint_handler.go @@ -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(), } } diff --git a/server/http_site_handler.go b/server/http_site_handler.go index c650fbb37..18f285447 100644 --- a/server/http_site_handler.go +++ b/server/http_site_handler.go @@ -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) diff --git a/server/mqtt.go b/server/mqtt.go index 3e004bcdc..30d5a6f5a 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -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 { diff --git a/tests/battery-settings.spec.ts b/tests/battery-settings.spec.ts index 6fd3ead50..cd7c85bd4 100644 --- a/tests/battery-settings.spec.ts +++ b/tests/battery-settings.spec.ts @@ -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(); }); }); diff --git a/tests/smart-feedin.evcc.yaml b/tests/smart-feedin.evcc.yaml new file mode 100755 index 000000000..6e3498dd4 --- /dev/null +++ b/tests/smart-feedin.evcc.yaml @@ -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 diff --git a/tests/smart-feedin.spec.ts b/tests/smart-feedin.spec.ts new file mode 100644 index 000000000..9916fdb15 --- /dev/null +++ b/tests/smart-feedin.spec.ts @@ -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"); + }); +});