From 5d6264829bac49afb3fafbcec4e87d5b76b4652b Mon Sep 17 00:00:00 2001 From: Frank Behrens Date: Mon, 20 Jul 2026 18:04:09 +0200 Subject: [PATCH 01/12] SMA SHM: new template with modbus transport and curtail Sunny Home Manager 2.0 with modbus transport and curtail. At this time there is no way to detect the effective curtail state/percent, return -1 instead. --- .../meter/sma-homemanager-modbus.yaml | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 templates/definition/meter/sma-homemanager-modbus.yaml diff --git a/templates/definition/meter/sma-homemanager-modbus.yaml b/templates/definition/meter/sma-homemanager-modbus.yaml new file mode 100644 index 0000000000..7ae812033c --- /dev/null +++ b/templates/definition/meter/sma-homemanager-modbus.yaml @@ -0,0 +1,60 @@ +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: const + value: -1 # not known From 5ba5eb0dabfb2566dda0c99b331e2a2eacc873fc Mon Sep 17 00:00:00 2001 From: Frank Behrens Date: Wed, 22 Jul 2026 15:02:38 +0200 Subject: [PATCH 02/12] core: a grid meter may curtail the site production when it implements api.Curtailer --- core/site_circuits.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/core/site_circuits.go b/core/site_circuits.go index 6aceb79fe0..f077f7baac 100644 --- a/core/site_circuits.go +++ b/core/site_circuits.go @@ -133,9 +133,42 @@ func (site *Site) curtailPV(percent *int) error { } } + errGrid := curtailGrid(site, percent) + errs = errors.Join(errs, errGrid) + if errs == nil { site.curtailPercent = new(*percent) } return errs } + +func curtailGrid(site *Site, percent *int) error { + + var errs error + + if site.gridMeter != nil { + m, ok := api.Cap[api.Curtailer](site.gridMeter) + if !ok { + return errs + } + + if curtailed, err := backoff.RetryWithData(m.CurtailedPercent, modbus.Backoff()); err == nil { + if curtailed == *percent { + return errs + } + } else { + if !errors.Is(err, api.ErrNotAvailable) { + errs = errors.Join(errs, fmt.Errorf("%s curtailed: %w", "grid", err)) + } + return errs + } + + if err := m.SetCurtailPercent(*percent); err == nil { + site.log.DEBUG.Printf("%s curtail: %d%%", "grid", *percent) + } else if !errors.Is(err, api.ErrNotAvailable) { + errs = errors.Join(errs, fmt.Errorf("%s curtail: %w", "grid", err)) + } + } + return errs +} From dcb59ef3bb837a737f04ac6d09389c3f7c2f2249 Mon Sep 17 00:00:00 2001 From: Frank Behrens Date: Wed, 22 Jul 2026 17:35:58 +0200 Subject: [PATCH 03/12] core: don't display curtailed percent value "-1", this means "unknown" --- assets/js/views/Config.vue | 6 +++--- server/http_config_helper.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index 8fd3efb35d..f2f3b9062f 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -865,13 +865,13 @@ export default defineComponent({ } else if (status.dimmed !== undefined) { result["dimmed"] = { value: status.dimmed }; } - if ((status.curtailed ?? 100) < 100 && status.maxProductionPower !== undefined) { + if ((status.curtailed ?? 100) < 100 && (status.curtailed ?? 100) >= 0 && status.maxProductionPower !== undefined) { result["curtailLimit"] = { value: status.maxProductionPower, warning: true, }; } else if (status.curtailed !== undefined) { - result["curtailed"] = { value: status.curtailed < 100 }; + result["curtailed"] = { value: status.curtailed < 100 && status.curtailed >= 0}; } return result; @@ -1278,7 +1278,7 @@ export default defineComponent({ }, 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 + return this.deviceTags("meter", name)["curtailed"]?.value !== undefined && ["curtailed"]?.value >= 0 ? this.$t("config.deviceValue.productionLimited") : undefined; }, diff --git a/server/http_config_helper.go b/server/http_config_helper.go index 0da57c72ae..3013ea53f9 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -442,7 +442,7 @@ func testInstance(ctx context.Context, instance any) map[string]testResult { 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 val, err := dev.CurtailedPercent(); err != nil || (val < 100 && val >= 0) { makeResult("curtailed", val, err) } } From 7f967a9b0a43978f953314cb246f7b3b22c4e854 Mon Sep 17 00:00:00 2001 From: Frank Behrens Date: Fri, 24 Jul 2026 11:03:07 +0200 Subject: [PATCH 04/12] core: fix newline style (after lint check) --- core/site_circuits.go | 1 - 1 file changed, 1 deletion(-) diff --git a/core/site_circuits.go b/core/site_circuits.go index f077f7baac..113e62d8ee 100644 --- a/core/site_circuits.go +++ b/core/site_circuits.go @@ -144,7 +144,6 @@ func (site *Site) curtailPV(percent *int) error { } func curtailGrid(site *Site, percent *int) error { - var errs error if site.gridMeter != nil { From e7c4d037885cb09dc3f9e73ce7c64ad033ce5574 Mon Sep 17 00:00:00 2001 From: Frank Behrens Date: Fri, 24 Jul 2026 11:04:33 +0200 Subject: [PATCH 05/12] core: fix variable fixes dcb59ef3bb837a737f04ac6d09389c3f7c2f2249 * core: don't display curtailed percent value "-1", this means "unknown" --- assets/js/views/Config.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index f2f3b9062f..ddbff60dcf 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -1278,7 +1278,7 @@ export default defineComponent({ }, 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 && ["curtailed"]?.value >= 0 + return this.deviceTags("meter", name)["curtailed"]?.value !== undefined && this.deviceTags("meter", name)["curtailed"]?.value >= 0 ? this.$t("config.deviceValue.productionLimited") : undefined; }, From 8ed31dab073de656f33467a72bff495fdc14327c Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 27 Jul 2026 16:41:45 +0200 Subject: [PATCH 06/12] Include grid meter in curtailments --- core/site.go | 19 +++++++++---------- core/site_circuits.go | 12 +++++++----- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/core/site.go b/core/site.go index aa10371e33..32b152f811 100644 --- a/core/site.go +++ b/core/site.go @@ -69,7 +69,7 @@ type Site struct { // meters circuit api.Circuit // Circuit hems api.HEMS // HEMS (set by configureHEMS at boot) - gridMeter api.Meter // Grid usage meter + gridMeter config.Device[api.Meter] // Grid usage meter pvMeters []config.Device[api.Meter] // PV generation meters batteryMeters []config.Device[api.Meter] // Battery charging meters extMeters []config.Device[api.Meter] // External meters - for monitoring only @@ -206,10 +206,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif return err } - site.gridMeter = dev.Instance() - if site.gridMeter == nil { - return errors.New("missing grid meter instance") - } + site.gridMeter = dev me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef, metrics.Grid) if err != nil { @@ -864,7 +861,9 @@ func (site *Site) updateGridMeter() error { mm := types.Measurement{Name: site.Meters.GridMeterRef} - if res, err := backoff.RetryWithData(site.gridMeter.CurrentPower, modbus.Backoff()); err == nil { + meter := site.gridMeter.Instance() + + if res, err := backoff.RetryWithData(meter.CurrentPower, modbus.Backoff()); err == nil { mm.Power = res site.gridPower = res site.log.DEBUG.Printf("grid power: %.0fW", res) @@ -873,10 +872,10 @@ func (site *Site) updateGridMeter() error { } // grid phase currents (signed) - if phaseMeter, ok := api.Cap[api.PhaseCurrents](site.gridMeter); ok { + if phaseMeter, ok := api.Cap[api.PhaseCurrents](meter); ok { // grid phase powers var p1, p2, p3 float64 - if phaseMeter, ok := api.Cap[api.PhasePowers](site.gridMeter); ok { + if phaseMeter, ok := api.Cap[api.PhasePowers](meter); ok { var err error // phases needed for signed currents if p1, p2, p3, err = phaseMeter.Powers(); err == nil { mm.Powers = []float64{p1, p2, p3} @@ -896,7 +895,7 @@ func (site *Site) updateGridMeter() error { // grid energy (import); nil when the device has no MeterEnergy capability or the read fails // ignore spurious zero readings (NaN-derived or nightly reset, #30950) - if energyMeter, ok := api.Cap[api.MeterEnergy](site.gridMeter); ok { + if energyMeter, ok := api.Cap[api.MeterEnergy](meter); ok { if f, err := nonZeroEnergy(energyMeter.TotalEnergy()); err == nil { mm.Energy = &f } else if !errors.Is(err, api.ErrNotAvailable) { @@ -906,7 +905,7 @@ func (site *Site) updateGridMeter() error { // grid return energy (export); nil when the device has no MeterReturnEnergy capability or the read fails // ignore spurious zero readings as above - if returnEnergyMeter, ok := api.Cap[api.MeterReturnEnergy](site.gridMeter); ok { + if returnEnergyMeter, ok := api.Cap[api.MeterReturnEnergy](meter); ok { if f, err := nonZeroEnergy(returnEnergyMeter.ReturnEnergy()); err == nil { mm.ReturnEnergy = &f } else if !errors.Is(err, api.ErrNotAvailable) { diff --git a/core/site_circuits.go b/core/site_circuits.go index 113e62d8ee..30fd027c64 100644 --- a/core/site_circuits.go +++ b/core/site_circuits.go @@ -98,7 +98,7 @@ func (site *Site) dimMeters(dim bool) error { return errs } -// curtailPV applies the HEMS curtailment percent to all curtailable pv meters. +// curtailPV applies the HEMS curtailment percent to all curtailable pv or grid 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 { @@ -108,8 +108,13 @@ 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 site.pvMeters { + for _, dev := range meters { m, ok := api.Cap[api.Curtailer](dev.Instance()) if !ok { continue @@ -133,9 +138,6 @@ func (site *Site) curtailPV(percent *int) error { } } - errGrid := curtailGrid(site, percent) - errs = errors.Join(errs, errGrid) - if errs == nil { site.curtailPercent = new(*percent) } From 7fc7eee102566ac65f2e3cab15870f920cb54654 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 27 Jul 2026 16:42:36 +0200 Subject: [PATCH 07/12] Use error --- templates/definition/meter/sma-homemanager-modbus.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/definition/meter/sma-homemanager-modbus.yaml b/templates/definition/meter/sma-homemanager-modbus.yaml index 7ae812033c..ac4ce06e33 100644 --- a/templates/definition/meter/sma-homemanager-modbus.yaml +++ b/templates/definition/meter/sma-homemanager-modbus.yaml @@ -56,5 +56,5 @@ render: | type: writesingle decode: uint16 curtailed: - source: const - value: -1 # not known + source: error + error: ErrNotAvailable From db65a3485937cbd997d8c8aad9188b28c733bd92 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 27 Jul 2026 16:45:02 +0200 Subject: [PATCH 08/12] Revert ui --- assets/js/views/Config.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index ddbff60dcf..f3889eef09 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -865,13 +865,13 @@ export default defineComponent({ } else if (status.dimmed !== undefined) { result["dimmed"] = { value: status.dimmed }; } - if ((status.curtailed ?? 100) < 100 && (status.curtailed ?? 100) >= 0 && status.maxProductionPower !== undefined) { + if ((status.curtailed ?? 100) < 100 && status.maxProductionPower !== undefined) { result["curtailLimit"] = { value: status.maxProductionPower, warning: true, }; } else if (status.curtailed !== undefined) { - result["curtailed"] = { value: status.curtailed < 100 && status.curtailed >= 0}; + result["curtailed"] = { value: status.curtailed < 100 }; } return result; @@ -1278,7 +1278,8 @@ export default defineComponent({ }, 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 && this.deviceTags("meter", name)["curtailed"]?.value >= 0 + return this.deviceTags("meter", name)["curtailed"]?.value !== undefined && + this.deviceTags("meter", name)["curtailed"]?.value >= 0 ? this.$t("config.deviceValue.productionLimited") : undefined; }, From adc6758f983d350c854f6868139b873dfff51b36 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 27 Jul 2026 16:45:39 +0200 Subject: [PATCH 09/12] wip --- assets/js/views/Config.vue | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index f3889eef09..8fd3efb35d 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -1278,8 +1278,7 @@ export default defineComponent({ }, 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 && - this.deviceTags("meter", name)["curtailed"]?.value >= 0 + return this.deviceTags("meter", name)["curtailed"]?.value !== undefined ? this.$t("config.deviceValue.productionLimited") : undefined; }, From f44e13980158166d3346f822b54954f74c1761b7 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 27 Jul 2026 16:46:42 +0200 Subject: [PATCH 10/12] wip --- server/http_config_helper.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/http_config_helper.go b/server/http_config_helper.go index 3013ea53f9..0da57c72ae 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -442,7 +442,7 @@ func testInstance(ctx context.Context, instance any) map[string]testResult { 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 && val >= 0) { + if val, err := dev.CurtailedPercent(); err != nil || val < 100 { makeResult("curtailed", val, err) } } From 7e48ba3ef1b7507e2596dc44daf3a4875b77c46c Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 27 Jul 2026 16:46:51 +0200 Subject: [PATCH 11/12] Drop curtail grid --- core/site_circuits.go | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/core/site_circuits.go b/core/site_circuits.go index 30fd027c64..4ae6ef372f 100644 --- a/core/site_circuits.go +++ b/core/site_circuits.go @@ -144,32 +144,3 @@ func (site *Site) curtailPV(percent *int) error { return errs } - -func curtailGrid(site *Site, percent *int) error { - var errs error - - if site.gridMeter != nil { - m, ok := api.Cap[api.Curtailer](site.gridMeter) - if !ok { - return errs - } - - if curtailed, err := backoff.RetryWithData(m.CurtailedPercent, modbus.Backoff()); err == nil { - if curtailed == *percent { - return errs - } - } else { - if !errors.Is(err, api.ErrNotAvailable) { - errs = errors.Join(errs, fmt.Errorf("%s curtailed: %w", "grid", err)) - } - return errs - } - - if err := m.SetCurtailPercent(*percent); err == nil { - site.log.DEBUG.Printf("%s curtail: %d%%", "grid", *percent) - } else if !errors.Is(err, api.ErrNotAvailable) { - errs = errors.Join(errs, fmt.Errorf("%s curtail: %w", "grid", err)) - } - } - return errs -} From 15b2f96f154f62ff772014d32f2ca125ff73122e Mon Sep 17 00:00:00 2001 From: Frank Behrens Date: Mon, 27 Jul 2026 18:13:33 +0200 Subject: [PATCH 12/12] fix panic after 8ed31dab073de656f33467a72bff495fdc14327c --- core/site.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/site.go b/core/site.go index 32b152f811..c25e2db86e 100644 --- a/core/site.go +++ b/core/site.go @@ -453,7 +453,7 @@ func (site *Site) DumpConfig() { ) if site.gridMeter != nil { - site.log.INFO.Println(meterCapabilities("grid", site.gridMeter)) + site.log.INFO.Println(meterCapabilities("grid", site.gridMeter.Instance())) } if len(site.pvMeters) > 0 {