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

@ -64,6 +64,7 @@ type All struct {
Site map[string]any
Loadpoints []config.Named
Circuits []config.Named
Curtailers []config.Named
}
type Javascript struct {

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;
},

View file

@ -7,11 +7,11 @@ import (
"strings"
)
const _ClassName = "configfilemeterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsshminfluxmessengersponsorshiploadpoint"
const _ClassName = "configfilemeterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsshminfluxmessengersponsorshiploadpointcurtailer"
var _ClassIndex = [...]uint8{0, 10, 15, 22, 29, 35, 42, 46, 50, 58, 69, 74, 84, 86, 90, 93, 99, 108, 119, 128}
var _ClassIndex = [...]uint8{0, 10, 15, 22, 29, 35, 42, 46, 50, 58, 69, 74, 84, 86, 90, 93, 99, 108, 119, 128, 137}
const _ClassLowerName = "configfilemeterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsshminfluxmessengersponsorshiploadpoint"
const _ClassLowerName = "configfilemeterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsshminfluxmessengersponsorshiploadpointcurtailer"
func (i Class) String() string {
i -= 1
@ -44,9 +44,10 @@ func _ClassNoOp() {
_ = x[ClassMessenger-(17)]
_ = x[ClassSponsorship-(18)]
_ = x[ClassLoadpoint-(19)]
_ = x[ClassCurtailer-(20)]
}
var _ClassValues = []Class{ClassConfigFile, ClassMeter, ClassCharger, ClassVehicle, ClassTariff, ClassCircuit, ClassSite, ClassMqtt, ClassDatabase, ClassModbusProxy, ClassEEBus, ClassJavascript, ClassGo, ClassHEMS, ClassSHM, ClassInflux, ClassMessenger, ClassSponsorship, ClassLoadpoint}
var _ClassValues = []Class{ClassConfigFile, ClassMeter, ClassCharger, ClassVehicle, ClassTariff, ClassCircuit, ClassSite, ClassMqtt, ClassDatabase, ClassModbusProxy, ClassEEBus, ClassJavascript, ClassGo, ClassHEMS, ClassSHM, ClassInflux, ClassMessenger, ClassSponsorship, ClassLoadpoint, ClassCurtailer}
var _ClassNameToValueMap = map[string]Class{
_ClassName[0:10]: ClassConfigFile,
@ -87,6 +88,8 @@ var _ClassNameToValueMap = map[string]Class{
_ClassLowerName[108:119]: ClassSponsorship,
_ClassName[119:128]: ClassLoadpoint,
_ClassLowerName[119:128]: ClassLoadpoint,
_ClassName[128:137]: ClassCurtailer,
_ClassLowerName[128:137]: ClassCurtailer,
}
var _ClassNames = []string{
@ -109,6 +112,7 @@ var _ClassNames = []string{
_ClassName[99:108],
_ClassName[108:119],
_ClassName[119:128],
_ClassName[128:137],
}
// ClassString retrieves an enum value from the enum constants string name.

View file

@ -57,6 +57,8 @@ func runConfigDelete(cmd *cobra.Command, args []string) {
deleteDevice[api.Tariff](c)
case templates.Circuit:
deleteDevice[api.Circuit](c)
case templates.Curtailer:
deleteDevice[api.Curtailer](c)
case templates.Hems:
deleteDevice[hems.API](c)
case templates.Loadpoint:

View file

@ -29,6 +29,7 @@ const (
ClassMessenger
ClassSponsorship
ClassLoadpoint
ClassCurtailer
)
// FatalError is an error that can be marshaled

View file

@ -15,7 +15,7 @@ import (
)
var references struct {
meter, charger, vehicle, circuit, tariff []string
meter, charger, vehicle, circuit, tariff, curtailer []string
}
func collectRefs(conf globalconfig.All) error {
@ -57,6 +57,7 @@ func collectRefs(conf globalconfig.All) error {
func collectSiteRefs(conf globalconfig.All) error {
var refs struct {
Meters core.MetersConfig `mapstructure:"meters"` // Meter references
Curtailers []string `mapstructure:"curtailers"` // Curtailment device references
Other map[string]any `mapstructure:",remain"`
}
@ -70,6 +71,7 @@ func collectSiteRefs(conf globalconfig.All) error {
references.meter = append(references.meter, refs.Meters.ExtMetersRef...)
references.meter = append(references.meter, refs.Meters.AuxMetersRef...)
references.meter = append(references.meter, refs.Meters.ConsumerMetersRef...)
references.curtailer = append(references.curtailer, refs.Curtailers...)
// append devices from settings
if v, err := settings.String(keys.GridMeter); err == nil && v != "" {
@ -82,6 +84,10 @@ func collectSiteRefs(conf globalconfig.All) error {
}
}
if v, err := settings.String(keys.Curtailers); err == nil && v != "" {
references.curtailer = append(references.curtailer, strings.Split(v, ",")...)
}
return nil
}

View file

@ -25,6 +25,7 @@ import (
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/metrics"
coresettings "github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/curtailer"
"github.com/evcc-io/evcc/hems"
hemsapi "github.com/evcc-io/evcc/hems/hems"
"github.com/evcc-io/evcc/hems/shm"
@ -400,6 +401,50 @@ func configureMeters(static []config.Named, names ...string) error {
return eg.Wait()
}
func configureCurtailers(static []config.Named, names ...string) error {
var eg errgroup.Group
for i, cc := range static {
if cc.Name == "" {
return fmt.Errorf("cannot create curtailer %d: missing name", i+1)
}
// configure all, if no name refs are given
if len(names) > 0 && !slices.Contains(names, cc.Name) {
continue
}
if err := nameValid(cc.Name); err != nil {
log.WARN.Printf("create curtailer %d: %v", i+1, err)
}
eg.Go(func() error {
return staticInstance("curtailer", cc, curtailer.NewFromConfig, config.Curtailers())
})
}
// append devices from database
configurable, err := config.ConfigurationsByClass(templates.Curtailer)
if err != nil {
return err
}
for _, conf := range configurable {
eg.Go(func() error {
cc := conf.Named()
// always skip unreferenced db devices
if !slices.Contains(names, cc.Name) {
return nil
}
return configurableInstance("curtailer", &conf, curtailer.NewFromConfig, config.Curtailers())
})
}
return eg.Wait()
}
func configureChargers(static []config.Named, names ...string) error {
var eg errgroup.Group
@ -1287,6 +1332,10 @@ func configureDevices(conf globalconfig.All) error {
errs = append(errs, &ClassError{ClassCircuit, err})
}
if err := configureCurtailers(conf.Curtailers, references.curtailer...); err != nil {
errs = append(errs, &ClassError{ClassCurtailer, err})
}
return joinErrors(errs...)
}

View file

@ -41,6 +41,9 @@ const (
AuxMeters = "auxMeters"
ConsumerMeters = "consumerMeters"
// curtailment devices
Curtailers = "curtailers"
// battery settings
BatteryDischargeControl = "batteryDischargeControl"
BatteryGridChargeLimit = "batteryGridChargeLimit"

View file

@ -65,6 +65,7 @@ type Site struct {
Voltage float64 `mapstructure:"voltage"` // Operating voltage. 230V for Germany.
ResidualPower float64 `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin
Meters MetersConfig `mapstructure:"meters"` // Meter references
CurtailersRef []string `mapstructure:"curtailers"` // Curtailment device references
// meters
circuit api.Circuit // Circuit
@ -75,6 +76,7 @@ type Site struct {
extMeters []config.Device[api.Meter] // External meters - for monitoring only
auxMeters []config.Device[api.Meter] // Auxiliary meters
consumerMeters []config.Device[api.Meter] // Consumer meters
curtailers []config.Device[api.Curtailer]
// last applied HEMS state, nil until applied or after a failed attempt
dimmed *bool
@ -351,6 +353,15 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.collectors[ref] = me
}
// curtailment devices
for _, ref := range site.CurtailersRef {
dev, err := config.Curtailers().ByName(ref)
if err != nil {
return err
}
site.curtailers = append(site.curtailers, dev)
}
// revert battery mode on shutdown
shutdown.Register(func() {
if mode := site.GetBatteryMode(); batteryModeModified(mode) {
@ -395,19 +406,22 @@ func (site *Site) restoreMetersAndTitle() {
site.Meters.GridMeterRef = v
}
if v, err := settings.String(keys.PvMeters); err == nil && v != "" {
site.Meters.PVMetersRef = append(site.Meters.PVMetersRef, filterConfigurable(strings.Split(v, ","))...)
site.Meters.PVMetersRef = append(site.Meters.PVMetersRef, filterConfigurableMeter(strings.Split(v, ","))...)
}
if v, err := settings.String(keys.BatteryMeters); err == nil && v != "" {
site.Meters.BatteryMetersRef = append(site.Meters.BatteryMetersRef, filterConfigurable(strings.Split(v, ","))...)
site.Meters.BatteryMetersRef = append(site.Meters.BatteryMetersRef, filterConfigurableMeter(strings.Split(v, ","))...)
}
if v, err := settings.String(keys.ExtMeters); err == nil && v != "" {
site.Meters.ExtMetersRef = append(site.Meters.ExtMetersRef, filterConfigurable(strings.Split(v, ","))...)
site.Meters.ExtMetersRef = append(site.Meters.ExtMetersRef, filterConfigurableMeter(strings.Split(v, ","))...)
}
if v, err := settings.String(keys.AuxMeters); err == nil && v != "" {
site.Meters.AuxMetersRef = append(site.Meters.AuxMetersRef, filterConfigurable(strings.Split(v, ","))...)
site.Meters.AuxMetersRef = append(site.Meters.AuxMetersRef, filterConfigurableMeter(strings.Split(v, ","))...)
}
if v, err := settings.String(keys.ConsumerMeters); err == nil && v != "" {
site.Meters.ConsumerMetersRef = append(site.Meters.ConsumerMetersRef, filterConfigurable(strings.Split(v, ","))...)
site.Meters.ConsumerMetersRef = append(site.Meters.ConsumerMetersRef, filterConfigurableMeter(strings.Split(v, ","))...)
}
if v, err := settings.String(keys.Curtailers); err == nil && v != "" {
site.CurtailersRef = append(site.CurtailersRef, filterConfigurableCurtailers(strings.Split(v, ","))...)
}
}

View file

@ -28,6 +28,8 @@ type API interface {
// Config
GetGridMeterRef() string
SetGridMeterRef(string)
GetCurtailerRefs() []string
SetCurtailerRefs([]string)
GetPVMeterRefs() []string
SetPVMeterRefs([]string)
GetBatteryMeterRefs() []string

View file

@ -24,18 +24,23 @@ var (
ErrBatteryControlNotAvailable = errors.New("battery control not available")
)
// isConfigurable checks if the meter is configurable
func isConfigurable(ref string) bool {
dev, _ := config.Meters().ByName(ref)
_, ok := dev.(config.ConfigurableDevice[api.Meter])
// filterConfigurableDevices filters references to configurable devices of the given handler
func filterConfigurableDevices[T any](h config.Handler[T], ref []string) []string {
return lo.Filter(ref, func(ref string, _ int) bool {
dev, _ := h.ByName(ref)
_, ok := dev.(config.ConfigurableDevice[T])
return ok
})
}
// filterConfigurable filters configurable meters
func filterConfigurable(ref []string) []string {
return lo.Filter(ref, func(ref string, _ int) bool {
return isConfigurable(ref)
})
// filterConfigurableMeter filters configurable meters
func filterConfigurableMeter(ref []string) []string {
return filterConfigurableDevices(config.Meters(), ref)
}
// filterConfigurableCurtailers filters configurable curtailment devices
func filterConfigurableCurtailers(ref []string) []string {
return filterConfigurableDevices(config.Curtailers(), ref)
}
// Optimize updates the optimizer
@ -89,7 +94,7 @@ func (site *Site) SetPVMeterRefs(ref []string) {
defer site.Unlock()
site.Meters.PVMetersRef = ref
settings.SetString(keys.PvMeters, strings.Join(filterConfigurable(ref), ","))
settings.SetString(keys.PvMeters, strings.Join(filterConfigurableMeter(ref), ","))
}
// GetBatteryMeterRefs returns the BatteryMeterRef
@ -105,7 +110,7 @@ func (site *Site) SetBatteryMeterRefs(ref []string) {
defer site.Unlock()
site.Meters.BatteryMetersRef = ref
settings.SetString(keys.BatteryMeters, strings.Join(filterConfigurable(ref), ","))
settings.SetString(keys.BatteryMeters, strings.Join(filterConfigurableMeter(ref), ","))
}
// GetAuxMeterRefs returns the AuxMeterRef
@ -121,7 +126,7 @@ func (site *Site) SetAuxMeterRefs(ref []string) {
defer site.Unlock()
site.Meters.AuxMetersRef = ref
settings.SetString(keys.AuxMeters, strings.Join(filterConfigurable(ref), ","))
settings.SetString(keys.AuxMeters, strings.Join(filterConfigurableMeter(ref), ","))
}
// GetConsumerMeterRefs returns the ConsumerMeterRef
@ -137,7 +142,7 @@ func (site *Site) SetConsumerMeterRefs(ref []string) {
defer site.Unlock()
site.Meters.ConsumerMetersRef = ref
settings.SetString(keys.ConsumerMeters, strings.Join(filterConfigurable(ref), ","))
settings.SetString(keys.ConsumerMeters, strings.Join(filterConfigurableMeter(ref), ","))
}
// GetExtMeterRefs returns the ExtMeterRef
@ -153,7 +158,23 @@ func (site *Site) SetExtMeterRefs(ref []string) {
defer site.Unlock()
site.Meters.ExtMetersRef = ref
settings.SetString(keys.ExtMeters, strings.Join(filterConfigurable(ref), ","))
settings.SetString(keys.ExtMeters, strings.Join(filterConfigurableMeter(ref), ","))
}
// GetCurtailerRefs returns the curtailment device references
func (site *Site) GetCurtailerRefs() []string {
site.RLock()
defer site.RUnlock()
return site.CurtailersRef
}
// SetCurtailerRefs sets the curtailment device references
func (site *Site) SetCurtailerRefs(ref []string) {
site.Lock()
defer site.Unlock()
site.CurtailersRef = ref
settings.SetString(keys.Curtailers, strings.Join(filterConfigurableCurtailers(ref), ","))
}
// GetBatterySoc returns the current battery soc

View file

@ -139,7 +139,30 @@ func (site *Site) dimMeters(dim bool) error {
return errs
}
// curtailPV applies the HEMS curtailment percent to all curtailable pv or grid meters.
// curtailable is a named curtailment device
type curtailable struct {
name string
api.Curtailer
}
// curtailables returns the curtailment devices and the curtailable pv meters
func (site *Site) curtailables() []curtailable {
var res []curtailable
for _, dev := range site.pvMeters {
if m, ok := api.Cap[api.Curtailer](dev.Instance()); ok {
res = append(res, curtailable{deviceTitleOrName(dev), m})
}
}
for _, dev := range site.curtailers {
res = append(res, curtailable{deviceTitleOrName(dev), dev.Instance()})
}
return res
}
// curtailPV applies the HEMS curtailment percent to all curtailment devices and curtailable pv meters.
// Devices are only queried when the percent changes or after a failed attempt.
func (site *Site) curtailPV(percent *int) error {
if percent == nil || site.curtailPercent != nil && *site.curtailPercent == *percent {
@ -149,22 +172,12 @@ func (site *Site) curtailPV(percent *int) error {
// invalidate until successfully applied
site.curtailPercent = nil
meters := slices.Clone(site.pvMeters)
if site.gridMeter != nil {
meters = append(meters, site.gridMeter)
}
var errs error
for _, dev := range meters {
m, ok := api.Cap[api.Curtailer](dev.Instance())
if !ok {
continue
}
for _, m := range site.curtailables() {
// unreadable state: apply unconditionally
curtailed, err := backoff.RetryWithData(m.CurtailedPercent, modbus.Backoff())
if err != nil && !errors.Is(err, api.ErrNotAvailable) {
errs = errors.Join(errs, fmt.Errorf("%s curtailed: %w", deviceTitleOrName(dev), err))
errs = errors.Join(errs, fmt.Errorf("%s curtailed: %w", m.name, err))
continue
}
if err == nil && curtailed == *percent {
@ -172,9 +185,9 @@ func (site *Site) curtailPV(percent *int) error {
}
if err := m.SetCurtailPercent(*percent); err == nil {
site.log.DEBUG.Printf("%s curtail: %d%%", deviceTitleOrName(dev), *percent)
site.log.DEBUG.Printf("%s curtail: %d%%", m.name, *percent)
} else if !errors.Is(err, api.ErrNotAvailable) {
errs = errors.Join(errs, fmt.Errorf("%s curtail: %w", deviceTitleOrName(dev), err))
errs = errors.Join(errs, fmt.Errorf("%s curtail: %w", m.name, err))
}
}

View file

@ -107,6 +107,24 @@ func TestCurtailPVNotAvailable(t *testing.T) {
assert.Equal(t, []int{60, 30}, m.setCalls)
}
// A curtailment device is applied like a curtailable pv meter.
func TestCurtailDevice(t *testing.T) {
m := &curtailableMeter{percent: 100}
site := &Site{
log: util.NewLogger("foo"),
curtailers: []config.Device[api.Curtailer]{config.NewStaticDevice[api.Curtailer](config.Named{}, m)},
}
require.NoError(t, site.curtailPV(new(60)))
assert.Equal(t, []int{60}, m.setCalls)
assert.Equal(t, 1, m.gets)
// unchanged: device is not queried again
require.NoError(t, site.curtailPV(new(60)))
assert.Equal(t, []int{60}, m.setCalls)
assert.Equal(t, 1, m.gets)
}
// dimmableMeter counts device interactions to verify caching.
type dimmableMeter struct {
api.Meter

View file

@ -0,0 +1,25 @@
package core
import (
"testing"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util/config"
"github.com/stretchr/testify/require"
)
// TestFilterConfigurableCurtailers ensures curtailment device refs are not filtered
// against the meter registry, which would drop them from the persisted settings
func TestFilterConfigurableCurtailers(t *testing.T) {
name := config.NameForID(1)
conf := &config.Config{ID: 1}
var m api.Curtailer = &curtailableMeter{}
dev := config.NewConfigurableDevice(conf, m)
require.NoError(t, config.Curtailers().Add(dev))
t.Cleanup(func() { _ = config.Curtailers().Delete(name) })
require.Equal(t, []string{name}, filterConfigurableCurtailers([]string{name}))
require.Empty(t, filterConfigurableMeter([]string{name}), "curtailer must not resolve as meter")
}

28
curtailer/curtailer.go Normal file
View file

@ -0,0 +1,28 @@
package curtailer
import (
"context"
"fmt"
"strings"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
reg "github.com/evcc-io/evcc/util/registry"
)
var registry = reg.New[api.Curtailer]("curtailer")
// NewFromConfig creates curtailer from configuration
func NewFromConfig(ctx context.Context, typ string, other map[string]any) (api.Curtailer, error) {
factory, err := registry.Get(strings.ToLower(typ))
if err != nil {
return nil, err
}
v, err := factory(ctx, other)
if err != nil {
err = fmt.Errorf("cannot create curtailer type '%s': %w", util.TypeWithTemplateName(typ, other), err)
}
return v, err
}

45
curtailer/custom.go Normal file
View file

@ -0,0 +1,45 @@
package curtailer
import (
"context"
"errors"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/implement"
"github.com/evcc-io/evcc/meter/measurement"
"github.com/evcc-io/evcc/util"
)
func init() {
registry.AddCtx(api.Custom, NewConfigurableFromConfig)
}
// NewConfigurableFromConfig creates a curtailer from config
func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.Curtailer, error) {
cc := struct {
measurement.Curtailer `mapstructure:",squash"`
}{}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
curtailS, curtailedG, err := cc.Curtailer.Configure(ctx)
if err != nil {
return nil, err
}
if curtailS == nil {
return nil, errors.New("missing curtail")
}
return implement.Curtailer(
func() (int, error) {
percent, err := curtailedG()
return int(percent), err
},
func(percent int) error {
return curtailS(int64(percent))
},
), nil
}

21
curtailer/template.go Normal file
View file

@ -0,0 +1,21 @@
package curtailer
import (
"context"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util/templates"
)
func init() {
registry.AddCtx("template", NewFromTemplateConfig)
}
func NewFromTemplateConfig(ctx context.Context, other map[string]any) (api.Curtailer, error) {
instance, err := templates.RenderInstance(templates.Curtailer, other)
if err != nil {
return nil, err
}
return NewFromConfig(ctx, instance.Type, instance.Other)
}

View file

@ -0,0 +1,21 @@
package curtailer
import (
"testing"
"github.com/evcc-io/evcc/util/templates"
"github.com/evcc-io/evcc/util/test"
)
var acceptable = []string{}
func TestTemplates(t *testing.T) {
templates.TestClass(t, templates.Curtailer, func(t *testing.T, values map[string]any) {
t.Helper()
if _, err := NewFromConfig(t.Context(), "template", values); err != nil && !test.Acceptable(err, acceptable) {
t.Log(values)
t.Error(err)
}
})
}

View file

@ -188,6 +188,15 @@
"label": "Währung",
"title": "Währung"
},
"curtailer": {
"description": "Gerät, das die Einspeisung auf Anforderung des Netzbetreibers begrenzt (§ 9 EEG). Viele Wechselrichter unterstützen dies direkt über ihre PV-Zähler-Konfiguration. Eine separate Konfiguration ist nur bei manchen Herstellern oder Anlagen mit mehreren Wechselrichtern nötig.",
"generic": "Generische Integrationen",
"specific": "Spezifische Integrationen",
"template": "Hersteller",
"title": "Einspeisebegrenzung",
"titleAdd": "Einspeisebegrenzung hinzufügen",
"titleEdit": "Einspeisebegrenzung bearbeiten"
},
"deviceValue": {
"activeClients": "Aktive Clients",
"amount": "Anzahl",
@ -212,7 +221,7 @@
"currentRange": "Strom",
"curtailLimit": "Einspeiselimit",
"curtailable": "Abregelbar",
"curtailed": "Einspeisung begrenzt",
"curtailed": "Einspeiselimit",
"detected": "Erkannt",
"dimLimit": "Verbrauchslimit",
"dimmed": "Verbrauch begrenzt",
@ -539,6 +548,7 @@
"main": {
"addAdditional": "Zusätzlichen Zähler hinzufügen",
"addConsumer": "Verbraucher hinzufügen",
"addCurtailer": "Einspeisebegrenzung hinzufügen",
"addGrid": "Netzzähler hinzufügen",
"addLoadpoint": "Ladepunkt oder Heizung hinzufügen",
"addPvBattery": "PV oder Speicher hinzufügen",
@ -639,6 +649,7 @@
"aux": "Selbstregelnden Verbraucher hinzufügen",
"battery": "Hausbatterie hinzufügen",
"consumer": "Regulären Verbraucher hinzufügen",
"grid": "Netzzähler hinzufügen",
"pv": "PV-Anlage hinzufügen"
},
"save": "Speichern",

View file

@ -188,6 +188,15 @@
"label": "Currency",
"title": "Currency"
},
"curtailer": {
"description": "Device that limits feed-in on request of the grid operator (§ 9 EEG). Many solar inverters support this directly via their solar meter configuration. A separate configuration is only needed for some manufacturers or multi-inverter systems.",
"generic": "Generic integrations",
"specific": "Specific integrations",
"template": "Manufacturer",
"title": "Curtailment",
"titleAdd": "Add Curtailment Device",
"titleEdit": "Edit Curtailment Device"
},
"deviceValue": {
"activeClients": "Active clients",
"amount": "Amount",
@ -212,7 +221,7 @@
"currentRange": "Current",
"curtailLimit": "Feed-in limit",
"curtailable": "Curtailable",
"curtailed": "Feed-in limited",
"curtailed": "Feed-in limit",
"detected": "Detected",
"dimLimit": "Consumption limit",
"dimmed": "Consumption limited",
@ -539,6 +548,7 @@
"main": {
"addAdditional": "Add additional meter",
"addConsumer": "Add consumer",
"addCurtailer": "Add curtailment device",
"addGrid": "Add grid meter",
"addLoadpoint": "Add charging point or heater",
"addPvBattery": "Add solar or battery",
@ -639,6 +649,7 @@
"aux": "Add self-regulating consumer",
"battery": "Add battery meter",
"consumer": "Add regular consumer",
"grid": "Add grid meter",
"pv": "Add solar meter"
},
"save": "Save",

View file

@ -18,6 +18,7 @@ import (
"github.com/evcc-io/evcc/core/circuit"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/curtailer"
"github.com/evcc-io/evcc/hems"
hemsapi "github.com/evcc-io/evcc/hems/hems"
"github.com/evcc-io/evcc/messenger"
@ -81,6 +82,9 @@ func devicesConfigHandler(w http.ResponseWriter, r *http.Request) {
case templates.Circuit:
res, err = devicesConfig(class, config.Circuits(), hidePrivate)
case templates.Curtailer:
res, err = devicesConfig(class, config.Curtailers(), hidePrivate)
case templates.Hems:
res, err = devicesConfig(class, config.Hems(), hidePrivate)
@ -210,6 +214,9 @@ func deviceConfigHandler(w http.ResponseWriter, r *http.Request) {
case templates.Circuit:
res, err = deviceConfig(class, id, config.Circuits(), hidePrivate)
case templates.Curtailer:
res, err = deviceConfig(class, id, config.Curtailers(), hidePrivate)
case templates.Hems:
res, err = deviceConfig(class, id, config.Hems(), hidePrivate)
@ -275,6 +282,9 @@ func deviceStatusHandler(w http.ResponseWriter, r *http.Request) {
case templates.Circuit:
instance, err = deviceStatus(name, config.Circuits())
case templates.Curtailer:
instance, err = deviceStatus(name, config.Curtailers())
case templates.Hems:
err = api.ErrNotAvailable
@ -375,6 +385,9 @@ func newDeviceHandler(site site.API, authObject auth.Auth) http.HandlerFunc {
case templates.Circuit:
conf, err = newDevice(ctx, class, req, circuit.NewFromConfig, config.Circuits(), force)
case templates.Curtailer:
conf, err = newDevice(ctx, class, req, curtailer.NewFromConfig, config.Curtailers(), force)
case templates.Hems:
if existing, _ := config.ConfigurationByClass(templates.Hems); existing != nil {
err = errors.New("hems already configured")
@ -477,6 +490,9 @@ func updateDeviceHandler(site site.API, authObject auth.Auth) http.HandlerFunc {
case templates.Circuit:
err = updateDevice(ctx, id, class, req, circuit.NewFromConfig, config.Circuits(), force)
case templates.Curtailer:
err = updateDevice(ctx, id, class, req, curtailer.NewFromConfig, config.Curtailers(), force)
case templates.Hems:
err = updateDevice(ctx, id, class, req, newHemsFactory(site), config.Hems(), force)
@ -655,6 +671,12 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
}
}
case templates.Curtailer:
err = deleteDevice(id, config.Curtailers())
// cleanup references
cleanupSiteMeterRef(config.NameForID(id), site.GetCurtailerRefs, site.SetCurtailerRefs)
case templates.Hems:
err = deleteDevice(id, config.Hems())
@ -759,6 +781,9 @@ func testConfigHandler(site site.API, authObject auth.Auth) http.HandlerFunc {
case templates.Circuit:
instance, err = testConfig(ctx, id, class, req, circuit.NewFromConfig, config.Circuits())
case templates.Curtailer:
instance, err = testConfig(ctx, id, class, req, curtailer.NewFromConfig, config.Curtailers())
case templates.Hems:
instance, err = testConfig(ctx, id, class, req, newHemsFactory(site), config.Hems())

View file

@ -446,11 +446,17 @@ func testInstance(ctx context.Context, instance any) map[string]testResult {
wg.Go(func() {
if dev, ok := api.Cap[api.Curtailer](instance); ok {
if _, isMeter := instance.(api.Meter); isMeter {
// curtailment as meter capability, limit only reported while actually curtailing
makeResult("curtailable", true, nil)
// only reported while actually curtailing
if val, err := dev.CurtailedPercent(); err != nil || val < 100 {
makeResult("curtailed", val, err)
}
} else {
// dedicated curtailment device, always report the limit
val, err := dev.CurtailedPercent()
makeResult("curtailed", val, err)
}
}
})

View file

@ -19,9 +19,11 @@ func siteHandler(site site.API) http.HandlerFunc {
Aux []string `json:"aux"`
Ext []string `json:"ext"`
Consumer []string `json:"consumer"`
Curtail []string `json:"curtail"`
}{
Title: site.GetTitle(),
Grid: site.GetGridMeterRef(),
Curtail: site.GetCurtailerRefs(),
PV: site.GetPVMeterRefs(),
Battery: site.GetBatteryMeterRefs(),
Aux: site.GetAuxMeterRefs(),
@ -54,6 +56,7 @@ func updateSiteHandler(site site.API) http.HandlerFunc {
Aux *[]string
Ext *[]string
Consumer *[]string
Curtail *[]string
}
if err := jsonDecoder(r.Body).Decode(&payload); err != nil {
@ -119,6 +122,18 @@ func updateSiteHandler(site site.API) http.HandlerFunc {
setConfigDirty()
}
if payload.Curtail != nil {
for _, m := range *payload.Curtail {
if _, err := config.Curtailers().ByName(m); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
}
site.SetCurtailerRefs(*payload.Curtail)
setConfigDirty()
}
// persist immediately to keep meter refs consistent with device config on unclean shutdown
if err := settings.Persist(); err != nil {
jsonError(w, http.StatusInternalServerError, err)

View file

@ -0,0 +1,25 @@
template: sma-homemanager
products:
- brand: SMA
description:
generic: Sunny Home Manager 2.0
params:
- name: modbus
choice: ["tcpip"]
port: 502
id: 2
help:
en: ModbusTCP server needs to be enabled.
de: Der ModbusTCP Server muss aktiviert sein.
render: |
type: custom
curtail:
source: modbus
{{- include "modbus" . | indent 2 }}
register:
address: 40016 # Wirkleistungssollwert P, in % der maximalen Wirkleistung (PMAX) der PV-Anlage
type: writesingle
encoding: uint16
curtailed:
source: error
error: ErrNotAvailable

View file

@ -0,0 +1,135 @@
template: solaredge
products:
- brand: SolarEdge
description:
de: Wechselrichter (Einspeisebegrenzung)
en: Inverter (feed-in limitation)
requirements:
description:
de: |
Nur ein System kann und darf zeitgleich eine Modbus TCP-Verbindung zum Wechselrichter haben!
Die Einspeisebegrenzung begrenzt die Netzeinspeisung am Netzanschlusspunkt auf den geforderten Prozentsatz der installierten Generatorleistung.
en: |
Only one system can and may have a Modbus TCP connection to the inverter at the same time!
Feed-in limitation limits the export power at the grid connection point to the requested percentage of the installed generator power.
params:
- name: modbus
choice: ["tcpip", "rs485"]
id: 1
port: 1502
- name: productionnominalmax
type: float
unit: Wp
required: true
description:
en: Installed generator power
de: Installierte Generatorleistung
help:
en: Rated generator/module power of the installation (reference value according to § 9 EEG).
de: Gesamtnennleistung bzw. Modul- oder Generatorleistung der Anlage (Bezugsgröße nach § 9 EEG).
render: |
type: custom
curtail:
source: sequence
set:
- source: switch
switch:
- case: 100
set:
source: go
in:
- name: mode
type: int
config:
source: modbus
{{- include "modbus" . | indent 18 }}
register:
address: 57344 # Export Control Mode
type: holding
decode: uint16
out:
- name: out
type: int
config:
source: modbus
{{- include "modbus" . | indent 18 }}
register:
address: 57344 # Export Control Mode
type: writesingle
encoding: uint16
script: |
mode &^ 1 // clear bit 0 (export limitation), preserve other bits (e.g. indirect production limitation)
default:
source: go
in:
- name: mode
type: int
config:
source: modbus
{{- include "modbus" . | indent 14 }}
register:
address: 57344 # Export Control Mode
type: holding
decode: uint16
out:
- name: out
type: int
config:
source: modbus
{{- include "modbus" . | indent 14 }}
register:
address: 57344 # Export Control Mode
type: writesingle
encoding: uint16
script: |
mode | 1 // set bit 0 (export limitation via meter), preserve other bits
- source: const
value: 0 # Total
set:
source: modbus
{{- include "modbus" . | indent 8 }}
register:
address: 57345 # Export Control Limit Mode
type: writesingle
encoding: uint16
- source: modbus
{{- include "modbus" . | indent 6 }}
register:
address: 57346 # Export Control Site Limit
type: writemultiple
encoding: float32s
scale: {{ divf (maxf .productionnominalmax 1) 100 }} # % to W
curtailed:
source: go
in:
- name: mode
type: int
config:
source: modbus
{{- include "modbus" . | indent 8 }}
register:
address: 57344 # Export Control Mode
type: holding
decode: uint16
- name: limit
type: float
config:
source: modbus
{{- include "modbus" . | indent 8 }}
register:
address: 57346 # Export Control Site Limit
type: holding
decode: float32s
script: |
percent := 100.0
if mode&1 != 0 {
percent = limit * 100 / {{ maxf .productionnominalmax 1 }}
}
p := int(percent + 0.5)
if p < 0 {
p = 0
}
if p > 100 {
p = 100
}
p

View file

@ -2,7 +2,7 @@ package definition
import "embed"
//go:embed charger/*.yaml meter/*.yaml vehicle/*.yaml tariff/*.yaml messenger/*.yaml circuit/*.yaml hems/*.yaml
//go:embed charger/*.yaml meter/*.yaml vehicle/*.yaml tariff/*.yaml messenger/*.yaml circuit/*.yaml hems/*.yaml curtailer/*.yaml
var YamlTemplates embed.FS
//go:embed */*.tpl

View file

@ -60,7 +60,7 @@ render: |
power:
source: const
value: {{ .power }}
{{- if .curtailable }}
{{- if eq .curtailable "true" }}
curtail:
source: js
vm: shared

View file

@ -1,60 +0,0 @@
template: sma-homemanager-modbus
products:
- brand: SMA
description:
generic: Sunny Home Manager 2.0 Modbus
capabilities: ["curtail"]
params:
- name: usage
choice: ["grid"]
- name: modbus
choice: ["tcpip"]
port: 502
id: 2
help:
en: ModbusTCP server needs to be enabled.
de: Der ModbusTCP Server muss aktiviert sein.
render: |
type: custom
power:
source: calc
add:
- source: modbus
{{- include "modbus" . | indent 6 }}
register:
address: 30865 # SMA Modbus Profile: Metering.GridMs.TotWIn
type: input
decode: int32nan
- source: modbus
{{- include "modbus" . | indent 6 }}
register:
address: 30867 # SMA Modbus Profile: Metering.GridMs.TotWOut
type: input
decode: int32nan
scale: -1
energy:
source: modbus
{{- include "modbus" . | indent 2 }}
register:
address: 30581 # SMA Modbus Profile: Metering.GridMs.TotWhIn
type: holding
decode: int32nan
scale: 0.001
returnenergy:
source: modbus
{{- include "modbus" . | indent 2 }}
register:
address: 30583 # SMA Modbus Profile: Metering.GridMs.TotWhOut
type: holding
decode: int32nan
scale: 0.001
curtail:
source: modbus
{{- include "modbus" . | indent 2 }}
register:
address: 40016 # Wirkleistungssollwert P, in % der maximalen Wirkleistung (PMAX) der PV-Anlage.
type: writesingle
decode: uint16
curtailed:
source: error
error: ErrNotAvailable

View file

@ -345,9 +345,7 @@ w4:${signalSource("w4")}`
// no signals: no limits
await page.goto("/#/config");
await expect(hems).toContainText(
["Consumption limited", "no", "Feed-in limited", "no"].join("")
);
await expect(hems).toContainText(["Consumption limited", "no", "Feed-in limit", "no"].join(""));
await expect(hint).not.toBeVisible();
// dim (W4): consumption limited
@ -391,9 +389,7 @@ w4:${signalSource("w4")}`
await expect(hint).not.toBeVisible();
await page.reload();
await expect(pvBanner).not.toBeVisible();
await expect(hems).toContainText(
["Consumption limited", "no", "Feed-in limited", "no"].join("")
);
await expect(hems).toContainText(["Consumption limited", "no", "Feed-in limit", "no"].join(""));
await stopSimulator();
});

View file

@ -14,6 +14,7 @@ var instance struct {
chargers *handler[api.Charger]
vehicles *handler[api.Vehicle]
circuits *handler[api.Circuit]
curtailers *handler[api.Curtailer]
hems *handler[hems.API]
messengers *handler[api.Messenger]
loadpoints *handler[loadpoint.API]
@ -29,6 +30,7 @@ func Reset() {
instance.chargers = &handler[api.Charger]{topic: "charger"}
instance.vehicles = &handler[api.Vehicle]{topic: "vehicle"}
instance.circuits = &handler[api.Circuit]{topic: "circuit"}
instance.curtailers = &handler[api.Curtailer]{topic: "curtailer"}
instance.hems = &handler[hems.API]{topic: "hems"}
instance.messengers = &handler[api.Messenger]{topic: "messenger"}
instance.loadpoints = &handler[loadpoint.API]{topic: "loadpoint"}
@ -63,6 +65,10 @@ func Circuits() Handler[api.Circuit] {
return instance.circuits
}
func Curtailers() Handler[api.Curtailer] {
return instance.curtailers
}
func Hems() Handler[hems.API] {
return instance.hems
}

View file

@ -13,4 +13,5 @@ const (
Circuit
Messenger
Hems
Curtailer
)

View file

@ -7,11 +7,11 @@ import (
"strings"
)
const _ClassName = "chargermetervehicletariffloadpointcircuitmessengerhems"
const _ClassName = "chargermetervehicletariffloadpointcircuitmessengerhemscurtailer"
var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 34, 41, 50, 54}
var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 34, 41, 50, 54, 63}
const _ClassLowerName = "chargermetervehicletariffloadpointcircuitmessengerhems"
const _ClassLowerName = "chargermetervehicletariffloadpointcircuitmessengerhemscurtailer"
func (i Class) String() string {
i -= 1
@ -33,9 +33,10 @@ func _ClassNoOp() {
_ = x[Circuit-(6)]
_ = x[Messenger-(7)]
_ = x[Hems-(8)]
_ = x[Curtailer-(9)]
}
var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Loadpoint, Circuit, Messenger, Hems}
var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Loadpoint, Circuit, Messenger, Hems, Curtailer}
var _ClassNameToValueMap = map[string]Class{
_ClassName[0:7]: Charger,
@ -54,6 +55,8 @@ var _ClassNameToValueMap = map[string]Class{
_ClassLowerName[41:50]: Messenger,
_ClassName[50:54]: Hems,
_ClassLowerName[50:54]: Hems,
_ClassName[54:63]: Curtailer,
_ClassLowerName[54:63]: Curtailer,
}
var _ClassNames = []string{
@ -65,6 +68,7 @@ var _ClassNames = []string{
_ClassName[34:41],
_ClassName[41:50],
_ClassName[50:54],
_ClassName[54:63],
}
// ClassString retrieves an enum value from the enum constants string name.

View file

@ -41,7 +41,7 @@ func init() {
// must not collide with an include name (`$` cannot appear in a file name)
baseTmpl = template.Must(FuncMap(template.New("$root")).ParseFS(includeFS, "includes/*.tpl"))
for _, class := range []Class{Charger, Meter, Vehicle, Tariff, Messenger, Circuit, Hems} {
for _, class := range []Class{Charger, Meter, Vehicle, Tariff, Messenger, Circuit, Hems, Curtailer} {
if err := loadIncludes(definition.TemplateIncludes, class); err != nil {
panic(err)
}

View file

@ -5,6 +5,7 @@ type Usage int
//go:generate go tool enumer -type Usage -trimprefix Usage -transform=lower -text
const (
UsageGrid Usage = iota
UsageCurtail
UsagePV
UsageBattery
UsageCharge

View file

@ -7,11 +7,11 @@ import (
"strings"
)
const _UsageName = "gridpvbatterychargeaux"
const _UsageName = "gridcurtailpvbatterychargeaux"
var _UsageIndex = [...]uint8{0, 4, 6, 13, 19, 22}
var _UsageIndex = [...]uint8{0, 4, 11, 13, 20, 26, 29}
const _UsageLowerName = "gridpvbatterychargeaux"
const _UsageLowerName = "gridcurtailpvbatterychargeaux"
func (i Usage) String() string {
if i < 0 || i >= Usage(len(_UsageIndex)-1) {
@ -25,33 +25,37 @@ func (i Usage) String() string {
func _UsageNoOp() {
var x [1]struct{}
_ = x[UsageGrid-(0)]
_ = x[UsagePV-(1)]
_ = x[UsageBattery-(2)]
_ = x[UsageCharge-(3)]
_ = x[UsageAux-(4)]
_ = x[UsageCurtail-(1)]
_ = x[UsagePV-(2)]
_ = x[UsageBattery-(3)]
_ = x[UsageCharge-(4)]
_ = x[UsageAux-(5)]
}
var _UsageValues = []Usage{UsageGrid, UsagePV, UsageBattery, UsageCharge, UsageAux}
var _UsageValues = []Usage{UsageGrid, UsageCurtail, UsagePV, UsageBattery, UsageCharge, UsageAux}
var _UsageNameToValueMap = map[string]Usage{
_UsageName[0:4]: UsageGrid,
_UsageLowerName[0:4]: UsageGrid,
_UsageName[4:6]: UsagePV,
_UsageLowerName[4:6]: UsagePV,
_UsageName[6:13]: UsageBattery,
_UsageLowerName[6:13]: UsageBattery,
_UsageName[13:19]: UsageCharge,
_UsageLowerName[13:19]: UsageCharge,
_UsageName[19:22]: UsageAux,
_UsageLowerName[19:22]: UsageAux,
_UsageName[4:11]: UsageCurtail,
_UsageLowerName[4:11]: UsageCurtail,
_UsageName[11:13]: UsagePV,
_UsageLowerName[11:13]: UsagePV,
_UsageName[13:20]: UsageBattery,
_UsageLowerName[13:20]: UsageBattery,
_UsageName[20:26]: UsageCharge,
_UsageLowerName[20:26]: UsageCharge,
_UsageName[26:29]: UsageAux,
_UsageLowerName[26:29]: UsageAux,
}
var _UsageNames = []string{
_UsageName[0:4],
_UsageName[4:6],
_UsageName[6:13],
_UsageName[13:19],
_UsageName[19:22],
_UsageName[4:11],
_UsageName[11:13],
_UsageName[13:20],
_UsageName[20:26],
_UsageName[26:29],
}
// UsageString retrieves an enum value from the enum constants string name.