From 19deff2342fdd94dda0b70f38daf03ad19ef953d Mon Sep 17 00:00:00 2001 From: Christoph Date: Tue, 12 May 2026 15:19:32 +0200 Subject: [PATCH 1/6] [Edge] Add ESS SMA Sunny Boy Storage 2.5 Implements the SMA Sunny Boy Storage 2.5 (SBS 2.5) as a ManagedSymmetricEss via Modbus TCP, using the SMA CmpBMS external control interface. Key implementation details: - Non-standard addressing: the SBS 2.5 uses the SMA register number directly as the 0-based Modbus PDU address (confirmed by live device testing) - SoC and Energy registers use FC3 (holding), not FC4 (input) - All 6 CmpBMS control registers must be refreshed within every 60 s window - Includes OpenemsApp (App.Ess.Sma.SunnyBoyStorage) with read-only mode option Tested on SMA Sunny Boy Storage 2.5, firmware 3.x Closes #3222 --- io.openems.edge.application/EdgeApp.bndrun | 2 + .../edge/app/ess/SmaEssSunnyBoyStorage.java | 181 ++++++++++++++++ .../.classpath | 39 ++++ .../.gitignore | 2 + .../.project | 34 +++ .../org.eclipse.core.resources.prefs | 2 + .../bnd.bnd | 15 ++ .../readme.adoc | 47 +++++ .../edge/ess/sma/sunnyboystorage/Config.java | 36 ++++ .../EssSmaSunnyBoyStorage.java | 111 ++++++++++ .../EssSmaSunnyBoyStorageImpl.java | 199 ++++++++++++++++++ .../EssSmaSunnyBoyStorageImplTest.java | 139 ++++++++++++ .../ess/sma/sunnyboystorage/MyConfig.java | 84 ++++++++ 13 files changed, 891 insertions(+) create mode 100644 io.openems.edge.core/src/io/openems/edge/app/ess/SmaEssSunnyBoyStorage.java create mode 100644 io.openems.edge.ess.sma.sunnyboystorage/.classpath create mode 100644 io.openems.edge.ess.sma.sunnyboystorage/.gitignore create mode 100644 io.openems.edge.ess.sma.sunnyboystorage/.project create mode 100644 io.openems.edge.ess.sma.sunnyboystorage/.settings/org.eclipse.core.resources.prefs create mode 100644 io.openems.edge.ess.sma.sunnyboystorage/bnd.bnd create mode 100644 io.openems.edge.ess.sma.sunnyboystorage/readme.adoc create mode 100644 io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/Config.java create mode 100644 io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorage.java create mode 100644 io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java create mode 100644 io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java create mode 100644 io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma/sunnyboystorage/MyConfig.java diff --git a/io.openems.edge.application/EdgeApp.bndrun b/io.openems.edge.application/EdgeApp.bndrun index fdae8d863b5..ca1b6d5f2df 100644 --- a/io.openems.edge.application/EdgeApp.bndrun +++ b/io.openems.edge.application/EdgeApp.bndrun @@ -125,6 +125,7 @@ bnd.identity;id='io.openems.edge.ess.fenecon.commercial40',\ bnd.identity;id='io.openems.edge.ess.generic',\ bnd.identity;id='io.openems.edge.ess.samsung',\ + bnd.identity;id='io.openems.edge.ess.sma.sunnyboystorage',\ bnd.identity;id='io.openems.edge.evcs.abl',\ bnd.identity;id='io.openems.edge.evcs.cluster',\ bnd.identity;id='io.openems.edge.evcs.core',\ @@ -331,6 +332,7 @@ io.openems.edge.ess.fenecon.commercial40;version=snapshot,\ io.openems.edge.ess.generic;version=snapshot,\ io.openems.edge.ess.samsung;version=snapshot,\ + io.openems.edge.ess.sma.sunnyboystorage;version=snapshot,\ io.openems.edge.evcs.abl;version=snapshot,\ io.openems.edge.evcs.api;version=snapshot,\ io.openems.edge.evcs.cluster;version=snapshot,\ diff --git a/io.openems.edge.core/src/io/openems/edge/app/ess/SmaEssSunnyBoyStorage.java b/io.openems.edge.core/src/io/openems/edge/app/ess/SmaEssSunnyBoyStorage.java new file mode 100644 index 00000000000..7c67dfbb29f --- /dev/null +++ b/io.openems.edge.core/src/io/openems/edge/app/ess/SmaEssSunnyBoyStorage.java @@ -0,0 +1,181 @@ +package io.openems.edge.app.ess; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.osgi.service.cm.ConfigurationAdmin; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +import com.google.gson.JsonElement; + +import io.openems.common.exceptions.OpenemsError.OpenemsNamedException; +import io.openems.common.function.ThrowingTriFunction; +import io.openems.common.session.Language; +import io.openems.common.types.EdgeConfig; +import io.openems.common.utils.JsonUtils; +import io.openems.edge.app.common.props.CommonProps; +import io.openems.edge.app.common.props.CommunicationProps; +import io.openems.edge.app.ess.SmaEssSunnyBoyStorage.Property; +import io.openems.edge.common.component.ComponentManager; +import io.openems.edge.core.appmanager.AbstractOpenemsApp; +import io.openems.edge.core.appmanager.AbstractOpenemsAppWithProps; +import io.openems.edge.core.appmanager.AppConfiguration; +import io.openems.edge.core.appmanager.AppDef; +import io.openems.edge.core.appmanager.ComponentUtil; +import io.openems.edge.core.appmanager.ConfigurationTarget; +import io.openems.edge.core.appmanager.Nameable; +import io.openems.edge.core.appmanager.OpenemsApp; +import io.openems.edge.core.appmanager.OpenemsAppCardinality; +import io.openems.edge.core.appmanager.OpenemsAppCategory; +import io.openems.edge.core.appmanager.Type; +import io.openems.edge.core.appmanager.Type.Parameter; +import io.openems.edge.core.appmanager.Type.Parameter.BundleParameter; +import io.openems.edge.core.appmanager.dependency.Tasks; +import io.openems.edge.core.appmanager.formly.JsonFormlyUtil; +import io.openems.edge.core.appmanager.formly.enums.InputType; + +/** + * Describes an App for the SMA Sunny Boy Storage 2.5 ESS. + * + *
+  {
+    "appId":"App.Ess.Sma.SunnyBoyStorage",
+    "alias":"SMA Sunny Boy Storage 2.5",
+    "instanceId": UUID,
+    "image": base64,
+    "properties":{
+      "ESS_ID": "ess0",
+      "MODBUS_ID": "modbus0",
+      "IP": "192.168.178.85",
+      "PORT": "502",
+      "MODBUS_UNIT_ID": "3",
+      "CAPACITY": "2000",
+      "READ_ONLY_MODE": "false"
+    },
+    "appDescriptor": {
+      "websiteUrl": {@link io.openems.edge.core.appmanager.AppDescriptor#getWebsiteUrl()}
+    }
+  }
+ * 
+ */ +@Component(name = "App.Ess.Sma.SunnyBoyStorage") +public class SmaEssSunnyBoyStorage + extends AbstractOpenemsAppWithProps + implements OpenemsApp { + + public static enum Property + implements Type, Nameable { + + // Component-IDs + ESS_ID(AppDef.componentId("ess0")), // + MODBUS_ID(AppDef.componentId("modbus0")), // + + // User-visible properties + ALIAS(CommonProps.alias()), // + IP(AppDef.copyOfGeneric(CommunicationProps.ip(), def -> def // + .setDefaultValue("192.168.178.85") // + .setRequired(true))), // + PORT(AppDef.copyOfGeneric(CommunicationProps.port(), def -> def // + .setRequired(true))), // + MODBUS_UNIT_ID(AppDef.copyOfGeneric(CommunicationProps.modbusUnitId(), def -> def // + .setDefaultValue(3) // + .setRequired(true))), // + CAPACITY(AppDef.copyOfGeneric(CommonProps.defaultDef(), def -> def // + .setLabel("Kapazität [Wh]") // + .setDescription("Nutzbare Netto-Kapazität der Batterie in Wh. SBS 2.5 = 2000 Wh") // + .setDefaultValue(2000) // + .setField(JsonFormlyUtil::buildInputFromNameable, + (app, prop, l, param, f) -> f.setInputType(InputType.NUMBER).setMin(0)))), // + READ_ONLY_MODE(AppDef.copyOfGeneric(CommonProps.defaultDef(), def -> def // + .setLabel("Nur-Lesen-Modus") // + .setDescription("Aktiviert den Nur-Lesen-Modus; keine Sollwerte werden an das Gerät geschrieben.") // + .setDefaultValue(false) // + .setField(JsonFormlyUtil::buildCheckboxFromNameable))), // + ; + + private final AppDef def; + + private Property(AppDef def) { + this.def = def; + } + + @Override + public Property self() { + return this; + } + + @Override + public AppDef def() { + return this.def; + } + + @Override + public Function, BundleParameter> getParamter() { + return Parameter.functionOf(AbstractOpenemsApp::getTranslationBundle); + } + } + + @Activate + public SmaEssSunnyBoyStorage(@Reference ComponentManager componentManager, ComponentContext context, + @Reference ConfigurationAdmin cm, @Reference ComponentUtil componentUtil) { + super(componentManager, context, cm, componentUtil); + } + + @Override + protected ThrowingTriFunction, Language, AppConfiguration, OpenemsNamedException> appPropertyConfigurationFactory() { + return (t, p, l) -> { + final var alias = this.getString(p, l, Property.ALIAS); + final var ip = this.getString(p, l, Property.IP); + final var port = this.getInt(p, Property.PORT); + final var modbusUnitId = this.getInt(p, Property.MODBUS_UNIT_ID); + final var capacity = this.getInt(p, Property.CAPACITY); + final var readOnlyMode = this.getBoolean(p, Property.READ_ONLY_MODE); + + final var essId = this.getId(t, p, Property.ESS_ID); + final var modbusId = this.getId(t, p, Property.MODBUS_ID); + + final var components = List.of(// + new EdgeConfig.Component(essId, alias, "Ess.Sma.SunnyBoyStorage", // + JsonUtils.buildJsonObject() // + .addProperty("modbus.id", modbusId) // + .addProperty("modbusUnitId", modbusUnitId) // + .addProperty("capacity", capacity) // + .addProperty("readOnlyMode", readOnlyMode) // + .build()), // + new EdgeConfig.Component(modbusId, alias, "Bridge.Modbus.Tcp", // + JsonUtils.buildJsonObject() // + .addProperty("ip", ip) // + .addProperty("port", port) // + .build()) // + ); + + return AppConfiguration.create() // + .addTask(Tasks.component(components)) // + .build(); + }; + } + + @Override + public OpenemsAppCardinality getCardinality() { + return OpenemsAppCardinality.MULTIPLE; + } + + @Override + public OpenemsAppCategory[] getCategories() { + return new OpenemsAppCategory[] { OpenemsAppCategory.ESS }; + } + + @Override + protected SmaEssSunnyBoyStorage getApp() { + return this; + } + + @Override + protected Property[] propertyValues() { + return Property.values(); + } +} diff --git a/io.openems.edge.ess.sma.sunnyboystorage/.classpath b/io.openems.edge.ess.sma.sunnyboystorage/.classpath new file mode 100644 index 00000000000..07205c33c53 --- /dev/null +++ b/io.openems.edge.ess.sma.sunnyboystorage/.classpath @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/io.openems.edge.ess.sma.sunnyboystorage/.gitignore b/io.openems.edge.ess.sma.sunnyboystorage/.gitignore new file mode 100644 index 00000000000..c2b941a96de --- /dev/null +++ b/io.openems.edge.ess.sma.sunnyboystorage/.gitignore @@ -0,0 +1,2 @@ +/bin_test/ +/generated/ diff --git a/io.openems.edge.ess.sma.sunnyboystorage/.project b/io.openems.edge.ess.sma.sunnyboystorage/.project new file mode 100644 index 00000000000..c9a202e8ec9 --- /dev/null +++ b/io.openems.edge.ess.sma.sunnyboystorage/.project @@ -0,0 +1,34 @@ + + + io.openems.edge.ess.sma.sunnyboystorage + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.buildship.core.gradleprojectnature + + + + 1778534679763 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + + diff --git a/io.openems.edge.ess.sma.sunnyboystorage/.settings/org.eclipse.core.resources.prefs b/io.openems.edge.ess.sma.sunnyboystorage/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 00000000000..99f26c0203a --- /dev/null +++ b/io.openems.edge.ess.sma.sunnyboystorage/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/io.openems.edge.ess.sma.sunnyboystorage/bnd.bnd b/io.openems.edge.ess.sma.sunnyboystorage/bnd.bnd new file mode 100644 index 00000000000..1e6462d7a84 --- /dev/null +++ b/io.openems.edge.ess.sma.sunnyboystorage/bnd.bnd @@ -0,0 +1,15 @@ +Bundle-Name: OpenEMS Edge ESS SMA Sunny Boy Storage +Bundle-Vendor: FENECON GmbH; OpenEMS Association e.V. +Bundle-License: https://opensource.org/licenses/EPL-2.0 +Bundle-Version: 1.0.0.${tstamp} + +-buildpath: \ + ${buildpath},\ + io.openems.common,\ + io.openems.edge.bridge.modbus,\ + io.openems.edge.common,\ + io.openems.edge.ess.api,\ + io.openems.j2mod,\ + +-testpath: \ + ${testpath} diff --git a/io.openems.edge.ess.sma.sunnyboystorage/readme.adoc b/io.openems.edge.ess.sma.sunnyboystorage/readme.adoc new file mode 100644 index 00000000000..313241bd5df --- /dev/null +++ b/io.openems.edge.ess.sma.sunnyboystorage/readme.adoc @@ -0,0 +1,47 @@ += SMA Sunny Boy Storage 2.5 + +== ESS + +Implements the SMA Sunny Boy Storage 2.5 (SBS 2.5) as a `ManagedSymmetricEss` via Modbus TCP. + +The inverter exposes read and write registers through the SMA BMS external control interface +(CmpBMS). All six control registers (BMS mode, min/max charge power, min/max discharge power, +grid power setpoint) must be written within every 60-second window or the device reverts to +autonomous mode. + +Tested on: + +* SMA Sunny Boy Storage 2.5 (firmware tested: 3.x) + +Implemented Natures: + +* ManagedSymmetricEss +* SymmetricEss + +=== Modbus Configuration + +* Protocol: Modbus TCP +* Default Unit-ID: 3 +* Default port: 502 + +=== Modbus Register Map + +NOTE: The SMA Sunny Boy Storage uses the SMA register number directly as the +0-based Modbus PDU address (non-standard; confirmed by live testing against firmware 3.x). + +[cols="1,1,1,3"] +|=== +|SMA Register (= PDU addr) |FC |Type |Description + +|30513 |FC3 |uint64 |Energy Total [Wh] +|30775 |FC4 |int32 |Battery Power / Active Power [W] +|30845 |FC3 |uint32 |State of Charge [%] +|40236 |FC16 |uint32 |BMS Mode (2424 = Normal, 2289 = Force Charge) +|40793 |FC16 |uint32 |Min Charge Power [W] +|40795 |FC16 |uint32 |Max Charge Power [W] +|40797 |FC16 |uint32 |Min Discharge Power [W] +|40799 |FC16 |uint32 |Max Discharge Power [W] +|40801 |FC16 |int32 |Grid Power Setpoint [W] +|=== + +https://github.com/OpenEMS/openems/tree/develop/io.openems.edge.ess.sma.sunnyboystorage[Source Code icon:github[]] diff --git a/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/Config.java b/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/Config.java new file mode 100644 index 00000000000..108d7418827 --- /dev/null +++ b/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/Config.java @@ -0,0 +1,36 @@ +package io.openems.edge.ess.sma.sunnyboystorage; + +import org.osgi.service.metatype.annotations.AttributeDefinition; +import org.osgi.service.metatype.annotations.ObjectClassDefinition; + +@ObjectClassDefinition(// + name = "ESS SMA Sunny Boy Storage", // + description = "Implements the SMA Sunny Boy Storage 2.5 energy storage system via Modbus TCP.") +@interface Config { + + @AttributeDefinition(name = "Component-ID", description = "Unique ID of this Component") + String id() default "ess0"; + + @AttributeDefinition(name = "Alias", description = "Human-readable name of this Component; defaults to Component-ID") + String alias() default ""; + + @AttributeDefinition(name = "Is enabled?", description = "Is this Component enabled?") + boolean enabled() default true; + + @AttributeDefinition(name = "Read-Only mode", description = "Enables Read-Only mode; no setpoints are written to the device") + boolean readOnlyMode() default false; + + @AttributeDefinition(name = "Modbus-ID", description = "ID of Modbus bridge.") + String modbus_id() default "modbus0"; + + @AttributeDefinition(name = "Modbus Unit-ID", description = "The Unit-ID of the Modbus device. SMA SBS default: 3") + int modbusUnitId() default 3; + + @AttributeDefinition(name = "Capacity [Wh]", description = "Net usable capacity of the battery in Wh. SBS 2.5 = 2000 Wh usable") + int capacity() default 2000; + + @AttributeDefinition(name = "Modbus target filter", description = "This is auto-generated by 'Modbus-ID'.") + String Modbus_target() default "(enabled=true)"; + + String webconsole_configurationFactory_nameHint() default "ESS SMA Sunny Boy Storage [{id}]"; +} diff --git a/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorage.java b/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorage.java new file mode 100644 index 00000000000..c1338b4a45e --- /dev/null +++ b/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorage.java @@ -0,0 +1,111 @@ +package io.openems.edge.ess.sma.sunnyboystorage; + +import io.openems.common.channel.AccessMode; +import io.openems.common.channel.Unit; +import io.openems.common.types.OpenemsType; +import io.openems.edge.common.channel.Doc; + +public interface EssSmaSunnyBoyStorage { + + public enum ChannelId implements io.openems.edge.common.channel.ChannelId { + + /** + * Total energy delivered by the battery (charge + discharge combined). + * + *
    + *
  • Register 30513, uint64, FC3 + *
  • Unit: Wh + *
+ */ + ENERGY_TOTAL(Doc.of(OpenemsType.LONG) // + .unit(Unit.CUMULATED_WATT_HOURS)), // + + // --- Write channels (mapped to Modbus holding registers 40xxx via FC16) --- + + /** + * BMS operating mode. + * + *
    + *
  • Register 40236, uint32, FC16 + *
  • 2424 = Normal, 2289 = Force charge + *
+ */ + BMS_MODE(Doc.of(OpenemsType.INTEGER) // + .accessMode(AccessMode.WRITE_ONLY)), // + + /** + * Minimum charge power sent to the inverter (CmpBMS.BatChaMinW). + * + *
    + *
  • Register 40793, uint32, FC16 + *
  • Unit: W. Set to 0 for normal operation, equal to MAX_CHARGE_POWER to + * force a fixed charge power. + *
+ */ + MIN_CHARGE_POWER(Doc.of(OpenemsType.INTEGER) // + .unit(Unit.WATT) // + .accessMode(AccessMode.WRITE_ONLY)), // + + /** + * Maximum charge power limit sent to the inverter (CmpBMS.BatChaMaxW). + * + *
    + *
  • Register 40795, uint32, FC16 + *
  • Unit: W, Range: 0..2500 + *
+ */ + MAX_CHARGE_POWER(Doc.of(OpenemsType.INTEGER) // + .unit(Unit.WATT) // + .accessMode(AccessMode.WRITE_ONLY)), // + + /** + * Minimum discharge power sent to the inverter (CmpBMS.BatDschMinW). + * + *
    + *
  • Register 40797, uint32, FC16 + *
  • Unit: W. Typically 0 (no minimum discharge enforced). + *
+ */ + MIN_DISCHARGE_POWER(Doc.of(OpenemsType.INTEGER) // + .unit(Unit.WATT) // + .accessMode(AccessMode.WRITE_ONLY)), // + + /** + * Maximum discharge power limit sent to the inverter (CmpBMS.BatDschMaxW). + * + *
    + *
  • Register 40799, uint32, FC16 + *
  • Unit: W, Range: 0..2500 + *
+ */ + MAX_DISCHARGE_POWER(Doc.of(OpenemsType.INTEGER) // + .unit(Unit.WATT) // + .accessMode(AccessMode.WRITE_ONLY)), // + + /** + * Grid power setpoint (CmpBMS.GridWSpt). Positive = discharge (ESS feeds + * grid/load), negative = charge (grid/PV charges ESS). Matches OpenEMS + * ACTIVE_POWER sign convention and verified against the EVCC SBS 2.5 + * implementation. + * + *
    + *
  • Register 40801, int32, FC16 + *
  • Unit: W + *
+ */ + GRID_POWER_SETPOINT(Doc.of(OpenemsType.INTEGER) // + .unit(Unit.WATT) // + .accessMode(AccessMode.WRITE_ONLY)); // + + private final Doc doc; + + private ChannelId(Doc doc) { + this.doc = doc; + } + + @Override + public Doc doc() { + return this.doc; + } + } +} diff --git a/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java b/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java new file mode 100644 index 00000000000..c438fffa674 --- /dev/null +++ b/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java @@ -0,0 +1,199 @@ +package io.openems.edge.ess.sma.sunnyboystorage; + +import org.osgi.service.cm.ConfigurationAdmin; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ConfigurationPolicy; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.ReferencePolicy; +import org.osgi.service.component.annotations.ReferencePolicyOption; +import org.osgi.service.metatype.annotations.Designate; + +import io.openems.common.channel.AccessMode; +import io.openems.common.exceptions.OpenemsError.OpenemsNamedException; +import io.openems.common.exceptions.OpenemsException; +import io.openems.edge.bridge.modbus.api.AbstractOpenemsModbusComponent; +import io.openems.edge.bridge.modbus.api.BridgeModbus; +import io.openems.edge.bridge.modbus.api.ModbusComponent; +import io.openems.edge.bridge.modbus.api.ModbusProtocol; +import io.openems.edge.bridge.modbus.api.element.SignedDoublewordElement; +import io.openems.edge.bridge.modbus.api.element.UnsignedDoublewordElement; +import io.openems.edge.bridge.modbus.api.element.UnsignedQuadruplewordElement; +import io.openems.edge.bridge.modbus.api.task.FC16WriteRegistersTask; +import io.openems.edge.bridge.modbus.api.task.FC3ReadRegistersTask; +import io.openems.edge.bridge.modbus.api.task.FC4ReadInputRegistersTask; +import io.openems.edge.common.channel.IntegerWriteChannel; +import io.openems.edge.common.component.OpenemsComponent; +import io.openems.edge.common.modbusslave.ModbusSlave; +import io.openems.edge.common.modbusslave.ModbusSlaveNatureTable; +import io.openems.edge.common.modbusslave.ModbusSlaveTable; +import io.openems.edge.common.taskmanager.Priority; +import io.openems.edge.ess.api.ManagedSymmetricEss; +import io.openems.edge.ess.api.SymmetricEss; +import io.openems.edge.ess.power.api.Power; + +@Designate(ocd = Config.class, factory = true) +@Component(// + name = "Ess.Sma.SunnyBoyStorage", // + immediate = true, // + configurationPolicy = ConfigurationPolicy.REQUIRE // +) +public class EssSmaSunnyBoyStorageImpl extends AbstractOpenemsModbusComponent + implements EssSmaSunnyBoyStorage, ManagedSymmetricEss, SymmetricEss, ModbusComponent, OpenemsComponent, + ModbusSlave { + + /** + * SBS 2.5 peak AC power in W. + */ + private static final int MAX_APPARENT_POWER = 2500; + + @Reference + private Power power; + + @Reference + private ConfigurationAdmin cm; + + private Config config; + + @Override + @Reference(policy = ReferencePolicy.STATIC, policyOption = ReferencePolicyOption.GREEDY, cardinality = ReferenceCardinality.MANDATORY) + protected void setModbus(BridgeModbus modbus) { + super.setModbus(modbus); + } + + public EssSmaSunnyBoyStorageImpl() { + super(// + OpenemsComponent.ChannelId.values(), // + ModbusComponent.ChannelId.values(), // + SymmetricEss.ChannelId.values(), // + ManagedSymmetricEss.ChannelId.values(), // + EssSmaSunnyBoyStorage.ChannelId.values() // + ); + this._setMaxApparentPower(MAX_APPARENT_POWER); + } + + @Activate + private void activate(ComponentContext context, Config config) throws OpenemsException { + if (super.activate(context, config.id(), config.alias(), config.enabled(), config.modbusUnitId(), this.cm, + "Modbus", config.modbus_id())) { + return; + } + this.config = config; + this._setCapacity(config.capacity()); + this.getAllowedChargePowerChannel().setNextValue(-MAX_APPARENT_POWER); + this._setAllowedDischargePower(MAX_APPARENT_POWER); + } + + @Override + @Deactivate + protected void deactivate() { + super.deactivate(); + } + + @Override + public void applyPower(int activePower, int reactivePower) throws OpenemsNamedException { + if (this.config.readOnlyMode()) { + return; + } + + IntegerWriteChannel bmsModeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.BMS_MODE); + bmsModeChannel.setNextWriteValue(2424); + + int chargePower = activePower < 0 ? Math.abs(activePower) : 0; + int dischargePower = activePower > 0 ? activePower : 0; + + // All 6 CmpBMS registers must be refreshed within every 60 s window + IntegerWriteChannel minChargeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.MIN_CHARGE_POWER); + minChargeChannel.setNextWriteValue(0); + + IntegerWriteChannel maxChargeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.MAX_CHARGE_POWER); + maxChargeChannel.setNextWriteValue(chargePower); + + IntegerWriteChannel minDischargeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.MIN_DISCHARGE_POWER); + minDischargeChannel.setNextWriteValue(0); + + IntegerWriteChannel maxDischargeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.MAX_DISCHARGE_POWER); + maxDischargeChannel.setNextWriteValue(dischargePower); + + IntegerWriteChannel gridSetpointChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.GRID_POWER_SETPOINT); + gridSetpointChannel.setNextWriteValue(activePower); + } + + @Override + protected ModbusProtocol defineModbusProtocol() { + /* + * The SMA Sunny Boy Storage uses the SMA register number directly as the + * 0-based Modbus PDU address (non-standard but confirmed by live testing). + * + * Reads: + * FC4 @ 30775 : Battery Power [W] int32 (2 words) + * FC3 @ 30513 : Energy Total [Wh] uint64 (4 words) + * FC3 @ 30845 : State of Charge [%] uint32 (2 words) + * + * Writes (all 6 must be refreshed within every 60 s window): + * FC16 @ 40236 : BMS Mode uint32 (2424=Normal, 2289=ForceCharge) + * FC16 @ 40793 : Min Charge Power [W] uint32 (CmpBMS.BatChaMinW) + * FC16 @ 40795 : Max Charge Power [W] uint32 (CmpBMS.BatChaMaxW) + * FC16 @ 40797 : Min Discharge Power [W] uint32 (CmpBMS.BatDschMinW) + * FC16 @ 40799 : Max Discharge Power [W] uint32 (CmpBMS.BatDschMaxW) + * FC16 @ 40801 : Grid Power Setpoint [W] int32 (CmpBMS.GridWSpt) + */ + return new ModbusProtocol(this, // + new FC4ReadInputRegistersTask(30775, Priority.HIGH, // + m(SymmetricEss.ChannelId.ACTIVE_POWER, // + new SignedDoublewordElement(30775))), // + new FC3ReadRegistersTask(30513, Priority.LOW, // + m(EssSmaSunnyBoyStorage.ChannelId.ENERGY_TOTAL, // + new UnsignedQuadruplewordElement(30513))), // + new FC3ReadRegistersTask(30845, Priority.HIGH, // + m(SymmetricEss.ChannelId.SOC, // + new UnsignedDoublewordElement(30845))), // + new FC16WriteRegistersTask(40236, // + m(EssSmaSunnyBoyStorage.ChannelId.BMS_MODE, // + new UnsignedDoublewordElement(40236))), // + new FC16WriteRegistersTask(40793, // + m(EssSmaSunnyBoyStorage.ChannelId.MIN_CHARGE_POWER, // + new UnsignedDoublewordElement(40793)), // + m(EssSmaSunnyBoyStorage.ChannelId.MAX_CHARGE_POWER, // + new UnsignedDoublewordElement(40795))), // + new FC16WriteRegistersTask(40797, // + m(EssSmaSunnyBoyStorage.ChannelId.MIN_DISCHARGE_POWER, // + new UnsignedDoublewordElement(40797)), // + m(EssSmaSunnyBoyStorage.ChannelId.MAX_DISCHARGE_POWER, // + new UnsignedDoublewordElement(40799)), // + m(EssSmaSunnyBoyStorage.ChannelId.GRID_POWER_SETPOINT, // + new SignedDoublewordElement(40801))) // + ); + } + + @Override + public String debugLog() { + return "SoC:" + this.getSoc().asString() // + + "|P:" + this.getActivePower().asString() // + + "|AllowedCharge:" + this.getAllowedChargePower().asString() // + + "|AllowedDischarge:" + this.getAllowedDischargePower().asString(); + } + + @Override + public Power getPower() { + return this.power; + } + + @Override + public int getPowerPrecision() { + return 1; + } + + @Override + public ModbusSlaveTable getModbusSlaveTable(AccessMode accessMode) { + return new ModbusSlaveTable(// + OpenemsComponent.getModbusSlaveNatureTable(accessMode), // + SymmetricEss.getModbusSlaveNatureTable(accessMode), // + ManagedSymmetricEss.getModbusSlaveNatureTable(accessMode), // + ModbusSlaveNatureTable.of(EssSmaSunnyBoyStorage.class, accessMode, 300) // + .build()); + } +} diff --git a/io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java b/io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java new file mode 100644 index 00000000000..a0082e014e2 --- /dev/null +++ b/io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java @@ -0,0 +1,139 @@ +package io.openems.edge.ess.sma.sunnyboystorage; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import io.openems.common.test.DummyConfigurationAdmin; +import io.openems.edge.bridge.modbus.test.DummyModbusBridge; +import io.openems.edge.common.channel.IntegerWriteChannel; +import io.openems.edge.common.test.ComponentTest; +import io.openems.edge.ess.api.ManagedSymmetricEss; +import io.openems.edge.ess.api.SymmetricEss; +import io.openems.edge.ess.test.DummyPower; + +public class EssSmaSunnyBoyStorageImplTest { + + /** + * Smoke test: component activates and deactivates without errors. + */ + @Test + public void testActivateDeactivate() throws Exception { + new ComponentTest(new EssSmaSunnyBoyStorageImpl()) // + .addReference("cm", new DummyConfigurationAdmin()) // + .addReference("power", new DummyPower()) // + .addReference("setModbus", new DummyModbusBridge("modbus0")) // + .activate(MyConfig.create() // + .setId("ess0") // + .setModbusId("modbus0") // + .setModbusUnitId(3) // + .setCapacity(2000) // + .build()) // + .deactivate(); + } + + /** + * Verifies that static limits set during activation are correct. + */ + @Test + public void testStaticLimits() throws Exception { + var ess = new EssSmaSunnyBoyStorageImpl(); + new ComponentTest(ess) // + .addReference("cm", new DummyConfigurationAdmin()) // + .addReference("power", new DummyPower()) // + .addReference("setModbus", new DummyModbusBridge("modbus0")) // + .activate(MyConfig.create().build()); + + assertEquals(Integer.valueOf(-2500), + ess.channel(ManagedSymmetricEss.ChannelId.ALLOWED_CHARGE_POWER).getNextValue().get()); + assertEquals(Integer.valueOf(2500), + ess.channel(ManagedSymmetricEss.ChannelId.ALLOWED_DISCHARGE_POWER).getNextValue().get()); + assertEquals(Integer.valueOf(2500), + ess.channel(SymmetricEss.ChannelId.MAX_APPARENT_POWER).getNextValue().get()); + assertEquals(Integer.valueOf(2000), + ess.channel(SymmetricEss.ChannelId.CAPACITY).getNextValue().get()); + } + + /** + * Verifies that applyPower() sets the correct write-channel values for + * discharge (activePower > 0). + */ + @Test + public void testApplyPowerDischarge() throws Exception { + var ess = new EssSmaSunnyBoyStorageImpl(); + new ComponentTest(ess) // + .addReference("cm", new DummyConfigurationAdmin()) // + .addReference("power", new DummyPower()) // + .addReference("setModbus", new DummyModbusBridge("modbus0")) // + .activate(MyConfig.create().build()); + + ess.applyPower(1000, 0); + + IntegerWriteChannel bmsModeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.BMS_MODE); + assertEquals(Integer.valueOf(2424), bmsModeChannel.getNextWriteValue().get()); + + IntegerWriteChannel minChargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MIN_CHARGE_POWER); + assertEquals(Integer.valueOf(0), minChargeChannel.getNextWriteValue().get()); + + IntegerWriteChannel maxChargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MAX_CHARGE_POWER); + assertEquals(Integer.valueOf(0), maxChargeChannel.getNextWriteValue().get()); + + IntegerWriteChannel minDischargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MIN_DISCHARGE_POWER); + assertEquals(Integer.valueOf(0), minDischargeChannel.getNextWriteValue().get()); + + IntegerWriteChannel maxDischargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MAX_DISCHARGE_POWER); + assertEquals(Integer.valueOf(1000), maxDischargeChannel.getNextWriteValue().get()); + + IntegerWriteChannel gridSetpointChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.GRID_POWER_SETPOINT); + assertEquals(Integer.valueOf(1000), gridSetpointChannel.getNextWriteValue().get()); + } + + /** + * Verifies that applyPower() sets the correct write-channel values for + * charge (activePower < 0). + */ + @Test + public void testApplyPowerCharge() throws Exception { + var ess = new EssSmaSunnyBoyStorageImpl(); + new ComponentTest(ess) // + .addReference("cm", new DummyConfigurationAdmin()) // + .addReference("power", new DummyPower()) // + .addReference("setModbus", new DummyModbusBridge("modbus0")) // + .activate(MyConfig.create().build()); + + ess.applyPower(-800, 0); + + IntegerWriteChannel minChargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MIN_CHARGE_POWER); + assertEquals(Integer.valueOf(0), minChargeChannel.getNextWriteValue().get()); + + IntegerWriteChannel maxChargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MAX_CHARGE_POWER); + assertEquals(Integer.valueOf(800), maxChargeChannel.getNextWriteValue().get()); + + IntegerWriteChannel minDischargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MIN_DISCHARGE_POWER); + assertEquals(Integer.valueOf(0), minDischargeChannel.getNextWriteValue().get()); + + IntegerWriteChannel maxDischargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MAX_DISCHARGE_POWER); + assertEquals(Integer.valueOf(0), maxDischargeChannel.getNextWriteValue().get()); + + IntegerWriteChannel gridSetpointChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.GRID_POWER_SETPOINT); + assertEquals(Integer.valueOf(-800), gridSetpointChannel.getNextWriteValue().get()); + } + + /** + * Verifies that read-only mode suppresses all write-channel updates. + */ + @Test + public void testReadOnlyMode() throws Exception { + var ess = new EssSmaSunnyBoyStorageImpl(); + new ComponentTest(ess) // + .addReference("cm", new DummyConfigurationAdmin()) // + .addReference("power", new DummyPower()) // + .addReference("setModbus", new DummyModbusBridge("modbus0")) // + .activate(MyConfig.create().setReadOnlyMode(true).build()); + + ess.applyPower(1500, 0); + + IntegerWriteChannel gridSetpointChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.GRID_POWER_SETPOINT); + assertEquals(false, gridSetpointChannel.getNextWriteValue().isPresent()); + } +} diff --git a/io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma/sunnyboystorage/MyConfig.java b/io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma/sunnyboystorage/MyConfig.java new file mode 100644 index 00000000000..a87dd8528df --- /dev/null +++ b/io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma/sunnyboystorage/MyConfig.java @@ -0,0 +1,84 @@ +package io.openems.edge.ess.sma.sunnyboystorage; + +import io.openems.common.test.AbstractComponentConfig; +import io.openems.common.utils.ConfigUtils; + +@SuppressWarnings("all") +public class MyConfig extends AbstractComponentConfig implements Config { + + protected static class Builder { + private String id = "ess0"; + private String modbusId = "modbus0"; + private int modbusUnitId = 3; + private int capacity = 2000; + private boolean readOnlyMode = false; + + private Builder() { + } + + public Builder setId(String id) { + this.id = id; + return this; + } + + public Builder setModbusId(String modbusId) { + this.modbusId = modbusId; + return this; + } + + public Builder setModbusUnitId(int modbusUnitId) { + this.modbusUnitId = modbusUnitId; + return this; + } + + public Builder setCapacity(int capacity) { + this.capacity = capacity; + return this; + } + + public Builder setReadOnlyMode(boolean readOnlyMode) { + this.readOnlyMode = readOnlyMode; + return this; + } + + public MyConfig build() { + return new MyConfig(this); + } + } + + public static Builder create() { + return new Builder(); + } + + private final Builder builder; + + private MyConfig(Builder builder) { + super(Config.class, builder.id); + this.builder = builder; + } + + @Override + public String modbus_id() { + return this.builder.modbusId; + } + + @Override + public int modbusUnitId() { + return this.builder.modbusUnitId; + } + + @Override + public int capacity() { + return this.builder.capacity; + } + + @Override + public boolean readOnlyMode() { + return this.builder.readOnlyMode; + } + + @Override + public String Modbus_target() { + return ConfigUtils.generateReferenceTargetFilter(this.id(), this.modbus_id()); + } +} From 9908d7745f37c5a08e25c358d19df78bcbcf9614 Mon Sep 17 00:00:00 2001 From: Stefan Feilmeier Date: Mon, 25 May 2026 20:06:16 +0200 Subject: [PATCH 2/6] Apply tools/prepare-commit.sh --- .../.classpath | 37 +++---------------- .../.project | 15 +------- .../test/.gitignore | 0 3 files changed, 7 insertions(+), 45 deletions(-) create mode 100644 io.openems.edge.ess.sma.sunnyboystorage/test/.gitignore diff --git a/io.openems.edge.ess.sma.sunnyboystorage/.classpath b/io.openems.edge.ess.sma.sunnyboystorage/.classpath index 07205c33c53..b4cffd0fe60 100644 --- a/io.openems.edge.ess.sma.sunnyboystorage/.classpath +++ b/io.openems.edge.ess.sma.sunnyboystorage/.classpath @@ -1,39 +1,12 @@ - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/io.openems.edge.ess.sma.sunnyboystorage/.project b/io.openems.edge.ess.sma.sunnyboystorage/.project index c9a202e8ec9..3420f050140 100644 --- a/io.openems.edge.ess.sma.sunnyboystorage/.project +++ b/io.openems.edge.ess.sma.sunnyboystorage/.project @@ -11,24 +11,13 @@ - org.eclipse.buildship.core.gradleprojectbuilder + bndtools.core.bndbuilder org.eclipse.jdt.core.javanature - org.eclipse.buildship.core.gradleprojectnature + bndtools.core.bndnature - - - 1778534679763 - - 30 - - org.eclipse.core.resources.regexFilterMatcher - node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ - - - diff --git a/io.openems.edge.ess.sma.sunnyboystorage/test/.gitignore b/io.openems.edge.ess.sma.sunnyboystorage/test/.gitignore new file mode 100644 index 00000000000..e69de29bb2d From 1814a1aefefffd13a6b1d6cda28620023874b00f Mon Sep 17 00:00:00 2001 From: Christoph Date: Mon, 25 May 2026 22:24:34 +0200 Subject: [PATCH 3/6] Move SMA Sunny Boy Storage into SMA bundle --- io.openems.edge.application/EdgeApp.bndrun | 4 +- .../.classpath | 12 ----- .../.gitignore | 2 - .../.project | 23 --------- .../org.eclipse.core.resources.prefs | 2 - .../bnd.bnd | 15 ------ .../readme.adoc | 47 ------------------ io.openems.edge.sma/readme.adoc | 8 +++- .../edge/sma/ess}/sunnyboystorage/Config.java | 5 +- .../EssSmaSunnyBoyStorage.java | 2 +- .../EssSmaSunnyBoyStorageImpl.java | 48 +++++++++---------- .../EssSmaSunnyBoyStorageImplTest.java | 8 +--- .../sma/ess}/sunnyboystorage/MyConfig.java | 7 +-- 13 files changed, 36 insertions(+), 147 deletions(-) delete mode 100644 io.openems.edge.ess.sma.sunnyboystorage/.classpath delete mode 100644 io.openems.edge.ess.sma.sunnyboystorage/.gitignore delete mode 100644 io.openems.edge.ess.sma.sunnyboystorage/.project delete mode 100644 io.openems.edge.ess.sma.sunnyboystorage/.settings/org.eclipse.core.resources.prefs delete mode 100644 io.openems.edge.ess.sma.sunnyboystorage/bnd.bnd delete mode 100644 io.openems.edge.ess.sma.sunnyboystorage/readme.adoc rename {io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma => io.openems.edge.sma/src/io/openems/edge/sma/ess}/sunnyboystorage/Config.java (87%) rename {io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma => io.openems.edge.sma/src/io/openems/edge/sma/ess}/sunnyboystorage/EssSmaSunnyBoyStorage.java (98%) rename {io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma => io.openems.edge.sma/src/io/openems/edge/sma/ess}/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java (83%) rename {io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma => io.openems.edge.sma/test/io/openems/edge/sma/ess}/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java (93%) rename {io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma => io.openems.edge.sma/test/io/openems/edge/sma/ess}/sunnyboystorage/MyConfig.java (86%) diff --git a/io.openems.edge.application/EdgeApp.bndrun b/io.openems.edge.application/EdgeApp.bndrun index 209c39fd418..a3b92b85c0c 100644 --- a/io.openems.edge.application/EdgeApp.bndrun +++ b/io.openems.edge.application/EdgeApp.bndrun @@ -125,7 +125,6 @@ bnd.identity;id='io.openems.edge.ess.fenecon.commercial40',\ bnd.identity;id='io.openems.edge.ess.generic',\ bnd.identity;id='io.openems.edge.ess.samsung',\ - bnd.identity;id='io.openems.edge.ess.sma.sunnyboystorage',\ bnd.identity;id='io.openems.edge.evcs.abl',\ bnd.identity;id='io.openems.edge.evcs.cluster',\ bnd.identity;id='io.openems.edge.evcs.core',\ @@ -332,7 +331,6 @@ io.openems.edge.ess.fenecon.commercial40;version=snapshot,\ io.openems.edge.ess.generic;version=snapshot,\ io.openems.edge.ess.samsung;version=snapshot,\ - io.openems.edge.ess.sma.sunnyboystorage;version=snapshot,\ io.openems.edge.evcs.abl;version=snapshot,\ io.openems.edge.evcs.api;version=snapshot,\ io.openems.edge.evcs.cluster;version=snapshot,\ @@ -505,4 +503,4 @@ org.owasp.encoder;version='[1.4.0,1.4.1)',\ reactive-streams;version='[1.0.4,1.0.5)',\ rrd4j;version='[3.10.0,3.10.1)',\ - stax2-api;version='[4.3.0,4.3.1)' \ No newline at end of file + stax2-api;version='[4.3.0,4.3.1)' diff --git a/io.openems.edge.ess.sma.sunnyboystorage/.classpath b/io.openems.edge.ess.sma.sunnyboystorage/.classpath deleted file mode 100644 index b4cffd0fe60..00000000000 --- a/io.openems.edge.ess.sma.sunnyboystorage/.classpath +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/io.openems.edge.ess.sma.sunnyboystorage/.gitignore b/io.openems.edge.ess.sma.sunnyboystorage/.gitignore deleted file mode 100644 index c2b941a96de..00000000000 --- a/io.openems.edge.ess.sma.sunnyboystorage/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/bin_test/ -/generated/ diff --git a/io.openems.edge.ess.sma.sunnyboystorage/.project b/io.openems.edge.ess.sma.sunnyboystorage/.project deleted file mode 100644 index 3420f050140..00000000000 --- a/io.openems.edge.ess.sma.sunnyboystorage/.project +++ /dev/null @@ -1,23 +0,0 @@ - - - io.openems.edge.ess.sma.sunnyboystorage - - - - - - org.eclipse.jdt.core.javabuilder - - - - - bndtools.core.bndbuilder - - - - - - org.eclipse.jdt.core.javanature - bndtools.core.bndnature - - diff --git a/io.openems.edge.ess.sma.sunnyboystorage/.settings/org.eclipse.core.resources.prefs b/io.openems.edge.ess.sma.sunnyboystorage/.settings/org.eclipse.core.resources.prefs deleted file mode 100644 index 99f26c0203a..00000000000 --- a/io.openems.edge.ess.sma.sunnyboystorage/.settings/org.eclipse.core.resources.prefs +++ /dev/null @@ -1,2 +0,0 @@ -eclipse.preferences.version=1 -encoding/=UTF-8 diff --git a/io.openems.edge.ess.sma.sunnyboystorage/bnd.bnd b/io.openems.edge.ess.sma.sunnyboystorage/bnd.bnd deleted file mode 100644 index 1e6462d7a84..00000000000 --- a/io.openems.edge.ess.sma.sunnyboystorage/bnd.bnd +++ /dev/null @@ -1,15 +0,0 @@ -Bundle-Name: OpenEMS Edge ESS SMA Sunny Boy Storage -Bundle-Vendor: FENECON GmbH; OpenEMS Association e.V. -Bundle-License: https://opensource.org/licenses/EPL-2.0 -Bundle-Version: 1.0.0.${tstamp} - --buildpath: \ - ${buildpath},\ - io.openems.common,\ - io.openems.edge.bridge.modbus,\ - io.openems.edge.common,\ - io.openems.edge.ess.api,\ - io.openems.j2mod,\ - --testpath: \ - ${testpath} diff --git a/io.openems.edge.ess.sma.sunnyboystorage/readme.adoc b/io.openems.edge.ess.sma.sunnyboystorage/readme.adoc deleted file mode 100644 index 313241bd5df..00000000000 --- a/io.openems.edge.ess.sma.sunnyboystorage/readme.adoc +++ /dev/null @@ -1,47 +0,0 @@ -= SMA Sunny Boy Storage 2.5 - -== ESS - -Implements the SMA Sunny Boy Storage 2.5 (SBS 2.5) as a `ManagedSymmetricEss` via Modbus TCP. - -The inverter exposes read and write registers through the SMA BMS external control interface -(CmpBMS). All six control registers (BMS mode, min/max charge power, min/max discharge power, -grid power setpoint) must be written within every 60-second window or the device reverts to -autonomous mode. - -Tested on: - -* SMA Sunny Boy Storage 2.5 (firmware tested: 3.x) - -Implemented Natures: - -* ManagedSymmetricEss -* SymmetricEss - -=== Modbus Configuration - -* Protocol: Modbus TCP -* Default Unit-ID: 3 -* Default port: 502 - -=== Modbus Register Map - -NOTE: The SMA Sunny Boy Storage uses the SMA register number directly as the -0-based Modbus PDU address (non-standard; confirmed by live testing against firmware 3.x). - -[cols="1,1,1,3"] -|=== -|SMA Register (= PDU addr) |FC |Type |Description - -|30513 |FC3 |uint64 |Energy Total [Wh] -|30775 |FC4 |int32 |Battery Power / Active Power [W] -|30845 |FC3 |uint32 |State of Charge [%] -|40236 |FC16 |uint32 |BMS Mode (2424 = Normal, 2289 = Force Charge) -|40793 |FC16 |uint32 |Min Charge Power [W] -|40795 |FC16 |uint32 |Max Charge Power [W] -|40797 |FC16 |uint32 |Min Discharge Power [W] -|40799 |FC16 |uint32 |Max Discharge Power [W] -|40801 |FC16 |int32 |Grid Power Setpoint [W] -|=== - -https://github.com/OpenEMS/openems/tree/develop/io.openems.edge.ess.sma.sunnyboystorage[Source Code icon:github[]] diff --git a/io.openems.edge.sma/readme.adoc b/io.openems.edge.sma/readme.adoc index 2058912b660..55f130915f1 100644 --- a/io.openems.edge.sma/readme.adoc +++ b/io.openems.edge.sma/readme.adoc @@ -5,8 +5,14 @@ Tested on - SMA SunnyIsland 6.0H - Sunny Island 4.4M +- SMA Sunny Boy Storage 2.5 - SMA STP 10.0 Hybrid ESS. (The implementation is split into Battery, Inverter and DC Charger. The inverter only works with that battery, but the implementation had to be split up as the devices use different Modbus Unit IDs.) +The Sunny Boy Storage implementation uses the SMA BMS external control interface +(CmpBMS). The control registers for BMS mode, min/max charge power, +min/max discharge power and grid power setpoint must be written within every +60-second window or the device reverts to autonomous mode. + == Meter Tested on @@ -24,4 +30,4 @@ Tested on - https://www.sma.de/produkte/solar-wechselrichter/sunny-tripower-80-100.html[SMA SUNNY TRIPOWER 8.0 / 10.0 ] - https://www.sma.de/produkte/solar-wechselrichter/sunny-tripower-15000tl-20000tl-25000tl.html[SMA SUNNY TRIPOWER 15000TL / 20000TL / 25000TL] -https://github.com/OpenEMS/openems/tree/develop/io.openems.edge.sma[Source Code icon:github[]] \ No newline at end of file +https://github.com/OpenEMS/openems/tree/develop/io.openems.edge.sma[Source Code icon:github[]] diff --git a/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/Config.java b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/Config.java similarity index 87% rename from io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/Config.java rename to io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/Config.java index 108d7418827..f1d10c76d02 100644 --- a/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/Config.java +++ b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/Config.java @@ -1,4 +1,4 @@ -package io.openems.edge.ess.sma.sunnyboystorage; +package io.openems.edge.sma.ess.sunnyboystorage; import org.osgi.service.metatype.annotations.AttributeDefinition; import org.osgi.service.metatype.annotations.ObjectClassDefinition; @@ -29,8 +29,5 @@ @AttributeDefinition(name = "Capacity [Wh]", description = "Net usable capacity of the battery in Wh. SBS 2.5 = 2000 Wh usable") int capacity() default 2000; - @AttributeDefinition(name = "Modbus target filter", description = "This is auto-generated by 'Modbus-ID'.") - String Modbus_target() default "(enabled=true)"; - String webconsole_configurationFactory_nameHint() default "ESS SMA Sunny Boy Storage [{id}]"; } diff --git a/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorage.java b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorage.java similarity index 98% rename from io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorage.java rename to io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorage.java index c1338b4a45e..1aff18be267 100644 --- a/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorage.java +++ b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorage.java @@ -1,4 +1,4 @@ -package io.openems.edge.ess.sma.sunnyboystorage; +package io.openems.edge.sma.ess.sunnyboystorage; import io.openems.common.channel.AccessMode; import io.openems.common.channel.Unit; diff --git a/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java similarity index 83% rename from io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java rename to io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java index c438fffa674..2c588c872b1 100644 --- a/io.openems.edge.ess.sma.sunnyboystorage/src/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java +++ b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java @@ -1,20 +1,22 @@ -package io.openems.edge.ess.sma.sunnyboystorage; +package io.openems.edge.sma.ess.sunnyboystorage; + +import static io.openems.edge.common.channel.ChannelUtils.setValue; +import static org.osgi.service.component.annotations.ConfigurationPolicy.REQUIRE; +import static org.osgi.service.component.annotations.ReferenceCardinality.MANDATORY; +import static org.osgi.service.component.annotations.ReferencePolicy.STATIC; +import static org.osgi.service.component.annotations.ReferencePolicyOption.GREEDY; -import org.osgi.service.cm.ConfigurationAdmin; import org.osgi.service.component.ComponentContext; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; -import org.osgi.service.component.annotations.ConfigurationPolicy; import org.osgi.service.component.annotations.Deactivate; import org.osgi.service.component.annotations.Reference; -import org.osgi.service.component.annotations.ReferenceCardinality; -import org.osgi.service.component.annotations.ReferencePolicy; -import org.osgi.service.component.annotations.ReferencePolicyOption; import org.osgi.service.metatype.annotations.Designate; import io.openems.common.channel.AccessMode; import io.openems.common.exceptions.OpenemsError.OpenemsNamedException; import io.openems.common.exceptions.OpenemsException; +import io.openems.common.referencetarget.GenerateTargetsFromReferences; import io.openems.edge.bridge.modbus.api.AbstractOpenemsModbusComponent; import io.openems.edge.bridge.modbus.api.BridgeModbus; import io.openems.edge.bridge.modbus.api.ModbusComponent; @@ -24,7 +26,6 @@ import io.openems.edge.bridge.modbus.api.element.UnsignedQuadruplewordElement; import io.openems.edge.bridge.modbus.api.task.FC16WriteRegistersTask; import io.openems.edge.bridge.modbus.api.task.FC3ReadRegistersTask; -import io.openems.edge.bridge.modbus.api.task.FC4ReadInputRegistersTask; import io.openems.edge.common.channel.IntegerWriteChannel; import io.openems.edge.common.component.OpenemsComponent; import io.openems.edge.common.modbusslave.ModbusSlave; @@ -37,10 +38,11 @@ @Designate(ocd = Config.class, factory = true) @Component(// - name = "Ess.Sma.SunnyBoyStorage", // + name = "Ess.SMA.SunnyBoyStorage", // immediate = true, // - configurationPolicy = ConfigurationPolicy.REQUIRE // + configurationPolicy = REQUIRE // ) +@GenerateTargetsFromReferences("Modbus") public class EssSmaSunnyBoyStorageImpl extends AbstractOpenemsModbusComponent implements EssSmaSunnyBoyStorage, ManagedSymmetricEss, SymmetricEss, ModbusComponent, OpenemsComponent, ModbusSlave { @@ -53,13 +55,12 @@ public class EssSmaSunnyBoyStorageImpl extends AbstractOpenemsModbusComponent @Reference private Power power; - @Reference - private ConfigurationAdmin cm; - private Config config; @Override - @Reference(policy = ReferencePolicy.STATIC, policyOption = ReferencePolicyOption.GREEDY, cardinality = ReferenceCardinality.MANDATORY) + @Reference(// + policy = STATIC, policyOption = GREEDY, cardinality = MANDATORY, // + target = "(&(id=${config.modbus_id})(enabled=true))") protected void setModbus(BridgeModbus modbus) { super.setModbus(modbus); } @@ -72,19 +73,18 @@ public EssSmaSunnyBoyStorageImpl() { ManagedSymmetricEss.ChannelId.values(), // EssSmaSunnyBoyStorage.ChannelId.values() // ); - this._setMaxApparentPower(MAX_APPARENT_POWER); } @Activate private void activate(ComponentContext context, Config config) throws OpenemsException { - if (super.activate(context, config.id(), config.alias(), config.enabled(), config.modbusUnitId(), this.cm, - "Modbus", config.modbus_id())) { - return; - } this.config = config; - this._setCapacity(config.capacity()); - this.getAllowedChargePowerChannel().setNextValue(-MAX_APPARENT_POWER); - this._setAllowedDischargePower(MAX_APPARENT_POWER); + + super.activate(context, config.id(), config.alias(), config.enabled(), config.modbusUnitId()); + + setValue(this, SymmetricEss.ChannelId.MAX_APPARENT_POWER, MAX_APPARENT_POWER); + setValue(this, SymmetricEss.ChannelId.CAPACITY, config.capacity()); + setValue(this, ManagedSymmetricEss.ChannelId.ALLOWED_CHARGE_POWER, -MAX_APPARENT_POWER); + setValue(this, ManagedSymmetricEss.ChannelId.ALLOWED_DISCHARGE_POWER, MAX_APPARENT_POWER); } @Override @@ -129,7 +129,7 @@ protected ModbusProtocol defineModbusProtocol() { * 0-based Modbus PDU address (non-standard but confirmed by live testing). * * Reads: - * FC4 @ 30775 : Battery Power [W] int32 (2 words) + * FC3 @ 30775 : Battery Power [W] int32 (2 words) * FC3 @ 30513 : Energy Total [Wh] uint64 (4 words) * FC3 @ 30845 : State of Charge [%] uint32 (2 words) * @@ -142,13 +142,13 @@ protected ModbusProtocol defineModbusProtocol() { * FC16 @ 40801 : Grid Power Setpoint [W] int32 (CmpBMS.GridWSpt) */ return new ModbusProtocol(this, // - new FC4ReadInputRegistersTask(30775, Priority.HIGH, // + new FC3ReadRegistersTask(30775, Priority.HIGH, // m(SymmetricEss.ChannelId.ACTIVE_POWER, // new SignedDoublewordElement(30775))), // new FC3ReadRegistersTask(30513, Priority.LOW, // m(EssSmaSunnyBoyStorage.ChannelId.ENERGY_TOTAL, // new UnsignedQuadruplewordElement(30513))), // - new FC3ReadRegistersTask(30845, Priority.HIGH, // + new FC3ReadRegistersTask(30845, Priority.LOW, // m(SymmetricEss.ChannelId.SOC, // new UnsignedDoublewordElement(30845))), // new FC16WriteRegistersTask(40236, // diff --git a/io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java b/io.openems.edge.sma/test/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java similarity index 93% rename from io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java rename to io.openems.edge.sma/test/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java index a0082e014e2..8d463ba2b38 100644 --- a/io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java +++ b/io.openems.edge.sma/test/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java @@ -1,10 +1,9 @@ -package io.openems.edge.ess.sma.sunnyboystorage; +package io.openems.edge.sma.ess.sunnyboystorage; import static org.junit.Assert.assertEquals; import org.junit.Test; -import io.openems.common.test.DummyConfigurationAdmin; import io.openems.edge.bridge.modbus.test.DummyModbusBridge; import io.openems.edge.common.channel.IntegerWriteChannel; import io.openems.edge.common.test.ComponentTest; @@ -20,7 +19,6 @@ public class EssSmaSunnyBoyStorageImplTest { @Test public void testActivateDeactivate() throws Exception { new ComponentTest(new EssSmaSunnyBoyStorageImpl()) // - .addReference("cm", new DummyConfigurationAdmin()) // .addReference("power", new DummyPower()) // .addReference("setModbus", new DummyModbusBridge("modbus0")) // .activate(MyConfig.create() // @@ -39,7 +37,6 @@ public void testActivateDeactivate() throws Exception { public void testStaticLimits() throws Exception { var ess = new EssSmaSunnyBoyStorageImpl(); new ComponentTest(ess) // - .addReference("cm", new DummyConfigurationAdmin()) // .addReference("power", new DummyPower()) // .addReference("setModbus", new DummyModbusBridge("modbus0")) // .activate(MyConfig.create().build()); @@ -62,7 +59,6 @@ public void testStaticLimits() throws Exception { public void testApplyPowerDischarge() throws Exception { var ess = new EssSmaSunnyBoyStorageImpl(); new ComponentTest(ess) // - .addReference("cm", new DummyConfigurationAdmin()) // .addReference("power", new DummyPower()) // .addReference("setModbus", new DummyModbusBridge("modbus0")) // .activate(MyConfig.create().build()); @@ -96,7 +92,6 @@ public void testApplyPowerDischarge() throws Exception { public void testApplyPowerCharge() throws Exception { var ess = new EssSmaSunnyBoyStorageImpl(); new ComponentTest(ess) // - .addReference("cm", new DummyConfigurationAdmin()) // .addReference("power", new DummyPower()) // .addReference("setModbus", new DummyModbusBridge("modbus0")) // .activate(MyConfig.create().build()); @@ -126,7 +121,6 @@ public void testApplyPowerCharge() throws Exception { public void testReadOnlyMode() throws Exception { var ess = new EssSmaSunnyBoyStorageImpl(); new ComponentTest(ess) // - .addReference("cm", new DummyConfigurationAdmin()) // .addReference("power", new DummyPower()) // .addReference("setModbus", new DummyModbusBridge("modbus0")) // .activate(MyConfig.create().setReadOnlyMode(true).build()); diff --git a/io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma/sunnyboystorage/MyConfig.java b/io.openems.edge.sma/test/io/openems/edge/sma/ess/sunnyboystorage/MyConfig.java similarity index 86% rename from io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma/sunnyboystorage/MyConfig.java rename to io.openems.edge.sma/test/io/openems/edge/sma/ess/sunnyboystorage/MyConfig.java index a87dd8528df..b7f4027ea86 100644 --- a/io.openems.edge.ess.sma.sunnyboystorage/test/io/openems/edge/ess/sma/sunnyboystorage/MyConfig.java +++ b/io.openems.edge.sma/test/io/openems/edge/sma/ess/sunnyboystorage/MyConfig.java @@ -1,7 +1,6 @@ -package io.openems.edge.ess.sma.sunnyboystorage; +package io.openems.edge.sma.ess.sunnyboystorage; import io.openems.common.test.AbstractComponentConfig; -import io.openems.common.utils.ConfigUtils; @SuppressWarnings("all") public class MyConfig extends AbstractComponentConfig implements Config { @@ -77,8 +76,4 @@ public boolean readOnlyMode() { return this.builder.readOnlyMode; } - @Override - public String Modbus_target() { - return ConfigUtils.generateReferenceTargetFilter(this.id(), this.modbus_id()); - } } From c5f8624153ef04b3ddd2dfa094ae3033804677bd Mon Sep 17 00:00:00 2001 From: Christoph Date: Thu, 28 May 2026 16:14:52 +0200 Subject: [PATCH 4/6] Ess.SMA.SunnyBoyStorage: fix GridMode, applyPower logic and live power limits - Set GRID_MODE=ON_GRID on activate() to prevent cluster GridMode=UNDEFINED - Set initial ALLOWED_CHARGE/DISCHARGE_POWER defaults (-2500/+2500 W) on activate() - Read live ALLOWED_CHARGE/DISCHARGE_POWER from registers 40189/40191 at Priority.LOW - Rewrite applyPower(): use BMS_MODE 2289/2290/2424 with exact min=max power windows; standby (0 W) locks device at 0 W instead of allowing autonomous charging - Add ENERGY_TOTAL channel (register 30513) and debugLog with SoC, power, limits, GridMode - Add unit tests for static limits, discharge, charge, standby and read-only mode Co-Authored-By: Claude Sonnet 4.6 --- .../EssSmaSunnyBoyStorage.java | 16 +++-- .../EssSmaSunnyBoyStorageImpl.java | 68 +++++++++++++++---- .../EssSmaSunnyBoyStorageImplTest.java | 50 ++++++++++++-- 3 files changed, 109 insertions(+), 25 deletions(-) diff --git a/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorage.java b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorage.java index 1aff18be267..4521c9e4396 100644 --- a/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorage.java +++ b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorage.java @@ -23,11 +23,13 @@ public enum ChannelId implements io.openems.edge.common.channel.ChannelId { // --- Write channels (mapped to Modbus holding registers 40xxx via FC16) --- /** - * BMS operating mode. + * BMS operating mode (CmpBMS register 40236). * *
    *
  • Register 40236, uint32, FC16 - *
  • 2424 = Normal, 2289 = Force charge + *
  • 2289 = Charge battery + *
  • 2290 = Discharge battery + *
  • 2424 = Presetting (self-consumption, internal BMS) *
*/ BMS_MODE(Doc.of(OpenemsType.INTEGER) // @@ -83,10 +85,12 @@ public enum ChannelId implements io.openems.edge.common.channel.ChannelId { .accessMode(AccessMode.WRITE_ONLY)), // /** - * Grid power setpoint (CmpBMS.GridWSpt). Positive = discharge (ESS feeds - * grid/load), negative = charge (grid/PV charges ESS). Matches OpenEMS - * ACTIVE_POWER sign convention and verified against the EVCC SBS 2.5 - * implementation. + * Grid power setpoint (CmpBMS.GridWSpt). + * + *

+ * SMA sign convention: positive = import from grid (charging), negative = + * export to grid (discharging). This is the inverse of the OpenEMS + * ACTIVE_POWER convention; write {@code -activePower}. * *

    *
  • Register 40801, int32, FC16 diff --git a/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java index 2c588c872b1..39dfa39c186 100644 --- a/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java +++ b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java @@ -1,5 +1,6 @@ package io.openems.edge.sma.ess.sunnyboystorage; +import static io.openems.edge.bridge.modbus.api.ElementToChannelConverter.INVERT; import static io.openems.edge.common.channel.ChannelUtils.setValue; import static org.osgi.service.component.annotations.ConfigurationPolicy.REQUIRE; import static org.osgi.service.component.annotations.ReferenceCardinality.MANDATORY; @@ -31,6 +32,7 @@ import io.openems.edge.common.modbusslave.ModbusSlave; import io.openems.edge.common.modbusslave.ModbusSlaveNatureTable; import io.openems.edge.common.modbusslave.ModbusSlaveTable; +import io.openems.edge.common.sum.GridMode; import io.openems.edge.common.taskmanager.Priority; import io.openems.edge.ess.api.ManagedSymmetricEss; import io.openems.edge.ess.api.SymmetricEss; @@ -85,6 +87,7 @@ private void activate(ComponentContext context, Config config) throws OpenemsExc setValue(this, SymmetricEss.ChannelId.CAPACITY, config.capacity()); setValue(this, ManagedSymmetricEss.ChannelId.ALLOWED_CHARGE_POWER, -MAX_APPARENT_POWER); setValue(this, ManagedSymmetricEss.ChannelId.ALLOWED_DISCHARGE_POWER, MAX_APPARENT_POWER); + setValue(this, SymmetricEss.ChannelId.GRID_MODE, GridMode.ON_GRID); } @Override @@ -99,27 +102,57 @@ public void applyPower(int activePower, int reactivePower) throws OpenemsNamedEx return; } + // 2289=Charge battery, 2290=Discharge battery, 2424=Presetting (self-consumption) + int bmsMode = activePower < 0 ? 2289 : (activePower > 0 ? 2290 : 2424); IntegerWriteChannel bmsModeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.BMS_MODE); - bmsModeChannel.setNextWriteValue(2424); - - int chargePower = activePower < 0 ? Math.abs(activePower) : 0; - int dischargePower = activePower > 0 ? activePower : 0; + bmsModeChannel.setNextWriteValue(bmsMode); + + // For forced charge/discharge set min=max to lock in the exact power rate. + // For self-consumption (activePower=0) set min=0, max=MAX so the internal BMS + // can freely manage the battery. + final int minCharge; + final int maxCharge; + final int minDischarge; + final int maxDischarge; + if (activePower < 0) { + int absPower = Math.abs(activePower); + minCharge = absPower; + maxCharge = absPower; + minDischarge = 0; + maxDischarge = 0; + } else if (activePower > 0) { + minCharge = 0; + maxCharge = 0; + minDischarge = activePower; + maxDischarge = activePower; + } else { + // Solver commanded 0 W: hold battery neutral. + // BMS_MODE=2424 (Presetting) with all limits=0 locks the device at 0 W, + // matching EVCC's "Hold" mode. The balancing controller sends explicit + // charge/discharge commands every cycle when actual power is needed. + minCharge = 0; + maxCharge = 0; + minDischarge = 0; + maxDischarge = 0; + } // All 6 CmpBMS registers must be refreshed within every 60 s window IntegerWriteChannel minChargeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.MIN_CHARGE_POWER); - minChargeChannel.setNextWriteValue(0); + minChargeChannel.setNextWriteValue(minCharge); IntegerWriteChannel maxChargeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.MAX_CHARGE_POWER); - maxChargeChannel.setNextWriteValue(chargePower); + maxChargeChannel.setNextWriteValue(maxCharge); IntegerWriteChannel minDischargeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.MIN_DISCHARGE_POWER); - minDischargeChannel.setNextWriteValue(0); + minDischargeChannel.setNextWriteValue(minDischarge); IntegerWriteChannel maxDischargeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.MAX_DISCHARGE_POWER); - maxDischargeChannel.setNextWriteValue(dischargePower); + maxDischargeChannel.setNextWriteValue(maxDischarge); + // GridWSpt=0: let the inverter manage the grid/PV split internally. + // Min/max charge-power registers already lock in the required power rate. IntegerWriteChannel gridSetpointChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.GRID_POWER_SETPOINT); - gridSetpointChannel.setNextWriteValue(activePower); + gridSetpointChannel.setNextWriteValue(0); } @Override @@ -134,12 +167,17 @@ protected ModbusProtocol defineModbusProtocol() { * FC3 @ 30845 : State of Charge [%] uint32 (2 words) * * Writes (all 6 must be refreshed within every 60 s window): - * FC16 @ 40236 : BMS Mode uint32 (2424=Normal, 2289=ForceCharge) + * FC16 @ 40236 : BMS Mode uint32 (2289=Charge, 2290=Discharge, 2424=Presetting) * FC16 @ 40793 : Min Charge Power [W] uint32 (CmpBMS.BatChaMinW) * FC16 @ 40795 : Max Charge Power [W] uint32 (CmpBMS.BatChaMaxW) * FC16 @ 40797 : Min Discharge Power [W] uint32 (CmpBMS.BatDschMinW) * FC16 @ 40799 : Max Discharge Power [W] uint32 (CmpBMS.BatDschMaxW) - * FC16 @ 40801 : Grid Power Setpoint [W] int32 (CmpBMS.GridWSpt) + * FC16 @ 40801 : Grid Power Setpoint [W] int32 (CmpBMS.GridWSpt, positive=import/charge) + * + * Power limits (live from device, same register layout as SunnyIsland): + * FC3 @ 40189 : Allowed Charge Power [W] uint32 (INVERT → negative) + * FC3 @ 40191 : Allowed Discharge Power [W] uint32 + * Priority.LOW: initial safe defaults (-2500/+2500) set in activate(); these refine over time. */ return new ModbusProtocol(this, // new FC3ReadRegistersTask(30775, Priority.HIGH, // @@ -151,6 +189,11 @@ protected ModbusProtocol defineModbusProtocol() { new FC3ReadRegistersTask(30845, Priority.LOW, // m(SymmetricEss.ChannelId.SOC, // new UnsignedDoublewordElement(30845))), // + new FC3ReadRegistersTask(40189, Priority.LOW, // + m(ManagedSymmetricEss.ChannelId.ALLOWED_CHARGE_POWER, // + new UnsignedDoublewordElement(40189), INVERT), // + m(ManagedSymmetricEss.ChannelId.ALLOWED_DISCHARGE_POWER, // + new UnsignedDoublewordElement(40191))), // new FC16WriteRegistersTask(40236, // m(EssSmaSunnyBoyStorage.ChannelId.BMS_MODE, // new UnsignedDoublewordElement(40236))), // @@ -174,7 +217,8 @@ public String debugLog() { return "SoC:" + this.getSoc().asString() // + "|P:" + this.getActivePower().asString() // + "|AllowedCharge:" + this.getAllowedChargePower().asString() // - + "|AllowedDischarge:" + this.getAllowedDischargePower().asString(); + + "|AllowedDischarge:" + this.getAllowedDischargePower().asString() // + + "|" + this.getGridModeChannel().value().asOptionString(); } @Override diff --git a/io.openems.edge.sma/test/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java b/io.openems.edge.sma/test/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java index 8d463ba2b38..81de679e86d 100644 --- a/io.openems.edge.sma/test/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java +++ b/io.openems.edge.sma/test/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java @@ -53,7 +53,7 @@ public void testStaticLimits() throws Exception { /** * Verifies that applyPower() sets the correct write-channel values for - * discharge (activePower > 0). + * discharge (activePower > 0). min=max=activePower forces the exact power rate. */ @Test public void testApplyPowerDischarge() throws Exception { @@ -66,7 +66,7 @@ public void testApplyPowerDischarge() throws Exception { ess.applyPower(1000, 0); IntegerWriteChannel bmsModeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.BMS_MODE); - assertEquals(Integer.valueOf(2424), bmsModeChannel.getNextWriteValue().get()); + assertEquals(Integer.valueOf(2290), bmsModeChannel.getNextWriteValue().get()); IntegerWriteChannel minChargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MIN_CHARGE_POWER); assertEquals(Integer.valueOf(0), minChargeChannel.getNextWriteValue().get()); @@ -75,18 +75,18 @@ public void testApplyPowerDischarge() throws Exception { assertEquals(Integer.valueOf(0), maxChargeChannel.getNextWriteValue().get()); IntegerWriteChannel minDischargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MIN_DISCHARGE_POWER); - assertEquals(Integer.valueOf(0), minDischargeChannel.getNextWriteValue().get()); + assertEquals(Integer.valueOf(1000), minDischargeChannel.getNextWriteValue().get()); IntegerWriteChannel maxDischargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MAX_DISCHARGE_POWER); assertEquals(Integer.valueOf(1000), maxDischargeChannel.getNextWriteValue().get()); IntegerWriteChannel gridSetpointChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.GRID_POWER_SETPOINT); - assertEquals(Integer.valueOf(1000), gridSetpointChannel.getNextWriteValue().get()); + assertEquals(Integer.valueOf(0), gridSetpointChannel.getNextWriteValue().get()); } /** * Verifies that applyPower() sets the correct write-channel values for - * charge (activePower < 0). + * charge (activePower < 0). min=max=abs(activePower) forces the exact rate. */ @Test public void testApplyPowerCharge() throws Exception { @@ -98,8 +98,11 @@ public void testApplyPowerCharge() throws Exception { ess.applyPower(-800, 0); + IntegerWriteChannel bmsModeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.BMS_MODE); + assertEquals(Integer.valueOf(2289), bmsModeChannel.getNextWriteValue().get()); + IntegerWriteChannel minChargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MIN_CHARGE_POWER); - assertEquals(Integer.valueOf(0), minChargeChannel.getNextWriteValue().get()); + assertEquals(Integer.valueOf(800), minChargeChannel.getNextWriteValue().get()); IntegerWriteChannel maxChargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MAX_CHARGE_POWER); assertEquals(Integer.valueOf(800), maxChargeChannel.getNextWriteValue().get()); @@ -111,7 +114,40 @@ public void testApplyPowerCharge() throws Exception { assertEquals(Integer.valueOf(0), maxDischargeChannel.getNextWriteValue().get()); IntegerWriteChannel gridSetpointChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.GRID_POWER_SETPOINT); - assertEquals(Integer.valueOf(-800), gridSetpointChannel.getNextWriteValue().get()); + assertEquals(Integer.valueOf(0), gridSetpointChannel.getNextWriteValue().get()); + } + + /** + * Verifies that applyPower(0) sets BMS mode to Presetting (self-consumption) + * and allows full charge/discharge range for internal BMS control. + */ + @Test + public void testApplyPowerStandby() throws Exception { + var ess = new EssSmaSunnyBoyStorageImpl(); + new ComponentTest(ess) // + .addReference("power", new DummyPower()) // + .addReference("setModbus", new DummyModbusBridge("modbus0")) // + .activate(MyConfig.create().build()); + + ess.applyPower(0, 0); + + IntegerWriteChannel bmsModeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.BMS_MODE); + assertEquals(Integer.valueOf(2424), bmsModeChannel.getNextWriteValue().get()); + + IntegerWriteChannel minChargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MIN_CHARGE_POWER); + assertEquals(Integer.valueOf(0), minChargeChannel.getNextWriteValue().get()); + + IntegerWriteChannel maxChargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MAX_CHARGE_POWER); + assertEquals(Integer.valueOf(0), maxChargeChannel.getNextWriteValue().get()); + + IntegerWriteChannel minDischargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MIN_DISCHARGE_POWER); + assertEquals(Integer.valueOf(0), minDischargeChannel.getNextWriteValue().get()); + + IntegerWriteChannel maxDischargeChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.MAX_DISCHARGE_POWER); + assertEquals(Integer.valueOf(0), maxDischargeChannel.getNextWriteValue().get()); + + IntegerWriteChannel gridSetpointChannel = ess.channel(EssSmaSunnyBoyStorage.ChannelId.GRID_POWER_SETPOINT); + assertEquals(Integer.valueOf(0), gridSetpointChannel.getNextWriteValue().get()); } /** From fef79b29398190e94983cae156583d44ae474309 Mon Sep 17 00:00:00 2001 From: Christoph Date: Tue, 23 Jun 2026 16:56:35 +0200 Subject: [PATCH 5/6] fix(sma-sbs): insert Presetting cycle on charge/discharge direction change --- .../EssSmaSunnyBoyStorageImpl.java | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java index 39dfa39c186..3ee7ec3eb02 100644 --- a/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java +++ b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java @@ -54,6 +54,13 @@ public class EssSmaSunnyBoyStorageImpl extends AbstractOpenemsModbusComponent */ private static final int MAX_APPARENT_POWER = 2500; + /** + * Last BMS_MODE written. Used to detect direction changes (charge↔discharge) + * that require a Presetting (2424) intermediate cycle before the SBS accepts + * the new mode. + */ + private int lastBmsMode = 2424; + @Reference private Power power; @@ -103,7 +110,18 @@ public void applyPower(int activePower, int reactivePower) throws OpenemsNamedEx } // 2289=Charge battery, 2290=Discharge battery, 2424=Presetting (self-consumption) - int bmsMode = activePower < 0 ? 2289 : (activePower > 0 ? 2290 : 2424); + int targetBmsMode = activePower < 0 ? 2289 : (activePower > 0 ? 2290 : 2424); + + // SMA CmpBMS requires a Presetting (2424) intermediate cycle when switching + // between charge (2289) and discharge (2290). Jumping directly 2289↔2290 + // causes the device to ignore the new command and continue on the old one + // until the 60 s watchdog expires. + boolean directionChange = (this.lastBmsMode == 2289 && targetBmsMode == 2290) + || (this.lastBmsMode == 2290 && targetBmsMode == 2289); + int bmsMode = directionChange ? 2424 : targetBmsMode; + int effectivePower = directionChange ? 0 : activePower; + this.lastBmsMode = bmsMode; + IntegerWriteChannel bmsModeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.BMS_MODE); bmsModeChannel.setNextWriteValue(bmsMode); @@ -114,22 +132,20 @@ public void applyPower(int activePower, int reactivePower) throws OpenemsNamedEx final int maxCharge; final int minDischarge; final int maxDischarge; - if (activePower < 0) { - int absPower = Math.abs(activePower); + if (effectivePower < 0) { + int absPower = Math.abs(effectivePower); minCharge = absPower; maxCharge = absPower; minDischarge = 0; maxDischarge = 0; - } else if (activePower > 0) { + } else if (effectivePower > 0) { minCharge = 0; maxCharge = 0; - minDischarge = activePower; - maxDischarge = activePower; + minDischarge = effectivePower; + maxDischarge = effectivePower; } else { - // Solver commanded 0 W: hold battery neutral. - // BMS_MODE=2424 (Presetting) with all limits=0 locks the device at 0 W, - // matching EVCC's "Hold" mode. The balancing controller sends explicit - // charge/discharge commands every cycle when actual power is needed. + // Solver commanded 0 W (or direction-change intermediate): hold battery neutral. + // BMS_MODE=2424 (Presetting) with all limits=0 locks the device at 0 W. minCharge = 0; maxCharge = 0; minDischarge = 0; From 616863dd15e5d1c589545a5ca5e508b789507990 Mon Sep 17 00:00:00 2001 From: Christoph Date: Wed, 24 Jun 2026 09:02:06 +0200 Subject: [PATCH 6/6] Revert "fix(sma-sbs): insert Presetting cycle on charge/discharge direction change" This reverts commit fef79b29398190e94983cae156583d44ae474309. --- .../EssSmaSunnyBoyStorageImpl.java | 36 ++++++------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java index 3ee7ec3eb02..39dfa39c186 100644 --- a/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java +++ b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java @@ -54,13 +54,6 @@ public class EssSmaSunnyBoyStorageImpl extends AbstractOpenemsModbusComponent */ private static final int MAX_APPARENT_POWER = 2500; - /** - * Last BMS_MODE written. Used to detect direction changes (charge↔discharge) - * that require a Presetting (2424) intermediate cycle before the SBS accepts - * the new mode. - */ - private int lastBmsMode = 2424; - @Reference private Power power; @@ -110,18 +103,7 @@ public void applyPower(int activePower, int reactivePower) throws OpenemsNamedEx } // 2289=Charge battery, 2290=Discharge battery, 2424=Presetting (self-consumption) - int targetBmsMode = activePower < 0 ? 2289 : (activePower > 0 ? 2290 : 2424); - - // SMA CmpBMS requires a Presetting (2424) intermediate cycle when switching - // between charge (2289) and discharge (2290). Jumping directly 2289↔2290 - // causes the device to ignore the new command and continue on the old one - // until the 60 s watchdog expires. - boolean directionChange = (this.lastBmsMode == 2289 && targetBmsMode == 2290) - || (this.lastBmsMode == 2290 && targetBmsMode == 2289); - int bmsMode = directionChange ? 2424 : targetBmsMode; - int effectivePower = directionChange ? 0 : activePower; - this.lastBmsMode = bmsMode; - + int bmsMode = activePower < 0 ? 2289 : (activePower > 0 ? 2290 : 2424); IntegerWriteChannel bmsModeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.BMS_MODE); bmsModeChannel.setNextWriteValue(bmsMode); @@ -132,20 +114,22 @@ public void applyPower(int activePower, int reactivePower) throws OpenemsNamedEx final int maxCharge; final int minDischarge; final int maxDischarge; - if (effectivePower < 0) { - int absPower = Math.abs(effectivePower); + if (activePower < 0) { + int absPower = Math.abs(activePower); minCharge = absPower; maxCharge = absPower; minDischarge = 0; maxDischarge = 0; - } else if (effectivePower > 0) { + } else if (activePower > 0) { minCharge = 0; maxCharge = 0; - minDischarge = effectivePower; - maxDischarge = effectivePower; + minDischarge = activePower; + maxDischarge = activePower; } else { - // Solver commanded 0 W (or direction-change intermediate): hold battery neutral. - // BMS_MODE=2424 (Presetting) with all limits=0 locks the device at 0 W. + // Solver commanded 0 W: hold battery neutral. + // BMS_MODE=2424 (Presetting) with all limits=0 locks the device at 0 W, + // matching EVCC's "Hold" mode. The balancing controller sends explicit + // charge/discharge commands every cycle when actual power is needed. minCharge = 0; maxCharge = 0; minDischarge = 0;