Curtailment devices: new device class for feed-in limitation (BC) (#32416)

This commit is contained in:
andig 2026-08-22 13:10:59 +02:00 • committed by GitHub
parent 7c0ddf1828
commit 8661c66fc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 819 additions and 149 deletions

View file

@ -0,0 +1,97 @@
<template>
<DeviceModalBase
:id="id"
name="curtailer"
device-type="curtailer"
:modal-title="$t(`config.curtailer.${isNew ? 'titleAdd' : 'titleEdit'}`)"
:provide-template-options="provideTemplateOptions"
:initial-values="initialValues"
:on-template-change="handleTemplateChange"
:preserve-on-template-change="preserveFields"
@added="(name) => $emit('changed', { action: 'added', name })"
@updated="$emit('changed', { action: 'updated' })"
@removed="$emit('changed', { action: 'removed' })"
>
<template #description>
<p class="mt-0 mb-4">{{ $t("config.curtailer.description") }}</p>
</template>
<template #before-template="{ values }">
<FormRow id="curtailerParamDeviceTitle" :label="$t('config.general.title')">
<PropertyField
id="curtailerParamDeviceTitle"
v-model.trim="values.deviceTitle"
type="String"
size="w-100"
class="me-2"
required
/>
</FormRow>
</template>
</DeviceModalBase>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue";
import FormRow from "./FormRow.vue";
import PropertyField from "./PropertyField.vue";
import type { DeviceValues, Product } from "./DeviceModal";
import { type TemplateGroup, customTemplateOption } from "./DeviceModal/TemplateSelector.vue";
import { ConfigType } from "@/types/evcc";
import defaultCurtailerYaml from "./defaultYaml/curtailer.yaml?raw";
import { getModal } from "@/configModal";
const initialValues = {
type: ConfigType.Template,
deviceTitle: "",
template: null,
};
export default defineComponent({
name: "CurtailerModal",
components: {
DeviceModalBase,
FormRow,
PropertyField,
},
emits: ["changed"],
data() {
return {
initialValues,
preserveFields: ["deviceTitle"],
};
},
computed: {
id(): number | undefined {
return getModal("curtailer")?.id;
},
isNew(): boolean {
return this.id === undefined;
},
},
methods: {
provideTemplateOptions(products: Product[]): TemplateGroup[] {
return [
{
label: "generic",
options: [
...products.filter((p: Product) => p.group === "generic"),
customTemplateOption(this.$t("config.general.customOption")),
],
},
{
label: "specific",
options: products.filter((p: Product) => p.group !== "generic"),
},
];
},
handleTemplateChange(value: string, values: DeviceValues) {
if (value === ConfigType.Custom) {
values.type = ConfigType.Custom;
values.yaml = defaultCurtailerYaml;
}
},
},
});
</script>

View file

@ -4,7 +4,7 @@
tabindex="0"
@click="$emit('click')"
>
<shopicon-regular-plus class="me-1"></shopicon-regular-plus>
<shopicon-regular-plus class="me-1 flex-shrink-0"></shopicon-regular-plus>
<span class="text-start">{{ title }}</span>
</button>
</template>

View file

@ -0,0 +1,28 @@
## required attributes
# feed-in limit in percent of nominal production power (0..100, 100 = uncurtailed)
curtail: # set feed-in limit
source: js
script: console.log(curtail)
curtailed: # current feed-in limit
source: const
value: 100
## alternative examples
#curtail:
# source: http
# uri: http://inverter.local/api/limit?percent={{ .curtail }}
# method: POST
#curtailed:
# source: http
# uri: http://inverter.local/api/limit
# jq: .percent
#curtail:
# source: modbus
# uri: 192.168.179.200:502
# id: 1
# register: { type: writesingle, address: 40016, encoding: uint16 }
#curtailed:
# source: error
# error: ErrNotAvailable

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M12.825 15.8q.75-.35 1.113-1.2t-.163-1.6q-1.1-1.575-2.212-3.062T9.3 6.95q-.275-.35-.687-.162t-.313.637q.4 1.825.85 3.625t1 3.65q.25.875 1.038 1.163t1.637-.063m-7.7 4.2q-.525 0-1.012-.238t-.738-.712q-.7-1.15-1.025-2.437T2.025 14q0-1.725.588-3.463t1.862-3.112q.375-.425.8-.412t.7.262t.35.625t-.225.725q-.95 1.05-1.512 2.475T4.025 14q0 1.025.263 2.038T5.125 18H18.9q.525-.9.813-1.925T20 14q0-1.875-.812-3.475T17.025 7.8q-1.05-.875-2.375-1.35T11.95 6q-.4 0-.712-.262t-.363-.663t.15-.725t.6-.35q1.825-.075 3.563.525T18.35 6.3q1.675 1.4 2.663 3.375T22 14q0 1.4-.35 2.663t-1 2.387q-.275.475-.75.713t-1 .237zm6.9-6.025"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "Curtail",
mixins: [icon],
});
</script>

