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

@ -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,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)
}
}

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)