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. + * + * + */ + DEBUG_RELAY(Doc.of(OpenemsType.BOOLEAN)), // + /** + * Relay Output. + * + * + */ + RELAY(new BooleanDoc() // + .accessMode(AccessMode.READ_WRITE) // + .onChannelSetNextWriteMirrorToDebugChannel(ChannelId.DEBUG_RELAY)), + /** + * Indicates if an update is available. + * + * + */ + HAS_UPDATE(Doc.of(Level.INFO) // + .text("A new Firmware Update is available.")), + /** + * Indicates whether the associated meter is functioning properly. + * + * + */ + EMETER1_EXCEPTION(Doc.of(Level.WARNING) // + .text("E-Meter Phase 1 is not valid.")), + /** + * Indicates whether the associated meter is functioning properly. + * + * + */ + EMETER2_EXCEPTION(Doc.of(Level.WARNING) // + .text("E-Meter Phase 2 is not valid.")), + /** + * Indicates whether the associated meter is functioning properly. + * + * + */ + EMETER3_EXCEPTION(Doc.of(Level.WARNING) // + .text("E-Meter Phase 3 is not valid.")), + /** + * Indicates whether the associated meter is functioning properly. + * + * + */ + EMETERN_EXCEPTION(Doc.of(Level.WARNING) // + .text("E-Meter Phase N is not valid.")), + /** + * Indicates whether the Relay is in an Overpower Condition. + * + * + */ + RELAY_OVERPOWER_EXCEPTION(Doc.of(Level.WARNING) // + .text("Relay is in overpower condition.")), + /** + * Slave Communication Failed Fault. + * + * + */ + 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