Optimizer: add static grid export limit (#32541)

This commit is contained in:
andig 2026-08-07 12:58:14 +02:00 • committed by GitHub
parent f2c45b0d84
commit 3093539af5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 373 additions and 44 deletions

View file

@ -586,6 +586,16 @@ small {
.dark .form-switch .form-check-input:checked {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2328293e'/%3e%3c/svg%3e");
}
/* hide number input spinners */
input[type="number"] {
appearance: textfield;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* fix desktop safari formatting */
input::-webkit-datetime-edit {
display: block;
@ -873,6 +883,12 @@ input::-webkit-date-and-time-value {
visibility var(--evcc-transition-fast) ease var(--evcc-transition-fast);
}
/* clearance so overflow-hidden containers don't clip focus rings */
.ring-space {
padding: 0.5rem;
margin: -0.5rem;
}
.sticky-bottom {
bottom: calc(var(--tab-bar-height) + 0.25rem);
}

View file

@ -207,11 +207,6 @@ export default defineComponent({
</script>
<style scoped>
/* overflow-hidden would clip the select focus rings */
.ring-space {
padding: 0.5rem;
margin: -0.5rem;
}
.icon {
transform: rotate(0deg);
transition: transform var(--evcc-transition-medium) ease;

View file

@ -202,6 +202,7 @@
</DeviceModalActions>
</template>
</form>
<slot name="post-content" :values="values"></slot>
</GenericModal>
</template>

View file

@ -10,13 +10,14 @@
:is-yaml-input-type="isYamlInputType"
:on-template-change="handleTemplateChange"
:hide-template-fields="fromYaml"
:show-main-content="!experimental || activeTab === 'dynamic'"
:hide-delete="true"
:hide-info="true"
:keep-open-on-remove="true"
@added="onAdded"
@updated="onUpdated"
@removed="onRemoved"
@open="loadSessions"
@open="onOpen"
@close="$emit('close')"
>
<template v-if="id !== undefined && !fromYaml" #template-action>
@ -31,13 +32,37 @@
<shopicon-regular-edit size="s" class="flex-shrink-0"></shopicon-regular-edit>
</button>
</template>
<template #description>
<template #pre-content>
<p class="mt-0 mb-4">
{{ $t("config.hems.description") }}
<a :href="docsLink" target="_blank" rel="noopener">
{{ $t("config.general.docsLink") }}
</a>
</p>
<ul v-if="experimental" class="nav nav-tabs mb-4">
<li class="nav-item">
<a
class="nav-link"
:class="{ active: activeTab === 'dynamic' }"
href="#"
@click.prevent="activeTab = 'dynamic'"
>
{{ $t("config.hems.dynamicLimits") }}
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
:class="{ active: activeTab === 'static' }"
href="#"
@click.prevent="activeTab = 'static'"
>
{{ $t("config.hems.staticLimits") }} 🧪
</a>
</li>
</ul>
</template>
<template #description>
<div v-if="configured" class="mb-4" data-testid="grid-sessions">
<h6 class="mb-3">{{ $t("config.hems.recordedEvents") }}</h6>
<div class="events-box rounded p-3">
@ -60,6 +85,56 @@
{{ $t("config.general.fromYamlHint") }}
</p>
</template>
<template v-if="experimental && activeTab === 'static'" #post-content>
<div data-testid="grid-export-limit">
<div class="form-check form-switch">
<input
id="hemsExportLimitEnabled"
class="form-check-input"
type="checkbox"
role="switch"
:checked="exportLimitEnabled"
:disabled="exportLimitState === 'saving'"
@change="toggleExportLimit"
/>
<label for="hemsExportLimitEnabled" class="form-check-label">
{{ $t("config.hems.exportLimit") }}
</label>
<div class="ps-2">
<p class="text-muted small mb-2">
{{ $t("config.hems.exportLimitHint") }}
</p>
<div class="collapsible-wrapper" :class="{ open: exportLimitEnabled }">
<div class="collapsible-content ring-space">
<div class="d-flex align-items-center gap-2">
<div class="input-group input-width">
<input
id="hemsExportLimit"
v-model.number="exportLimit"
type="number"
min="0"
:aria-label="$t('config.hems.exportLimit')"
aria-describedby="hemsExportLimitUnit"
class="form-control text-end"
@input="onExportLimitInput"
@blur="commitExportLimit"
@keydown.enter.prevent="commitExportLimit"
/>
<span id="hemsExportLimitUnit" class="input-group-text">
W
</span>
</div>
<SavingIndicator :state="exportLimitState" />
</div>
<div class="form-text evcc-gray mb-2">
{{ $t("config.hems.exportLimitDescription") }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</DeviceModalBase>
</template>
@ -73,13 +148,17 @@ import { customTemplateOption, type TemplateGroup } from "./DeviceModal/Template
import customHemsYaml from "./defaultYaml/customHems.yaml?raw";
import relayHemsYaml from "./defaultYaml/relayHems.yaml?raw";
import api from "../../api";
import store from "@/store";
import { docsPrefix } from "@/i18n";
import DownloadButton from "../Helper/DownloadButton.vue";
import SavingIndicator, { type SavingState } from "../Helper/SavingIndicator.vue";
import formatter from "../../mixins/formatter";
// selector value for the relay variant; both variants save as type custom
const RELAY_OPTION = "relay";
let exportLimitTimer: ReturnType<typeof setTimeout> | undefined;
const initialValues = {
type: ConfigType.Template,
icon: undefined,
@ -90,7 +169,7 @@ const initialValues = {
export default defineComponent({
name: "HemsModal",
components: { DeviceModalBase, DownloadButton },
components: { DeviceModalBase, DownloadButton, SavingIndicator },
mixins: [formatter],
props: {
yamlSource: String as PropType<YamlSource>,
@ -102,6 +181,10 @@ export default defineComponent({
initialValues,
sessions: [] as Array<{ created: string }>,
changing: false,
exportLimit: null as number | null,
exportLimitEnabled: false,
exportLimitState: null as SavingState,
activeTab: "dynamic" as "dynamic" | "static",
};
},
computed: {
@ -127,8 +210,55 @@ export default defineComponent({
docsLink(): string {
return `${docsPrefix()}/docs/external-limit`;
},
experimental(): boolean {
return store.state?.experimental === true;
},
serverExportLimit(): number {
return store.state?.gridExportLimit || 0;
},
exportLimitUnchanged(): boolean {
return (this.exportLimit || 0) === this.serverExportLimit;
},
},
beforeUnmount() {
clearTimeout(exportLimitTimer);
},
methods: {
onOpen() {
this.exportLimit = this.serverExportLimit || null;
this.exportLimitEnabled = this.serverExportLimit > 0;
this.exportLimitState = null;
this.activeTab = "dynamic";
this.loadSessions();
},
onExportLimitInput() {
clearTimeout(exportLimitTimer);
exportLimitTimer = setTimeout(this.commitExportLimit, 2000);
},
commitExportLimit() {
clearTimeout(exportLimitTimer);
if (this.exportLimitState === "saving" || this.exportLimitUnchanged) return;
this.postExportLimit(this.exportLimit || 0);
},
toggleExportLimit($event: Event) {
const { checked } = $event.target as HTMLInputElement;
this.exportLimitEnabled = checked;
this.exportLimitState = null;
if (!checked) {
this.exportLimit = null;
this.commitExportLimit();
}
},
async postExportLimit(value: number) {
this.exportLimitState = "saving";
try {
await api.post(`gridexportlimit/${value}`);
this.exportLimitState = "saved";
} catch (e) {
this.exportLimitState = null;
console.error(e);
}
},
downloadHref(): string {
const params = new URLSearchParams({ lang: this.$i18n?.locale });
return `./api/gridsessions?${params.toString()}`;
@ -198,4 +328,7 @@ export default defineComponent({
.events-box {
background: var(--evcc-background);
}
.input-width {
max-width: 180px;
}
</style>

View file

@ -409,14 +409,6 @@ export default {
</script>
<style scoped>
input[type="number"] {
appearance: textfield;
}
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.w-min-100 {
min-width: min(100px, 100%);
}

View file

@ -216,16 +216,4 @@ export default {
min-width: min(200px, 100%);
}
/* Hide spinner for number input */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
</style>

View file

@ -0,0 +1,21 @@
<template>
<div class="small">
<span v-if="state === 'saving'">{{ $t("config.general.saving") }}</span>
<span v-else-if="state === 'saved'" class="text-primary">
{{ $t("config.general.saved") }}
</span>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
export type SavingState = "saving" | "saved" | null;
export default defineComponent({
name: "SavingIndicator",
props: {
state: { type: String as PropType<SavingState>, default: null },
},
});
</script>

View file

@ -367,14 +367,5 @@ export default defineComponent({
.odometer-input .form-control {
/* fit ~7 digits (e.g. 128222) */
width: calc(7ch + 1rem);
-moz-appearance: textfield;
appearance: textfield;
}
.odometer-input .form-control::-webkit-inner-spin-button,
.odometer-input .form-control::-webkit-outer-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
</style>

View file

@ -158,11 +158,6 @@ export default defineComponent({
</script>
<style scoped>
/* overflow-hidden would clip the vehicle select focus ring */
.ring-space {
padding: 0.5rem;
margin: -0.5rem;
}
.vehicle-name {
text-decoration-color: var(--evcc-gray);
}

View file

@ -210,6 +210,8 @@ export interface State {
homePower?: number;
/** Configured grid operating point in W. Positive values maintain grid import. */
residualPower?: number;
/** Static grid export power limit in W used as optimizer constraint, 0 = disabled. An active HEMS curtailment takes precedence. */
gridExportLimit?: number;
/** Share of green energy in home consumption, between 0 and 1. */
greenShareHome?: number;
/** Share of green energy used for charging, between 0 and 1. */