diff --git a/io.openems.edge.io.shelly/bnd.bnd b/io.openems.edge.io.shelly/bnd.bnd
index 483698b6a43..916d14d623c 100644
--- a/io.openems.edge.io.shelly/bnd.bnd
+++ b/io.openems.edge.io.shelly/bnd.bnd
@@ -10,6 +10,7 @@ Bundle-Version: 1.0.0.${tstamp}
io.openems.edge.common,\
io.openems.edge.io.api,\
io.openems.edge.meter.api,\
+ io.openems.edge.timedata.api,\
-testpath: \
${testpath}
diff --git a/io.openems.edge.io.shelly/src/io/openems/edge/io/shelly/shelly3em/Config.java b/io.openems.edge.io.shelly/src/io/openems/edge/io/shelly/shelly3em/Config.java
new file mode 100644
index 00000000000..5ff09dac9e0
--- /dev/null
+++ b/io.openems.edge.io.shelly/src/io/openems/edge/io/shelly/shelly3em/Config.java
@@ -0,0 +1,29 @@
+package io.openems.edge.io.shelly.shelly3em;
+
+import org.osgi.service.metatype.annotations.AttributeDefinition;
+import org.osgi.service.metatype.annotations.ObjectClassDefinition;
+
+import io.openems.edge.meter.api.MeterType;
+
+@ObjectClassDefinition(//
+ name = "IO Shelly 3EM", //
+ description = "Implements the Shelly 3EM")
+@interface Config {
+
+ @AttributeDefinition(name = "Component-ID", description = "Unique ID of this Component")
+ String id() default "io0";
+
+ @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 = "IP-Address", description = "The IP address of the Shelly device.")
+ String ip();
+
+ @AttributeDefinition(name = "Meter-Type", description = "What is measured by this Meter?")
+ MeterType type() default MeterType.CONSUMPTION_METERED;
+
+ String webconsole_configurationFactory_nameHint() default "IO Shelly 3EM [{id}]";
+}
\ No newline at end of file
diff --git a/io.openems.edge.io.shelly/src/io/openems/edge/io/shelly/shelly3em/IoShelly3Em.java b/io.openems.edge.io.shelly/src/io/openems/edge/io/shelly/shelly3em/IoShelly3Em.java
new file mode 100644
index 00000000000..09926e3a921
--- /dev/null
+++ b/io.openems.edge.io.shelly/src/io/openems/edge/io/shelly/shelly3em/IoShelly3Em.java
@@ -0,0 +1,197 @@
+package io.openems.edge.io.shelly.shelly3em;
+
+import org.osgi.service.event.EventHandler;
+
+import io.openems.common.channel.AccessMode;
+import io.openems.common.channel.Level;
+import io.openems.common.exceptions.OpenemsError.OpenemsNamedException;
+import io.openems.common.types.OpenemsType;
+import io.openems.edge.common.channel.BooleanDoc;
+import io.openems.edge.common.channel.BooleanWriteChannel;
+import io.openems.edge.common.channel.Doc;
+import io.openems.edge.common.channel.StateChannel;
+import io.openems.edge.common.channel.value.Value;
+import io.openems.edge.common.component.OpenemsComponent;
+import io.openems.edge.io.api.DigitalOutput;
+import io.openems.edge.meter.api.ElectricityMeter;
+
+public interface IoShelly3Em extends DigitalOutput, ElectricityMeter, OpenemsComponent, EventHandler {
+
+ public static enum ChannelId implements io.openems.edge.common.channel.ChannelId {
+ /**
+ * Holds writes to Relay Output for debugging.
+ *
+ *
+ * - Interface: ShellyPlug
+ *
- Type: Boolean
+ *
- Range: On/Off
+ *
+ */
+ DEBUG_RELAY(Doc.of(OpenemsType.BOOLEAN)), //
+ /**
+ * Relay Output.
+ *
+ *
+ * - Interface: ShellyPlug
+ *
- Type: Boolean
+ *
- Range: On/Off
+ *
+ */
+ RELAY(new BooleanDoc() //
+ .accessMode(AccessMode.READ_WRITE) //
+ .onChannelSetNextWriteMirrorToDebugChannel(ChannelId.DEBUG_RELAY)),
+ /**
+ * Indicates if an update is available.
+ *
+ *
+ * - Interface: ShellyPlug
+ *
- Type: Boolean
+ *
- Level: INFO
+ *
+ */
+ HAS_UPDATE(Doc.of(Level.INFO) //
+ .text("A new Firmware Update is available.")),
+ /**
+ * Indicates whether the associated meter is functioning properly.
+ *
+ *
+ * - Interface: ShellyPlug
+ *
- Type: Boolean
+ *
- Level: WARN
+ *
+ */
+ EMETER1_EXCEPTION(Doc.of(Level.WARNING) //
+ .text("E-Meter Phase 1 is not valid.")),
+ /**
+ * Indicates whether the associated meter is functioning properly.
+ *
+ *
+ * - Interface: ShellyPlug
+ *
- Type: Boolean
+ *
- Level: WARN
+ *
+ */
+ EMETER2_EXCEPTION(Doc.of(Level.WARNING) //
+ .text("E-Meter Phase 2 is not valid.")),
+ /**
+ * Indicates whether the associated meter is functioning properly.
+ *
+ *
+ * - Interface: ShellyPlug
+ *
- Type: Boolean
+ *
- Level: WARN
+ *
+ */
+ EMETER3_EXCEPTION(Doc.of(Level.WARNING) //
+ .text("E-Meter Phase 3 is not valid.")),
+ /**
+ * Indicates whether the associated meter is functioning properly.
+ *
+ *
+ * - Interface: ShellyPlug
+ *
- Type: Boolean
+ *
- Level: WARN
+ *
+ */
+ EMETERN_EXCEPTION(Doc.of(Level.WARNING) //
+ .text("E-Meter Phase N is not valid.")),
+ /**
+ * Indicates whether the Relay is in an Overpower Condition.
+ *
+ *
+ * - Interface: ShellyPlug
+ *
- Type: Boolean
+ *
- Level: WARN
+ *
+ */
+ RELAY_OVERPOWER_EXCEPTION(Doc.of(Level.WARNING) //
+ .text("Relay is in overpower condition.")),
+ /**
+ * Slave Communication Failed Fault.
+ *
+ *
+ * - Interface: ShellyPlug
+ *
- Type: State
+ *
+ */
+ SLAVE_COMMUNICATION_FAILED(Doc.of(Level.FAULT)); //
+
+ private final Doc doc;
+
+ private ChannelId(Doc doc) {
+ this.doc = doc;
+ }
+
+ @Override
+ public Doc doc() {
+ return this.doc;
+ }
+ }
+
+ /**
+ * Gets the Channel for {@link ChannelId#RELAY}.
+ *
+ * @return the Channel
+ */
+ public default BooleanWriteChannel getRelayChannel() {
+ return this.channel(ChannelId.RELAY);
+ }
+
+ /**
+ * Gets the Relay Output 1. See {@link ChannelId#RELAY}.
+ *
+ * @return the Channel {@link Value}
+ */
+ public default Value getRelay() {
+ return this.getRelayChannel().value();
+ }
+
+ /**
+ * Internal method to set the 'nextValue' on {@link ChannelId#RELAY} Channel.
+ *
+ * @param value the next value
+ */
+ public default void _setRelay(Boolean value) {
+ this.getRelayChannel().setNextValue(value);
+ }
+
+ /**
+ * Sets the Relay Output. See {@link ChannelId#RELAY}.
+ *
+ * @param value the next write value
+ * @throws OpenemsNamedException on error
+ */
+ public default void setRelay(boolean value) throws OpenemsNamedException {
+ this.getRelayChannel().setNextWriteValue(value);
+ }
+
+ /**
+ * Gets the Channel for {@link ChannelId#SLAVE_COMMUNICATION_FAILED}.
+ *
+ * @return the Channel
+ */
+ public default StateChannel getSlaveCommunicationFailedChannel() {
+ return this.channel(ChannelId.SLAVE_COMMUNICATION_FAILED);
+ }
+
+ /**
+ * Gets the Slave Communication Failed State. See
+ * {@link ChannelId#SLAVE_COMMUNICATION_FAILED}.
+ *
+ * @return the Channel {@link Value}
+ */
+ public default Value getSlaveCommunicationFailed() {
+ return this.getSlaveCommunicationFailedChannel().value();
+ }
+
+ /**
+ * Internal method to set the 'nextValue' on
+ * {@link ChannelId#SLAVE_COMMUNICATION_FAILED} Channel.
+ *
+ * @param value the next value
+ */
+ public default void _setSlaveCommunicationFailed(boolean value) {
+ this.getSlaveCommunicationFailedChannel().setNextValue(value);
+ }
+
+}
diff --git a/io.openems.edge.io.shelly/src/io/openems/edge/io/shelly/shelly3em/IoShelly3EmImpl.java b/io.openems.edge.io.shelly/src/io/openems/edge/io/shelly/shelly3em/IoShelly3EmImpl.java
new file mode 100644
index 00000000000..5d899b8faba
--- /dev/null
+++ b/io.openems.edge.io.shelly/src/io/openems/edge/io/shelly/shelly3em/IoShelly3EmImpl.java
@@ -0,0 +1,283 @@
+package io.openems.edge.io.shelly.shelly3em;
+
+import static io.openems.common.utils.JsonUtils.getAsBoolean;
+import static io.openems.common.utils.JsonUtils.getAsFloat;
+import static io.openems.common.utils.JsonUtils.getAsJsonArray;
+import static io.openems.common.utils.JsonUtils.getAsJsonObject;
+import static java.lang.Math.round;
+
+import java.util.Objects;
+
+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.event.Event;
+import org.osgi.service.event.EventHandler;
+import org.osgi.service.event.propertytypes.EventTopics;
+import org.osgi.service.metatype.annotations.Designate;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.JsonElement;
+
+import io.openems.common.exceptions.OpenemsError.OpenemsNamedException;
+import io.openems.edge.bridge.http.api.BridgeHttp;
+import io.openems.edge.bridge.http.api.BridgeHttpFactory;
+import io.openems.edge.common.channel.BooleanWriteChannel;
+import io.openems.edge.common.component.AbstractOpenemsComponent;
+import io.openems.edge.common.component.OpenemsComponent;
+import io.openems.edge.common.event.EdgeEventConstants;
+import io.openems.edge.io.api.DigitalOutput;
+import io.openems.edge.meter.api.ElectricityMeter;
+import io.openems.edge.meter.api.MeterType;
+import io.openems.edge.timedata.api.Timedata;
+import io.openems.edge.timedata.api.TimedataProvider;
+import io.openems.edge.timedata.api.utils.CalculateEnergyFromPower;
+
+@Designate(ocd = Config.class, factory = true)
+@Component(//
+ name = "IO.Shelly.3EM", //
+ immediate = true, //
+ configurationPolicy = ConfigurationPolicy.REQUIRE //
+)
+@EventTopics({ //
+ EdgeEventConstants.TOPIC_CYCLE_AFTER_PROCESS_IMAGE, //
+ EdgeEventConstants.TOPIC_CYCLE_EXECUTE_WRITE //
+})
+public class IoShelly3EmImpl extends AbstractOpenemsComponent
+ implements IoShelly3Em, DigitalOutput, ElectricityMeter, OpenemsComponent, TimedataProvider, EventHandler {
+
+ private final CalculateEnergyFromPower calculateProductionEnergy = new CalculateEnergyFromPower(this,
+ ElectricityMeter.ChannelId.ACTIVE_PRODUCTION_ENERGY);
+ private final CalculateEnergyFromPower calculateConsumptionEnergy = new CalculateEnergyFromPower(this,
+ ElectricityMeter.ChannelId.ACTIVE_CONSUMPTION_ENERGY);
+
+ private final Logger log = LoggerFactory.getLogger(IoShelly3EmImpl.class);
+ private final BooleanWriteChannel[] digitalOutputChannels;
+
+ private MeterType meterType = null;
+ private String baseUrl;
+
+ @Reference(policy = ReferencePolicy.DYNAMIC, policyOption = ReferencePolicyOption.GREEDY, cardinality = ReferenceCardinality.OPTIONAL)
+ private volatile Timedata timedata;
+
+ @Reference()
+ private BridgeHttpFactory httpBridgeFactory;
+ private BridgeHttp httpBridge;
+
+ public IoShelly3EmImpl() {
+ super(//
+ OpenemsComponent.ChannelId.values(), //
+ ElectricityMeter.ChannelId.values(), //
+ DigitalOutput.ChannelId.values(), //
+ IoShelly3Em.ChannelId.values() //
+ );
+ this.digitalOutputChannels = new BooleanWriteChannel[] { this.channel(IoShelly3Em.ChannelId.RELAY) };
+
+ ElectricityMeter.calculateSumActivePowerFromPhases(this);
+ }
+
+ @Activate
+ protected void activate(ComponentContext context, Config config) {
+ super.activate(context, config.id(), config.alias(), config.enabled());
+ this.meterType = config.type();
+ this.baseUrl = "http://" + config.ip();
+ this.httpBridge = this.httpBridgeFactory.get();
+
+ if (this.isEnabled()) {
+ this.httpBridge.subscribeJsonEveryCycle(this.baseUrl + "/status", this::processHttpResult);
+ }
+ }
+
+ @Deactivate
+ protected void deactivate() {
+ this.httpBridgeFactory.unget(this.httpBridge);
+ this.httpBridge = null;
+ super.deactivate();
+ }
+
+ @Override
+ public BooleanWriteChannel[] digitalOutputChannels() {
+ return this.digitalOutputChannels;
+ }
+
+ @Override
+ public String debugLog() {
+ var b = new StringBuilder();
+ var valueOpt = this.getRelayChannel().value().asOptional();
+ b.append(valueOpt.isPresent() //
+ ? (valueOpt.get() //
+ ? "ON" //
+ : "OFF") //
+ : "Unknown");
+ b.append("|");
+ b.append(this.getActivePowerChannel().value().asString());
+
+ return b.toString();
+ }
+
+ @Override
+ public void handleEvent(Event event) {
+ if (!this.isEnabled()) {
+ return;
+ }
+
+ switch (event.getTopic()) {
+ case EdgeEventConstants.TOPIC_CYCLE_AFTER_PROCESS_IMAGE //
+ -> this.calculateEnergy();
+ case EdgeEventConstants.TOPIC_CYCLE_EXECUTE_WRITE //
+ -> this.executeWrite();
+ }
+ }
+
+ private void processHttpResult(JsonElement result, Throwable error) {
+ this._setSlaveCommunicationFailed(result == null);
+
+ // Prepare variables
+ Boolean relay0 = null;
+ Integer activePower = null;
+ Integer activePowerL1 = null;
+ Integer activePowerL2 = null;
+ Integer activePowerL3 = null;
+ Integer voltageL1 = null;
+ Integer voltageL2 = null;
+ Integer voltageL3 = null;
+ Integer currentL1 = null;
+ Integer currentL2 = null;
+ Integer currentL3 = null;
+ boolean hasUpdate = false;
+ boolean overpower = false;
+
+ if (error != null) {
+ this.logDebug(this.log, error.getMessage());
+
+ } else {
+ try {
+ var response = getAsJsonObject(result);
+
+ var relays = getAsJsonArray(response, "relays");
+ if (!relays.isEmpty()) {
+ var relay = getAsJsonObject(relays.get(0));
+ relay0 = getAsBoolean(relay, "ison");
+ overpower = getAsBoolean(relay, "overpower");
+ }
+
+ var update = getAsJsonObject(response, "update");
+ hasUpdate = getAsBoolean(update, "has_update");
+
+ activePower = round(getAsFloat(response, "total_power"));
+
+ var emeters = getAsJsonArray(response, "emeters");
+ for (int i = 0; i < emeters.size(); i++) {
+ var emeter = getAsJsonObject(emeters.get(i));
+ var power = round(getAsFloat(emeter, "power"));
+ var voltage = round(getAsFloat(emeter, "voltage") * 1000);
+ var current = round(getAsFloat(emeter, "current") * 1000);
+ var isValid = getAsBoolean(emeter, "is_valid");
+
+ switch (i + 1 /* phase */) {
+ case 1 -> {
+ activePowerL1 = power;
+ voltageL1 = voltage;
+ currentL1 = current;
+ this.channel(IoShelly3Em.ChannelId.EMETER1_EXCEPTION).setNextValue(!isValid);
+ }
+ case 2 -> {
+ activePowerL2 = power;
+ voltageL2 = voltage;
+ currentL2 = current;
+ this.channel(IoShelly3Em.ChannelId.EMETER2_EXCEPTION).setNextValue(!isValid);
+ }
+ case 3 -> {
+ activePowerL3 = power;
+ voltageL3 = voltage;
+ currentL3 = current;
+ this.channel(IoShelly3Em.ChannelId.EMETER3_EXCEPTION).setNextValue(!isValid);
+ }
+ }
+ }
+
+ } catch (OpenemsNamedException e) {
+ this.logDebug(this.log, e.getMessage());
+ }
+ }
+
+ // Actually set Channels
+ this._setRelay(relay0);
+ this.channel(IoShelly3Em.ChannelId.RELAY_OVERPOWER_EXCEPTION).setNextValue(overpower);
+ this._setActivePower(activePower);
+ this.channel(IoShelly3Em.ChannelId.HAS_UPDATE).setNextValue(hasUpdate);
+
+ this._setActivePowerL1(activePowerL1);
+ this._setVoltageL1(voltageL1);
+ this._setCurrentL1(currentL1);
+
+ this._setActivePowerL2(activePowerL2);
+ this._setVoltageL2(voltageL2);
+ this._setCurrentL2(currentL2);
+
+ this._setActivePowerL3(activePowerL3);
+ this._setVoltageL3(voltageL3);
+ this._setCurrentL3(currentL3);
+ }
+
+ /**
+ * Execute on Cycle Event "Execute Write".
+ */
+ private void executeWrite() {
+ var channel = this.getRelayChannel();
+ var index = 0;
+ var readValue = channel.value().get();
+ var writeValue = channel.getNextWriteValueAndReset();
+ if (writeValue.isEmpty()) {
+ return;
+ }
+ if (Objects.equals(readValue, writeValue.get())) {
+ return;
+ }
+ final var url = this.baseUrl + "/relay/" + index + "?turn=" + (writeValue.get() ? "on" : "off");
+
+ this.httpBridge.get(url).whenComplete((t, e) -> {
+ this._setSlaveCommunicationFailed(e != null);
+ if (e == null) {
+ this.logInfo(this.log, "Executed write successfully for URL: " + url);
+ } else {
+ this.logError(this.log, "Failed to execute write for URL: " + url + "; Error: " + e.getMessage());
+ }
+ });
+ }
+
+ /**
+ * Calculate the Energy values from ActivePower.
+ */
+ private void calculateEnergy() {
+ // Calculate Energy
+ final var activePower = this.getActivePower().get();
+ if (activePower == null) {
+ this.calculateProductionEnergy.update(null);
+ this.calculateConsumptionEnergy.update(null);
+ } else if (activePower >= 0) {
+ this.calculateProductionEnergy.update(activePower);
+ this.calculateConsumptionEnergy.update(0);
+ } else {
+ this.calculateProductionEnergy.update(0);
+ this.calculateConsumptionEnergy.update(-activePower);
+ }
+ }
+
+ @Override
+ public Timedata getTimedata() {
+ return this.timedata;
+ }
+
+ @Override
+ public MeterType getMeterType() {
+ return this.meterType;
+ }
+}
diff --git a/io.openems.edge.io.shelly/test/io/openems/edge/io/shelly/shelly3em/IoShelly3EmImplTest.java b/io.openems.edge.io.shelly/test/io/openems/edge/io/shelly/shelly3em/IoShelly3EmImplTest.java
new file mode 100644
index 00000000000..d7184c4c84f
--- /dev/null
+++ b/io.openems.edge.io.shelly/test/io/openems/edge/io/shelly/shelly3em/IoShelly3EmImplTest.java
@@ -0,0 +1,25 @@
+package io.openems.edge.io.shelly.shelly3em;
+
+import org.junit.Test;
+
+import io.openems.edge.bridge.http.dummy.DummyBridgeHttpFactory;
+import io.openems.edge.common.test.ComponentTest;
+import io.openems.edge.meter.api.MeterType;
+
+public class IoShelly3EmImplTest {
+
+ private static final String COMPONENT_ID = "io0";
+
+ @Test
+ public void test() throws Exception {
+ new ComponentTest(new IoShelly3EmImpl()) //
+ .addReference("httpBridgeFactory", new DummyBridgeHttpFactory()) //
+ .activate(MyConfig.create() //
+ .setId(COMPONENT_ID) //
+ .setIp("127.0.0.1") //
+ .setType(MeterType.CONSUMPTION_METERED) //
+ .build()) //
+ ;
+ }
+
+}
diff --git a/io.openems.edge.io.shelly/test/io/openems/edge/io/shelly/shelly3em/MyConfig.java b/io.openems.edge.io.shelly/test/io/openems/edge/io/shelly/shelly3em/MyConfig.java
new file mode 100644
index 00000000000..73b5ec2dc44
--- /dev/null
+++ b/io.openems.edge.io.shelly/test/io/openems/edge/io/shelly/shelly3em/MyConfig.java
@@ -0,0 +1,62 @@
+package io.openems.edge.io.shelly.shelly3em;
+
+import io.openems.common.test.AbstractComponentConfig;
+import io.openems.edge.meter.api.MeterType;
+
+@SuppressWarnings("all")
+public class MyConfig extends AbstractComponentConfig implements Config {
+
+ protected static class Builder {
+ private String id;
+ private String ip;
+ private MeterType type;
+
+ private Builder() {
+ }
+
+ public Builder setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public Builder setIp(String ip) {
+ this.ip = ip;
+ return this;
+ }
+
+ public Builder setType(MeterType type) {
+ this.type = type;
+ return this;
+ }
+
+ public MyConfig build() {
+ return new MyConfig(this);
+ }
+ }
+
+ /**
+ * Create a Config builder.
+ *
+ * @return a {@link Builder}
+ */
+ 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 ip() {
+ return this.builder.ip;
+ }
+
+ @Override
+ public MeterType type() {
+ return this.builder.type;
+ }
+}
\ No newline at end of file