View file

@ -460,6 +460,10 @@ export enum ConfigType {
export type ConfigVehicle = Entity;
export type ConfigMessenger = Entity;
export interface ConfigCurtailer extends Entity {
deviceTitle?: string;
}
export interface ConfigHems extends Entity {
deviceProduct?: string;
}
@ -1368,7 +1372,8 @@ export type DeviceType =
| "loadpoint"
| "messenger"
| "tariff"
| "hems";
| "hems"
| "curtailer";
export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext" | "consumer";
export type MeterTemplateUsage = "grid" | "pv" | "battery" | "charge" | "aux";
export type TariffType = "grid" | "feedIn" | "co2" | "planner" | "solar" | "temperature";
@ -1385,6 +1390,7 @@ export interface SiteConfig {
aux: string[] | null;
ext: string[] | null;
consumer: string[] | null;
curtail: string[] | null;
}
export type ValueOf<T> = T[keyof T];

View file

@ -150,6 +150,31 @@
data-testid="add-grid"
@click="openModal('meter', { type: 'grid' })"
/>
<DeviceCard
v-for="curtailer in curtailerDevices"
:id="`curtailer_${curtailer.name}`"
:key="curtailer.name"
:name="curtailer.name"
:title="curtailerTitle(curtailer)"
:editable="!!curtailer.id"
:error="hasDeviceError('curtailer', curtailer.name)"
:banner="curtailmentBanner('curtailer', curtailer.name)"
data-testid="curtailer"
@edit="openModal('curtailer', { id: curtailer.id })"
>
<template #icon>
<CurtailIcon />
</template>
<template #tags>
<DeviceTags :tags="deviceTags('curtailer', curtailer.name)" />
</template>
</DeviceCard>
<NewDeviceButton
v-if="pvMeters.length > 0"
:title="$t('config.main.addCurtailer')"
data-testid="add-curtailer"
@click="openModal('curtailer')"
/>
</div>
</ConfigSection>
@ -162,7 +187,7 @@
meter-type="pv"
:has-error="hasDeviceError('meter', meter.name)"
:tags="deviceTags('meter', meter.name)"
:banner="meterBanner(meter.name)"
:banner="curtailmentBanner('meter', meter.name)"
@edit="(type, id) => openModal('meter', { type, id })"
@enable="handleDisable('meter', meter.id, false)"
/>
@ -525,6 +550,7 @@
<MessagingLegacyModal @changed="loadDirty" />
<MessagingModal :messengers="messengers" @changed="loadDirty" />
<MessengerModal @changed="messengerChanged" />
<CurtailerModal @changed="curtailerChanged" />
<TariffsLegacyModal @changed="loadDirty" />
<TariffModal
:currency="currency"
@ -579,6 +605,8 @@ import CircuitsModal from "../components/Config/CircuitsModal.vue";
import CircuitTags from "../components/Config/CircuitTags.vue";
import collector from "../mixins/collector";
import ControlModal from "../components/Config/ControlModal.vue";
import CurtailerModal from "../components/Config/CurtailerModal.vue";
import CurtailIcon from "../components/MaterialIcon/Curtail.vue";
import DeviceCard from "../components/Config/DeviceCard.vue";
import DeviceTags from "../components/Config/DeviceTags.vue";
import EebusIcon from "../components/MaterialIcon/Eebus.vue";
@ -633,6 +661,7 @@ import type {
ConfigCharger,
ConfigVehicle,
ConfigCircuit,
ConfigCurtailer,
ConfigMessenger,
ConfigHems,
ConfigLoadpoint,
@ -691,6 +720,8 @@ export default defineComponent({
CircuitsModal,
CircuitTags,
ControlModal,
CurtailerModal,
CurtailIcon,
DeviceCard,
DeviceTags,
EebusIcon,
@ -749,6 +780,7 @@ export default defineComponent({
data() {
return {
messengers: [] as ConfigMessenger[],
curtailers: [] as ConfigCurtailer[],
vehicles: [] as ConfigVehicle[],
meters: [] as ConfigMeter[],
loadpoints: [] as ConfigLoadpoint[],
@ -772,6 +804,7 @@ export default defineComponent({
aux: null as string[] | null,
ext: null as string[] | null,
consumer: null as string[] | null,
curtail: null as string[] | null,
} as SiteConfig,
deviceValueTimeout: null as Timeout,
deviceValues: {
@ -781,6 +814,7 @@ export default defineComponent({
loadpoint: {},
messenger: {},
tariff: {},
curtailer: {},
} as DeviceValuesMap,
isComponentMounted: true,
isPageVisible: true,
@ -868,8 +902,10 @@ export default defineComponent({
{
slug: "grid",
icon: "shopicon-regular-powersupply",
count: this.gridMeter ? 1 : 0,
error: !!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name),
count: (this.gridMeter ? 1 : 0) + this.curtailerDevices.length,
error:
(!!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name)) ||
this.curtailerDevices.some((c) => this.hasDeviceError("curtailer", c.name)),
},
{
slug: "pv-battery",
@ -877,7 +913,7 @@ export default defineComponent({
count: pvAndBattery.length,
error: meterError(pvAndBattery),
warning:
this.pvMeters.some((m) => this.meterBanner(m.name)) ||
this.pvMeters.some((m) => this.curtailmentBanner("meter", m.name)) ||
meterDisabled(pvAndBattery),
},
{
@ -956,6 +992,12 @@ export default defineComponent({
const names = this.site?.ext;
return this.getMetersByNames(names);
},
curtailerDevices(): ConfigCurtailer[] {
const names = this.site?.curtail || [];
return names
.map((name) => this.curtailers.find((c) => c.name === name))
.filter((c): c is ConfigCurtailer => c !== undefined);
},
consumerMeters() {
return this.getMetersByNames(this.site?.consumer);
},
@ -1291,6 +1333,7 @@ export default defineComponent({
await this.loadLoadpoints();
await this.loadCircuits();
await this.loadMessengers();
await this.loadCurtailers();
await this.loadTariffs();
await this.loadTariffRefs();
await this.loadHems();
@ -1311,6 +1354,9 @@ export default defineComponent({
async loadMessengers() {
this.messengers = (await this.loadConfig("devices/messenger")) || [];
},
async loadCurtailers() {
this.curtailers = (await this.loadConfig("devices/curtailer")) || [];
},
async loadVehicles() {
this.vehicles = (await this.loadConfig("devices/vehicle")) || [];
},
@ -1464,6 +1510,25 @@ export default defineComponent({
this.loadMessengers();
this.loadDirty();
},
curtailerTitle(curtailer: ConfigCurtailer): string {
return (
curtailer.deviceTitle ||
curtailer.config?.template ||
this.$t("config.curtailer.title")
);
},
async curtailerChanged(result: ModalResult) {
if (result.action === "added" && result.name) {
this.site.curtail = [...(this.site.curtail || []), result.name];
await this.saveSite("curtail");
}
if (result.action === "removed") {
await this.loadSite();
}
await this.loadCurtailers();
await this.loadDirty();
this.updateValues();
},
siteChanged() {
this.loadDirty();
},
@ -1504,6 +1569,7 @@ export default defineComponent({
vehicle: this.vehicles,
charger: this.chargers,
tariff: this.tariffs,
curtailer: this.curtailers,
} as Record<DeviceType, any[]>;
for (const type in devices) {
for (const device of devices[type as DeviceType]) {
@ -1523,9 +1589,10 @@ export default defineComponent({
deviceTags(type: DeviceType, id: string) {
return this.deviceValues[type][id] || {};
},
meterBanner(name: string): string | undefined {
// the tag is only present while curtailing, a zero percent limit is still one
return this.deviceTags("meter", name)["curtailed"]?.value !== undefined
curtailmentBanner(type: DeviceType, name: string): string | undefined {
// devices report the allowed feed-in percent, 100 = uncurtailed
const value = this.deviceTags(type, name)["curtailed"]?.value;
return typeof value === "number" && value < 100
? this.$t("config.deviceValue.productionLimited")
: undefined;
},