diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go index eace135f8..40e565037 100644 --- a/api/globalconfig/types.go +++ b/api/globalconfig/types.go @@ -64,6 +64,7 @@ type All struct { Site map[string]any Loadpoints []config.Named Circuits []config.Named + Curtailers []config.Named } type Javascript struct { diff --git a/assets/js/components/Config/CurtailerModal.vue b/assets/js/components/Config/CurtailerModal.vue new file mode 100644 index 000000000..75a11846f --- /dev/null +++ b/assets/js/components/Config/CurtailerModal.vue @@ -0,0 +1,97 @@ + + $emit('changed', { action: 'added', name })" + @updated="$emit('changed', { action: 'updated' })" + @removed="$emit('changed', { action: 'removed' })" + > + + {{ $t("config.curtailer.description") }} + + + + + + + + + + + diff --git a/assets/js/components/Config/NewDeviceButton.vue b/assets/js/components/Config/NewDeviceButton.vue index b7ec08b32..637d29acd 100644 --- a/assets/js/components/Config/NewDeviceButton.vue +++ b/assets/js/components/Config/NewDeviceButton.vue @@ -4,7 +4,7 @@ tabindex="0" @click="$emit('click')" > - + {{ title }} diff --git a/assets/js/components/Config/defaultYaml/curtailer.yaml b/assets/js/components/Config/defaultYaml/curtailer.yaml new file mode 100644 index 000000000..73324009c --- /dev/null +++ b/assets/js/components/Config/defaultYaml/curtailer.yaml @@ -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 diff --git a/assets/js/components/MaterialIcon/Curtail.vue b/assets/js/components/MaterialIcon/Curtail.vue new file mode 100644 index 000000000..d72ef9fc1 --- /dev/null +++ b/assets/js/components/MaterialIcon/Curtail.vue @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 41c6704ad..470188ed3 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -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[keyof T]; diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index bc63fa0ea..3bec578e7 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -150,6 +150,31 @@ data-testid="add-grid" @click="openModal('meter', { type: 'grid' })" /> + + + + + + + + + @@ -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 @@ + 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; 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; }, diff --git a/cmd/class_enumer.go b/cmd/class_enumer.go index a3b8fecb2..b72716db3 100644 --- a/cmd/class_enumer.go +++ b/cmd/class_enumer.go @@ -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. diff --git a/cmd/config_delete.go b/cmd/config_delete.go index 6d5db61d2..417d2dd8b 100644 --- a/cmd/config_delete.go +++ b/cmd/config_delete.go @@ -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: diff --git a/cmd/error.go b/cmd/error.go index 63fdeb6b2..1348a5b46 100644 --- a/cmd/error.go +++ b/cmd/error.go @@ -29,6 +29,7 @@ const ( ClassMessenger ClassSponsorship ClassLoadpoint + ClassCurtailer ) // FatalError is an error that can be marshaled diff --git a/cmd/refs.go b/cmd/refs.go index cdaf0a39b..6f9748e42 100644 --- a/cmd/refs.go +++ b/cmd/refs.go @@ -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 { @@ -56,8 +56,9 @@ func collectRefs(conf globalconfig.All) error { func collectSiteRefs(conf globalconfig.All) error { var refs struct { - Meters core.MetersConfig `mapstructure:"meters"` // Meter references - Other map[string]any `mapstructure:",remain"` + Meters core.MetersConfig `mapstructure:"meters"` // Meter references + Curtailers []string `mapstructure:"curtailers"` // Curtailment device references + Other map[string]any `mapstructure:",remain"` } if err := util.DecodeOther(conf.Site, &refs); err != nil { @@ -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 } diff --git a/cmd/setup.go b/cmd/setup.go index 4cc932170..74cb577e1 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -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...) } diff --git a/core/keys/site.go b/core/keys/site.go index 18ad2add4..dddd4dd5b 100644 --- a/core/keys/site.go +++ b/core/keys/site.go @@ -41,6 +41,9 @@ const ( AuxMeters = "auxMeters" ConsumerMeters = "consumerMeters" + // curtailment devices + Curtailers = "curtailers" + // battery settings BatteryDischargeControl = "batteryDischargeControl" BatteryGridChargeLimit = "batteryGridChargeLimit" diff --git a/core/site.go b/core/site.go index 5e13fbc2e..c89b6d108 100644 --- a/core/site.go +++ b/core/site.go @@ -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, ","))...) } } diff --git a/core/site/api.go b/core/site/api.go index f18e5c45b..0560b8bdc 100644 --- a/core/site/api.go +++ b/core/site/api.go @@ -28,6 +28,8 @@ type API interface { // Config GetGridMeterRef() string SetGridMeterRef(string) + GetCurtailerRefs() []string + SetCurtailerRefs([]string) GetPVMeterRefs() []string SetPVMeterRefs([]string) GetBatteryMeterRefs() []string diff --git a/core/site_api.go b/core/site_api.go index 083e4c8f3..e6e904933 100644 --- a/core/site_api.go +++ b/core/site_api.go @@ -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]) - return ok +// 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 diff --git a/core/site_circuits.go b/core/site_circuits.go index 8dd1cad07..c4d299b3a 100644 --- a/core/site_circuits.go +++ b/core/site_circuits.go @@ -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)) } } diff --git a/core/site_circuits_test.go b/core/site_circuits_test.go index e18aa53c9..78387c698 100644 --- a/core/site_circuits_test.go +++ b/core/site_circuits_test.go @@ -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 diff --git a/core/site_curtailer_refs_test.go b/core/site_curtailer_refs_test.go new file mode 100644 index 000000000..1692e972d --- /dev/null +++ b/core/site_curtailer_refs_test.go @@ -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") +} diff --git a/curtailer/curtailer.go b/curtailer/curtailer.go new file mode 100644 index 000000000..aa0c33c44 --- /dev/null +++ b/curtailer/curtailer.go @@ -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 +} diff --git a/curtailer/custom.go b/curtailer/custom.go new file mode 100644 index 000000000..2d908d3c6 --- /dev/null +++ b/curtailer/custom.go @@ -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 +} diff --git a/curtailer/template.go b/curtailer/template.go new file mode 100644 index 000000000..e6afb8294 --- /dev/null +++ b/curtailer/template.go @@ -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) +} diff --git a/curtailer/template_test.go b/curtailer/template_test.go new file mode 100644 index 000000000..18b6892ac --- /dev/null +++ b/curtailer/template_test.go @@ -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) + } + }) +} diff --git a/i18n/de.json b/i18n/de.json index 4ef54b677..387106d9e 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -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", diff --git a/i18n/en.json b/i18n/en.json index 4456e5c95..de2e333df 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go index d69ff7217..2d1fea9b2 100644 --- a/server/http_config_device_handler.go +++ b/server/http_config_device_handler.go @@ -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()) diff --git a/server/http_config_helper.go b/server/http_config_helper.go index 90c40cd6e..4aaf7620c 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -446,9 +446,15 @@ func testInstance(ctx context.Context, instance any) map[string]testResult { wg.Go(func() { if dev, ok := api.Cap[api.Curtailer](instance); ok { - makeResult("curtailable", true, nil) - // only reported while actually curtailing - if val, err := dev.CurtailedPercent(); err != nil || val < 100 { + if _, isMeter := instance.(api.Meter); isMeter { + // curtailment as meter capability, limit only reported while actually curtailing + makeResult("curtailable", true, nil) + 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) } } diff --git a/server/http_config_site_handler.go b/server/http_config_site_handler.go index fb3f0d5ad..829d5e348 100644 --- a/server/http_config_site_handler.go +++ b/server/http_config_site_handler.go @@ -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) diff --git a/templates/definition/curtailer/sma-homemanager.yaml b/templates/definition/curtailer/sma-homemanager.yaml new file mode 100644 index 000000000..2eb58915f --- /dev/null +++ b/templates/definition/curtailer/sma-homemanager.yaml @@ -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 diff --git a/templates/definition/curtailer/solaredge.yaml b/templates/definition/curtailer/solaredge.yaml new file mode 100644 index 000000000..a2c3b3ccb --- /dev/null +++ b/templates/definition/curtailer/solaredge.yaml @@ -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 diff --git a/templates/definition/embed.go b/templates/definition/embed.go index 1f60bfa3b..2f923d6b4 100644 --- a/templates/definition/embed.go +++ b/templates/definition/embed.go @@ -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 diff --git a/templates/definition/meter/demo-meter.yaml b/templates/definition/meter/demo-meter.yaml index 64d89227e..7f6557788 100644 --- a/templates/definition/meter/demo-meter.yaml +++ b/templates/definition/meter/demo-meter.yaml @@ -60,7 +60,7 @@ render: | power: source: const value: {{ .power }} - {{- if .curtailable }} + {{- if eq .curtailable "true" }} curtail: source: js vm: shared diff --git a/templates/definition/meter/sma-homemanager-modbus.yaml b/templates/definition/meter/sma-homemanager-modbus.yaml deleted file mode 100644 index ac4ce06e3..000000000 --- a/templates/definition/meter/sma-homemanager-modbus.yaml +++ /dev/null @@ -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 diff --git a/tests/hems.spec.ts b/tests/hems.spec.ts index e6e923b39..56c9e082c 100644 --- a/tests/hems.spec.ts +++ b/tests/hems.spec.ts @@ -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(); }); diff --git a/util/config/instance.go b/util/config/instance.go index f26e7bfa8..6a995e553 100644 --- a/util/config/instance.go +++ b/util/config/instance.go @@ -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 } diff --git a/util/templates/class.go b/util/templates/class.go index 11dcfee9c..5b948deed 100644 --- a/util/templates/class.go +++ b/util/templates/class.go @@ -13,4 +13,5 @@ const ( Circuit Messenger Hems + Curtailer ) diff --git a/util/templates/class_enumer.go b/util/templates/class_enumer.go index c957a61f8..3ce42e19e 100644 --- a/util/templates/class_enumer.go +++ b/util/templates/class_enumer.go @@ -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. diff --git a/util/templates/init.go b/util/templates/init.go index 132f7847f..712e0d631 100644 --- a/util/templates/init.go +++ b/util/templates/init.go @@ -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) } diff --git a/util/templates/usage.go b/util/templates/usage.go index e393c7baf..7c2ee8f20 100644 --- a/util/templates/usage.go +++ b/util/templates/usage.go @@ -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 diff --git a/util/templates/usage_enumer.go b/util/templates/usage_enumer.go index c6c4b605b..9235f121f 100644 --- a/util/templates/usage_enumer.go +++ b/util/templates/usage_enumer.go @@ -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.
{{ $t("config.curtailer.description") }}