Skip to content

Add SMA Sunny Home Manager 2.0 ModbusTCP incl. curtailment - #32091

Open
frbehrens wants to merge 14 commits into
evcc-io:masterfrom
frbehrens:feat/SMA-SHM-curtail
Open

Add SMA Sunny Home Manager 2.0 ModbusTCP incl. curtailment#32091
frbehrens wants to merge 14 commits into
evcc-io:masterfrom
frbehrens:feat/SMA-SHM-curtail

Conversation

@frbehrens

@frbehrens frbehrens commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Fix #32192

Preliminary remark: This PR is not designed to merged directly. It is unlikely to meet the quality requirements of the project. It is a PoC and documents some technical details.

  1. I could not find an existing template for SMA Sunny Home Manager (SHM) 2.0 with modbus transport. So there was a new one created.
  2. A GRID-meter may now implement the api.Curtailer and the processing was added.
  3. There is no known way to read the curtailed status from SHM. I implemented to return the constant "-1" insted. Thanks to 608aa36 and 13aab7c this works quite well.
  4. I made some minor changes to suppress the "-1" value from property "curtailed".

For 3. and 4. a probably better solution is to return the written value from template, that means reading "curtailed" will contain the written value for "curtail". I did not find an easy solution for this and so I continued with 3.

Conclusion: Quick and dirty, but it works and may help to implement the final version.

@github-actions github-actions Bot added devices Specific device support enhancement New feature or request labels Jul 23, 2026
@CiNcH83

CiNcH83 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Is the percentage in 40016 relative to the max. AC power of the inverter or the DC power of the connected panels? Found somewhat conflicting info:

40016 Wirkleistungssollwert P, in % der maximalen Wirkleistung (PMAX) des Wechselrichters
-> Write only (WO) Register zum Schreiben des Wertes verwenden

Active power setpoint P, in % of the maximum active power (PMAX) of the PV plant
Value range:
• -100% to -1% = load
• 0 = no active power
• +1% to +100% = generator

Probably not conflicting at all since it can go negative which means that power is drawn from the grid to charge battery? So it is also AC power I suppose.

There is no known way to read the curtailed status from SHM

Probably 100% is uncurtailed? Everything from 0-99% is curtailed?

@frbehrens

Copy link
Copy Markdown
Contributor Author

Is the percentage in 40016 relative to the max. AC power of the inverter or the DC power of the connected panels? Found somewhat conflicting info:

It is relative to the configured maximum DC power of panels. In my opinion, that is what we want.

There is no known way to read the curtailed status from SHM
Probably 100% is uncurtailed? Everything from 0-99% is curtailed?

I couldn't find any register to read the curtailed value. The register 40016 is write-only, reading leads to an error.

Frank Behrens added 5 commits July 24, 2026 11:01
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.
fixes dcb59ef
* core: don't display curtailed percent value "-1", this means "unknown"
@frbehrens
frbehrens force-pushed the feat/SMA-SHM-curtail branch from 5c3074d to e7c4d03 Compare July 24, 2026 09:06
@andig
andig requested a review from premultiply July 26, 2026 14:29
@premultiply premultiply changed the title SMA SHM: implement curtail for SMA Sunny Home Manager 2.0 modbus Add SMA Sunny Home Manager 2.0 incl. Curtailment (Modbus TCP) Jul 26, 2026
Comment thread assets/js/views/Config.vue Outdated
Comment thread templates/definition/meter/sma-homemanager-modbus.yaml Outdated
@andig
andig marked this pull request as ready for review July 27, 2026 14:41

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 6 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="core/site_circuits.go" line_range="148-157" />
<code_context>
 	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
</code_context>
<issue_to_address>
**issue (bug_risk):** Handle nil `percent` in `curtailGrid` to avoid a potential panic.

`curtailGrid` dereferences `*percent` without a nil check. If it’s ever called with a nil `percent` (similar to `curtailPV`’s call pattern), it will panic. Either add an early-return guard like `curtailPV` or enforce and document that `percent` must be non-nil at the call sites.
</issue_to_address>

