diff --git a/io.openems.edge.application/EdgeApp.bndrun b/io.openems.edge.application/EdgeApp.bndrun
index 5f7dcab0e42..a3b92b85c0c 100644
--- a/io.openems.edge.application/EdgeApp.bndrun
+++ b/io.openems.edge.application/EdgeApp.bndrun
@@ -503,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.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 super SmaEssSunnyBoyStorage, ? super Property, ? super BundleParameter> def;
+
+ private Property(AppDef super SmaEssSunnyBoyStorage, ? super Property, ? super BundleParameter> def) {
+ this.def = def;
+ }
+
+ @Override
+ public Property self() {
+ return this;
+ }
+
+ @Override
+ public AppDef super SmaEssSunnyBoyStorage, ? super Property, ? super BundleParameter> 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/test/.gitignore b/io.openems.edge.ess.sma.sunnyboystorage/test/.gitignore
new file mode 100644
index 00000000000..e69de29bb2d
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.sma/src/io/openems/edge/sma/ess/sunnyboystorage/Config.java b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/Config.java
new file mode 100644
index 00000000000..f1d10c76d02
--- /dev/null
+++ b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/Config.java
@@ -0,0 +1,33 @@
+package io.openems.edge.sma.ess.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;
+
+ String webconsole_configurationFactory_nameHint() default "ESS SMA Sunny Boy Storage [{id}]";
+}
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
new file mode 100644
index 00000000000..4521c9e4396
--- /dev/null
+++ b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorage.java
@@ -0,0 +1,115 @@
+package io.openems.edge.sma.ess.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 (CmpBMS register 40236).
+ *
+ *
+ * - Register 40236, uint32, FC16
+ *
- 2289 = Charge battery
+ *
- 2290 = Discharge battery
+ *
- 2424 = Presetting (self-consumption, internal BMS)
+ *
+ */
+ 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).
+ *
+ *
+ * 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
+ *
- 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.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java
new file mode 100644
index 00000000000..39dfa39c186
--- /dev/null
+++ b/io.openems.edge.sma/src/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImpl.java
@@ -0,0 +1,243 @@
+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;
+import static org.osgi.service.component.annotations.ReferencePolicy.STATIC;
+import static org.osgi.service.component.annotations.ReferencePolicyOption.GREEDY;
+
+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.Deactivate;
+import org.osgi.service.component.annotations.Reference;
+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;
+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.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.sum.GridMode;
+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 = REQUIRE //
+)
+@GenerateTargetsFromReferences("Modbus")
+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;
+
+ private Config config;
+
+ @Override
+ @Reference(//
+ policy = STATIC, policyOption = GREEDY, cardinality = MANDATORY, //
+ target = "(&(id=${config.modbus_id})(enabled=true))")
+ 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() //
+ );
+ }
+
+ @Activate
+ private void activate(ComponentContext context, Config config) throws OpenemsException {
+ this.config = config;
+
+ 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);
+ setValue(this, SymmetricEss.ChannelId.GRID_MODE, GridMode.ON_GRID);
+ }
+
+ @Override
+ @Deactivate
+ protected void deactivate() {
+ super.deactivate();
+ }
+
+ @Override
+ public void applyPower(int activePower, int reactivePower) throws OpenemsNamedException {
+ if (this.config.readOnlyMode()) {
+ 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(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(minCharge);
+
+ IntegerWriteChannel maxChargeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.MAX_CHARGE_POWER);
+ maxChargeChannel.setNextWriteValue(maxCharge);
+
+ IntegerWriteChannel minDischargeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.MIN_DISCHARGE_POWER);
+ minDischargeChannel.setNextWriteValue(minDischarge);
+
+ IntegerWriteChannel maxDischargeChannel = this.channel(EssSmaSunnyBoyStorage.ChannelId.MAX_DISCHARGE_POWER);
+ 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(0);
+ }
+
+ @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:
+ * FC3 @ 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 (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, 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, //
+ 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.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))), //
+ 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() //
+ + "|" + this.getGridModeChannel().value().asOptionString();
+ }
+
+ @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.sma/test/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java b/io.openems.edge.sma/test/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java
new file mode 100644
index 00000000000..81de679e86d
--- /dev/null
+++ b/io.openems.edge.sma/test/io/openems/edge/sma/ess/sunnyboystorage/EssSmaSunnyBoyStorageImplTest.java
@@ -0,0 +1,169 @@
+package io.openems.edge.sma.ess.sunnyboystorage;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+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("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("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). min=max=activePower forces the exact power rate.
+ */
+ @Test
+ public void testApplyPowerDischarge() throws Exception {
+ var ess = new EssSmaSunnyBoyStorageImpl();
+ new ComponentTest(ess) //
+ .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(2290), 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(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(0), gridSetpointChannel.getNextWriteValue().get());
+ }
+
+ /**
+ * Verifies that applyPower() sets the correct write-channel values for
+ * charge (activePower < 0). min=max=abs(activePower) forces the exact rate.
+ */
+ @Test
+ public void testApplyPowerCharge() throws Exception {
+ var ess = new EssSmaSunnyBoyStorageImpl();
+ new ComponentTest(ess) //
+ .addReference("power", new DummyPower()) //
+ .addReference("setModbus", new DummyModbusBridge("modbus0")) //
+ .activate(MyConfig.create().build());
+
+ 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(800), 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(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());
+ }
+
+ /**
+ * Verifies that read-only mode suppresses all write-channel updates.
+ */
+ @Test
+ public void testReadOnlyMode() throws Exception {
+ var ess = new EssSmaSunnyBoyStorageImpl();
+ new ComponentTest(ess) //
+ .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.sma/test/io/openems/edge/sma/ess/sunnyboystorage/MyConfig.java b/io.openems.edge.sma/test/io/openems/edge/sma/ess/sunnyboystorage/MyConfig.java
new file mode 100644
index 00000000000..b7f4027ea86
--- /dev/null
+++ b/io.openems.edge.sma/test/io/openems/edge/sma/ess/sunnyboystorage/MyConfig.java
@@ -0,0 +1,79 @@
+package io.openems.edge.sma.ess.sunnyboystorage;
+
+import io.openems.common.test.AbstractComponentConfig;
+
+@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;
+ }
+
+}