### Comment 2
<location path="core/site_circuits.go" line_range="152" />
<code_context>
+	var errs error
+
+	if site.gridMeter != nil {
+		m, ok := api.Cap[api.Curtailer](site.gridMeter)
+		if !ok {
+			return errs
</code_context>
<issue_to_address>
**issue (bug_risk):** Use the grid meter instance when checking for `Curtailer` capability.

Elsewhere (e.g. in `curtailPV` and `updateGridMeter`) you call `api.Cap` on `dev.Instance()` / `site.gridMeter.Instance()`. Here `site.gridMeter` is a `config.Device[api.Meter]`, so passing it directly to `api.Cap[api.Curtailer]` is inconsistent and likely wrong. Consider `api.Cap[api.Curtailer](site.gridMeter.Instance())` so the capability check runs on the underlying meter instance.
</issue_to_address>

### Comment 3
<location path="core/site_circuits.go" line_range="111-117" />
<code_context>
 	// 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 {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Consider checking the grid meter instance rather than the `config.Device` itself before appending.

`site.gridMeter != nil` only checks that the wrapper exists, not that it has a backing instance. Other code paths use `.Instance()` to get the actual meter, which may be `nil`. For consistency and to avoid appending a meter with no instance, consider:

```go
if gm := site.gridMeter.Instance(); gm != nil {
    meters = append(meters, site.gridMeter)
}
```

This makes it clear that we only add the grid meter when a real instance is available, without depending on how `config.Device` represents `nil` internally.

Suggested implementation:

```golang
	meters := slices.Clone(site.pvMeters)
	if gm := site.gridMeter.Instance(); gm != nil {
		meters = append(meters, site.gridMeter)
	}

```

1. Ensure `site.gridMeter` has an `Instance()` method returning the underlying device; this is already implied by other code paths you mentioned, so no further change should be needed.
2. No changes to imports are required, as `slices` is already in use.
</issue_to_address>

### Comment 4
<location path="core/site.go" line_range="864-866" />
<code_context>
 	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
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard against a nil grid meter instance before using it.

`Boot` previously guaranteed a non-nil `gridMeter`, but now `gridMeter` is a `config.Device[api.Meter]` and `Instance()` may return `nil`. In that case, `meter.CurrentPower` will panic. Please add a nil check (and optionally a log) before using `meter`.
</issue_to_address>

### Comment 5
<location path="assets/js/views/Config.vue" line_range="873-874" />
<code_context>
 					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};
 			}

</code_context>
<issue_to_address>
**issue (bug_risk):** Clarify the semantics and type of the `curtailed` tag value.

`curtailed` used to be a numeric percentage but is now set to a boolean (`status.curtailed < 100 && status.curtailed >= 0`). However, downstream code (e.g. `meterBanner`) still performs numeric comparisons on `curtailed`, which is incorrect for a boolean. Please either keep `curtailed` as a numeric percentage and update the banner logic, or fully treat it as a boolean and remove numeric comparisons everywhere it is used.
</issue_to_address>

### Comment 6
<location path="assets/js/views/Config.vue" line_range="1281" />
<code_context>
 		},
 		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 && this.deviceTags("meter", name)["curtailed"]?.value >= 0
 				? this.$t("config.deviceValue.productionLimited")
 				: undefined;
</code_context>
<issue_to_address>
**issue (bug_risk):** Avoid comparing a potentially boolean `curtailed` value numerically and reduce duplicate lookups.

With the new `deviceTags` behavior, `curtailed.value` is boolean, so `>= 0` just coerces it to a number and doesn’t add a meaningful check. If the banner should appear whenever `curtailed` is present, you can drop the numeric comparison and rely on the tag creation logic. Also, consider storing `this.deviceTags("meter", name)` in a local variable to avoid evaluating it twice.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread core/site_circuits.go Outdated
Comment thread core/site_circuits.go Outdated
Comment thread core/site_circuits.go
Comment thread core/site.go
Comment thread assets/js/views/Config.vue Outdated
Comment thread assets/js/views/Config.vue
@andig

andig commented Jul 27, 2026

Copy link
Copy Markdown
Member

I've added two more commits outside of this PR to fix the api.ErrNotAvailable handling. This should pretty much work, but curtailment is now written at every step.

@andig andig changed the title Add SMA Sunny Home Manager 2.0 incl. Curtailment (Modbus TCP) Add SMA Sunny Home Manager 2.0 ModbusTCP incl. curtailment Jul 27, 2026
@frbehrens

Copy link
Copy Markdown
Contributor Author

I've added two more commits outside of this PR to fix the api.ErrNotAvailable handling. This should pretty much work, but curtailment is now written at every step.

Thanks! I will test this over the next few days.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

devices Specific device support enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Abregelbarkeit für SMA Wechselrichter nach § 9 EEG

3 participants