diff --git a/CODEOWNERS b/CODEOWNERS index b389428a27569..2d50da55a03c1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -69,6 +69,7 @@ /bundles/org.openhab.binding.energenie/ @hmerk /bundles/org.openhab.binding.enigma2/ @gdolfen /bundles/org.openhab.binding.enocean/ @fruggy83 +/bundles/org.openhab.binding.enphase/ @Hilbrand /bundles/org.openhab.binding.enturno/ @klocsson /bundles/org.openhab.binding.epsonprojector/ @mlobstein /bundles/org.openhab.binding.etherrain/ @dfad1469 diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 7b8d6ce5ff569..cdd2aaa0ab5e6 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -331,6 +331,11 @@ org.openhab.binding.enocean ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.enphase + ${project.version} + org.openhab.addons.bundles org.openhab.binding.enturno diff --git a/bundles/org.openhab.binding.enphase/NOTICE b/bundles/org.openhab.binding.enphase/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.enphase/README.md b/bundles/org.openhab.binding.enphase/README.md new file mode 100644 index 0000000000000..4f768bb405b4a --- /dev/null +++ b/bundles/org.openhab.binding.enphase/README.md @@ -0,0 +1,113 @@ +# Enphase Binding + +This is the binding for the [Enphase](https://enphase.com/) Envoy Solar Panel gateway. +The binding uses the local API of the Envoy gateway. +Some calls can be made without authentication and some use a user name and password. +The default user name is `envoy` and the default password is the last 6 numbers of the serial number. +The Envoy gateway updates the data every 5 minutes. +Therefore using a refresh rate shorter doesn't provide more information. + +## Supported Things + +The follow things are supported: + +* `envoy` The Envoy gateway thing, which is a bridge thing. +* `inverter` A Enphase micro inverter connected to a solar panel. +* `relay` A Enphase relay. + +Not all Envoy gateways support all channels and things. +Therefore some data on inverters and the relay may not be available. +The binding auto detects which data is available and will report this in the log on initialization of the gateway bridge. + +## Discovery + +The binding can discover Envoy gateways, micro inverters and relays. + +## Thing Configuration + +The Envoy gateway thing `envoy` has the following configuration options: + +| parameter | required | description | +|--------------|----------|-------------------------------------------------------------------------------------------------------------| +| serialNumber | yes | The serial number of the Envoy gateway which can be found on the gateway | +| hostname | no | The host name/ip address of the Envoy gateway. Leave empty to auto detect | +| username | no | The user name to the Envoy gateway. Leave empty when using the default user name | +| password | no | The password to the Envoy gateway. Leave empty when using the default password | +| refresh | no | Period between data updates. The default is the same 5 minutes the data is actual refreshed on the Envoy | + +The micro inverter `inverter` and `relay` things have only 1 parameter: + +| parameter | required | description | +|--------------|----------|-----------------------------------| +| serialNumber | yes | The serial number of the inverter | + +## Channels + +The `envoy` thing has can show both production as well as consumption data. +There are channel groups for `production` and `consumption` data. +The `consumption` data is only available if the gateway reports this. +A example of a production channel name is: `production#wattsNow`. + +| channel | type | description | +|--------------------|---------------|---------------------------------------| +| wattHoursToday | Number:Energy | Watt hours produced today | +| wattHoursSevenDays | Number:Energy | Watt hours produced the last 7 days | +| wattHoursLifetime | Number:Energy | Watt hours produced over the lifetime | +| wattsNow | Number:Power | Latest watts produced | + +The `inverter` thing has the following channels: + +| channel | type | description | +|-----------------|--------------|--------------------------------------| +| lastReportWatts | Number:Power | Last reported power delivery | +| maxReportWatts | Number:Power | Maximum reported power | +| lastReportDate | DateTime | Date of last reported power delivery | + +The following channels are only available if supported by the Envoy gateway: + +The `relay` thing has the following channels: + +| channel | type | description | +|-----------------|--------------|--------------------------------------------------------| +| relay | Contact | Status of the relay. | +| line1Connected | Contact | If power line 1 is connected. If closed it's connected | +| line2Connected | Contact | If power line 2 is connected. If closed it's connected | +| line2Connected | Contact | If power line 3 is connected. If closed it's connected | + +The `inverter` and `relay` have the following additional advanced channels: + +| channel | type | description | +|-----------------|--------------------|--------------------------------------| +| producing | Switch (Read Only) | If the device is producing | +| communicating | Switch (Read Only) | If the device is communicating | +| provisioned | Switch (Read Only) | If the device is provisioned | +| operating | Switch (Read Only) | If the device is operating | + +## Full Example + +Things example: + +``` +Bridge enphase:envoy:789012 "Envoy" [ serialNumber="12345789012" ] { + Things: + inverter 123456 "Enphase Inverter 123456" [ serialNumber="789012123456" ] + inverter 223456 "Enphase Inverter 223456" [ serialNumber="789012223456" ] +} +``` + +Items example: + +``` +Number:Power envoyWattsNow "Watts Now [%d %unit%]" { channel="enphase:envoy:789012:production#wattsNow" } +Number:Energy envoyWattHoursToday "Watt Hours Today [%d %unit%]" { channel="enphase:envoy:789012:production#wattHoursToday" } +Number:Energy envoyWattHours7Days "Watt Hours 7 Days [%.1f kWh]" { channel="enphase:envoy:789012:production#wattHoursSevenDays" } +Number:Energy envoyWattHoursLifetime "Watt Hours Lifetime [%.1f kWh]" { channel="enphase:envoy:789012:production#wattHoursLifetime" } + +Number:Power i1LastReportWatts "Last Report [%d %unit%]" { channel="enphase:inverter:789012:123456:lastReportWatts" } +Number:Power i1MaxReportWatts "Max Report [%d %unit%]" { channel="enphase:inverter:789012:123456:maxReportWatts" } +DateTime i1LastReportDate "Last Report Date [%1$tY-%1$tm-%1$td %1$tH:%1$tM]" { channel="enphase:inverter:789012:123456:lastReportDate" } + +Number:Power i2LastReportWatts "Last Report [%d %unit%]" { channel="enphase:inverter:789012:223456:lastReportWatts" } +Number:Power i21MaxReportWatts "Max Report [%d %unit%]" { channel="enphase:inverter:789012:223456:maxReportWatts" } +DateTime i2LastReportDate "Last Report Date [%1$tY-%1$tm-%1$td %1$tH:%1$tM]" { channel="enphase:inverter:789012:223456:lastReportDate" } +``` diff --git a/bundles/org.openhab.binding.enphase/pom.xml b/bundles/org.openhab.binding.enphase/pom.xml new file mode 100644 index 0000000000000..12e5c60bac012 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 3.1.0-SNAPSHOT + + + org.openhab.binding.enphase + + openHAB Add-ons :: Bundles :: Enphase Binding + + diff --git a/bundles/org.openhab.binding.enphase/src/main/feature/feature.xml b/bundles/org.openhab.binding.enphase/src/main/feature/feature.xml new file mode 100644 index 0000000000000..537cf0dc53526 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/feature/feature.xml @@ -0,0 +1,10 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.enphase/${project.version} + + diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnphaseBindingConstants.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnphaseBindingConstants.java new file mode 100644 index 0000000000000..4d08743545709 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnphaseBindingConstants.java @@ -0,0 +1,114 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link EnphaseBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +@NonNullByDefault +public class EnphaseBindingConstants { + + private static final String BINDING_ID = "enphase"; + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_ENPHASE_ENVOY = new ThingTypeUID(BINDING_ID, "envoy"); + public static final ThingTypeUID THING_TYPE_ENPHASE_INVERTER = new ThingTypeUID(BINDING_ID, "inverter"); + public static final ThingTypeUID THING_TYPE_ENPHASE_RELAY = new ThingTypeUID(BINDING_ID, "relay"); + + // Configuration parameters + public static final String CONFIG_SERIAL_NUMBER = "serialNumber"; + public static final String CONFIG_HOSTNAME = "hostname"; + public static final String CONFIG_USERNAME = "username"; + public static final String CONFIG_PASSWORD = "password"; + public static final String CONFIG_REFRESH = "refresh"; + public static final String PROPERTY_VERSION = "version"; + + // Envoy gateway channels + public static final String ENVOY_CHANNELGROUP_CONSUMPTION = "consumption"; + public static final String ENVOY_WATT_HOURS_TODAY = "wattHoursToday"; + public static final String ENVOY_WATT_HOURS_SEVEN_DAYS = "wattHoursSevenDays"; + public static final String ENVOY_WATT_HOURS_LIFETIME = "wattHoursLifetime"; + public static final String ENVOY_WATTS_NOW = "wattsNow"; + + // Device channels + public static final String DEVICE_CHANNEL_STATUS = "status"; + public static final String DEVICE_CHANNEL_PRODUCING = "producing"; + public static final String DEVICE_CHANNEL_COMMUNICATING = "communicating"; + public static final String DEVICE_CHANNEL_PROVISIONED = "provisioned"; + public static final String DEVICE_CHANNEL_OPERATING = "operating"; + + // Inverter channels + public static final String INVERTER_CHANNEL_LAST_REPORT_WATTS = "lastReportWatts"; + public static final String INVERTER_CHANNEL_MAX_REPORT_WATTS = "maxReportWatts"; + public static final String INVERTER_CHANNEL_LAST_REPORT_DATE = "lastReportDate"; + + // Relay channels + public static final String RELAY_CHANNEL_RELAY = "relay"; + public static final String RELAY_CHANNEL_LINE_1_CONNECTED = "line1Connected"; + public static final String RELAY_CHANNEL_LINE_2_CONNECTED = "line2Connected"; + public static final String RELAY_CHANNEL_LINE_3_CONNECTED = "line3Connected"; + + public static final String RELAY_STATUS_CLOSED = "closed"; + + // Properties + public static final String DEVICE_PROPERTY_PART_NUMBER = "partNumber"; + + // Discovery constants + public static final String DISCOVERY_SERIAL = "serialnum"; + public static final String DISCOVERY_VERSION = "protovers"; + + // Status messages + public static final String DEVICE_STATUS_OK = "envoy.global.ok"; + public static final String ERROR_NODATA = "error.nodata"; + + public enum EnphaseDeviceType { + ACB, // AC Battery + PSU, // Inverter + NSRB; // Network system relay controller + + public static @Nullable EnphaseDeviceType safeValueOf(final String type) { + try { + return valueOf(type); + } catch (final IllegalArgumentException e) { + return null; + } + } + } + + /** + * Derives the default password from the serial number. + * + * @param serialNumber serial number to use + * @return the default password or empty string if serial number is to short. + */ + public static String defaultPassword(final String serialNumber) { + return isValidSerial(serialNumber) ? serialNumber.substring(serialNumber.length() - 6) : ""; + } + + /** + * Checks if the serial number is at least long enough to contain the default password. + * + * @param serialNumber serial number to check + * @return true if not null and at least 6 characters long. + */ + public static boolean isValidSerial(@Nullable final String serialNumber) { + return serialNumber != null && serialNumber.length() > 6; + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnphaseHandlerFactory.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnphaseHandlerFactory.java new file mode 100644 index 0000000000000..4f267a5cd51c0 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnphaseHandlerFactory.java @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal; + +import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.*; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.enphase.internal.handler.EnphaseInverterHandler; +import org.openhab.binding.enphase.internal.handler.EnphaseRelayHandler; +import org.openhab.binding.enphase.internal.handler.EnvoyBridgeHandler; +import org.openhab.core.i18n.LocaleProvider; +import org.openhab.core.i18n.TranslationProvider; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link EnphaseHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.enphase", service = ThingHandlerFactory.class) +public class EnphaseHandlerFactory extends BaseThingHandlerFactory { + + private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ENPHASE_ENVOY, + THING_TYPE_ENPHASE_INVERTER, THING_TYPE_ENPHASE_RELAY); + + private final MessageTranslator messageTranslator; + private final HttpClient commonHttpClient; + private final EnvoyHostAddressCache envoyHostAddressCache; + + @Activate + public EnphaseHandlerFactory(final @Reference LocaleProvider localeProvider, + final @Reference TranslationProvider i18nProvider, final @Reference HttpClientFactory httpClientFactory, + @Reference final EnvoyHostAddressCache envoyHostAddressCache) { + messageTranslator = new MessageTranslator(localeProvider, i18nProvider); + commonHttpClient = httpClientFactory.getCommonHttpClient(); + this.envoyHostAddressCache = envoyHostAddressCache; + } + + @Override + public boolean supportsThingType(final ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(final Thing thing) { + final ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (THING_TYPE_ENPHASE_ENVOY.equals(thingTypeUID)) { + return new EnvoyBridgeHandler((Bridge) thing, commonHttpClient, envoyHostAddressCache); + } else if (THING_TYPE_ENPHASE_INVERTER.equals(thingTypeUID)) { + return new EnphaseInverterHandler(thing, messageTranslator); + } else if (THING_TYPE_ENPHASE_RELAY.equals(thingTypeUID)) { + return new EnphaseRelayHandler(thing, messageTranslator); + } + + return null; + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnvoyConfiguration.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnvoyConfiguration.java new file mode 100644 index 0000000000000..e9a0aef7d710a --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnvoyConfiguration.java @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link EnvoyConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +@NonNullByDefault +public class EnvoyConfiguration { + + public static final String DEFAULT_USERNAME = "envoy"; + private static final int DEFAULT_REFRESH_MINUTES = 5; + + public String serialNumber = ""; + public String hostname = ""; + public String username = DEFAULT_USERNAME; + public String password = ""; + public int refresh = DEFAULT_REFRESH_MINUTES; + + @Override + public String toString() { + return "EnvoyConfiguration [serialNumber=" + serialNumber + ", hostname=" + hostname + ", username=" + username + + ", password=" + password + ", refresh=" + refresh + "]"; + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnvoyConnectionException.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnvoyConnectionException.java new file mode 100644 index 0000000000000..d468d3d32053e --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnvoyConnectionException.java @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Exception thrown when a connection problem occurs to the Envoy gateway. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +@NonNullByDefault +public class EnvoyConnectionException extends Exception { + + private static final long serialVersionUID = 1L; + + public EnvoyConnectionException(final String message) { + super(message); + } + + public EnvoyConnectionException(final String message, final @Nullable Throwable e) { + super(message + (e == null ? "" : e.getMessage()), e); + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnvoyHostAddressCache.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnvoyHostAddressCache.java new file mode 100644 index 0000000000000..be6198d3b63ab --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnvoyHostAddressCache.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Service that keeps track of host names/ip addresses of discovered Envoy devices. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +@NonNullByDefault +public interface EnvoyHostAddressCache { + + /** + * Returns the known host name/ip address for the device with the given serial number. + * If not known an empty string is returned. + * + * @param serialNumber serial number of device to get host address for + * @return the known host address or an empty string if not known + */ + String getLastKnownHostAddress(String serialNumber); +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnvoyNoHostnameException.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnvoyNoHostnameException.java new file mode 100644 index 0000000000000..95c25c6b4e04c --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/EnvoyNoHostnameException.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Exception thrown when a api call is made while the hostname / ip address is not set. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +@NonNullByDefault +public class EnvoyNoHostnameException extends Exception { + + private static final long serialVersionUID = 1L; + + public EnvoyNoHostnameException(final String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/MessageTranslator.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/MessageTranslator.java new file mode 100644 index 0000000000000..71e60408926c9 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/MessageTranslator.java @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal; + +import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.ERROR_NODATA; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.i18n.LocaleProvider; +import org.openhab.core.i18n.TranslationProvider; +import org.osgi.framework.Bundle; +import org.osgi.framework.FrameworkUtil; + +/** + * Class to get the message for the enphase message code. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +@NonNullByDefault +public class MessageTranslator { + + private final LocaleProvider localeProvider; + private final TranslationProvider i18nProvider; + private final Bundle bundle; + + public MessageTranslator(LocaleProvider localeProvider, TranslationProvider i18nProvider) { + this.localeProvider = localeProvider; + this.i18nProvider = i18nProvider; + bundle = FrameworkUtil.getBundle(this.getClass()); + } + + /** + * Gets the message text for the enphase message code. + * + * @param key the enphase message code + * @return translated key + */ + public @Nullable String translate(String key) { + return i18nProvider.getText(bundle, key, ERROR_NODATA, localeProvider.getLocale()); + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/discovery/EnphaseDevicesDiscoveryService.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/discovery/EnphaseDevicesDiscoveryService.java new file mode 100644 index 0000000000000..7b1bfe3fe0409 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/discovery/EnphaseDevicesDiscoveryService.java @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal.discovery; + +import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.*; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.enphase.internal.EnphaseBindingConstants.EnphaseDeviceType; +import org.openhab.binding.enphase.internal.dto.InventoryJsonDTO.DeviceDTO; +import org.openhab.binding.enphase.internal.dto.InverterDTO; +import org.openhab.binding.enphase.internal.handler.EnvoyBridgeHandler; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.DiscoveryService; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Discovery service to discovery Enphase inverters connected to an Envoy gateway. + * + * @author Thomas Hentschel - Initial contribution + * @author Hilbrand Bouwkamp - Initial contribution + */ +@NonNullByDefault +public class EnphaseDevicesDiscoveryService extends AbstractDiscoveryService + implements ThingHandlerService, DiscoveryService { + + private static final int TIMEOUT_SECONDS = 20; + + private final Logger logger = LoggerFactory.getLogger(EnphaseDevicesDiscoveryService.class); + private @Nullable EnvoyBridgeHandler envoyHandler; + + public EnphaseDevicesDiscoveryService() { + super(Collections.singleton(THING_TYPE_ENPHASE_INVERTER), TIMEOUT_SECONDS, false); + } + + @Override + public void setThingHandler(final @Nullable ThingHandler handler) { + if (handler instanceof EnvoyBridgeHandler) { + envoyHandler = (EnvoyBridgeHandler) handler; + } + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return envoyHandler; + } + + @Override + public void deactivate() { + super.deactivate(); + } + + @Override + protected void startScan() { + removeOlderResults(getTimestampOfLastScan()); + final EnvoyBridgeHandler envoyHandler = this.envoyHandler; + + if (envoyHandler == null || !envoyHandler.isOnline()) { + logger.debug("Envoy handler not available or online: {}", envoyHandler); + return; + } + final ThingUID uid = envoyHandler.getThing().getUID(); + + scanForInverterThings(envoyHandler, uid); + scanForDeviceThings(envoyHandler, uid); + } + + private void scanForInverterThings(final EnvoyBridgeHandler envoyHandler, final ThingUID bridgeID) { + final Map inverters = envoyHandler.getInvertersData(true); + + if (inverters == null) { + logger.debug("No inverter data for Enphase inverters in discovery for Envoy {}.", bridgeID); + } else { + for (final Entry entry : inverters.entrySet()) { + discover(bridgeID, entry.getKey(), THING_TYPE_ENPHASE_INVERTER, "Inverter "); + } + } + } + + /** + * Scans for other device things ('other' as in: no inverters). + * + * @param envoyHandler + * @param bridgeID + */ + private void scanForDeviceThings(final EnvoyBridgeHandler envoyHandler, final ThingUID bridgeID) { + final Map devices = envoyHandler.getDevices(true); + + if (devices == null) { + logger.debug("No device data for Enphase devices in discovery for Envoy {}.", bridgeID); + } else { + for (final Entry entry : devices.entrySet()) { + final DeviceDTO dto = entry.getValue(); + final EnphaseDeviceType type = dto == null ? null : EnphaseDeviceType.safeValueOf(dto.type); + + if (type == EnphaseDeviceType.NSRB) { + discover(bridgeID, entry.getKey(), THING_TYPE_ENPHASE_RELAY, "Relay "); + } + } + } + } + + private void discover(final ThingUID bridgeID, final String serialNumber, final ThingTypeUID typeUID, + final String label) { + final String shortSerialNumber = defaultPassword(serialNumber); + final ThingUID thingUID = new ThingUID(typeUID, bridgeID, shortSerialNumber); + final Map properties = new HashMap<>(1); + + properties.put(CONFIG_SERIAL_NUMBER, serialNumber); + final DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID).withBridge(bridgeID) + .withRepresentationProperty(CONFIG_SERIAL_NUMBER).withProperties(properties) + .withLabel("Enphase " + label + shortSerialNumber).build(); + thingDiscovered(discoveryResult); + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/discovery/EnvoyDiscoveryParticipant.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/discovery/EnvoyDiscoveryParticipant.java new file mode 100644 index 0000000000000..beae1cabfed10 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/discovery/EnvoyDiscoveryParticipant.java @@ -0,0 +1,136 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal.discovery; + +import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.*; + +import java.net.Inet4Address; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import javax.jmdns.ServiceInfo; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.enphase.internal.EnphaseBindingConstants; +import org.openhab.binding.enphase.internal.EnvoyHostAddressCache; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * MDNS discovery participant for discovering Envoy gateways. + * This service also keeps track of any discovered Envoys host name to provide this information for existing Envoy + * bridges + * so the bridge cat get the host name/ip address if that is unknown. + * + * @author Thomas Hentschel - Initial contribution + * @author Hilbrand Bouwkamp - Initial contribution + */ +@Component(service = { EnvoyHostAddressCache.class, MDNSDiscoveryParticipant.class }) +@NonNullByDefault +public class EnvoyDiscoveryParticipant implements MDNSDiscoveryParticipant, EnvoyHostAddressCache { + private static final String ENVOY_MDNS_ID = "envoy"; + + private final Logger logger = LoggerFactory.getLogger(EnvoyDiscoveryParticipant.class); + + private final Map lastKnownHostAddresses = new ConcurrentHashMap<>(); + + @Override + public Set getSupportedThingTypeUIDs() { + return Collections.singleton(EnphaseBindingConstants.THING_TYPE_ENPHASE_ENVOY); + } + + @Override + public String getServiceType() { + return "_enphase-envoy._tcp.local."; + } + + @Override + public @Nullable DiscoveryResult createResult(final ServiceInfo info) { + final String id = info.getName(); + + logger.debug("id found: {} with type: {}", id, info.getType()); + + if (!id.contains(ENVOY_MDNS_ID)) { + return null; + } + + if (info.getInet4Addresses().length == 0 || info.getInet4Addresses()[0] == null) { + return null; + } + + final ThingUID uid = getThingUID(info); + + if (uid == null) { + return null; + } + + final Inet4Address hostname = info.getInet4Addresses()[0]; + final String serialNumber = info.getPropertyString(DISCOVERY_SERIAL); + + if (serialNumber == null) { + logger.debug("No serial number found in data for discovered Envoy {}: {}", id, info); + return null; + } + final String version = info.getPropertyString(DISCOVERY_VERSION); + final String hostAddress = hostname == null ? "" : hostname.getHostAddress(); + + lastKnownHostAddresses.put(serialNumber, hostAddress); + final Map properties = new HashMap<>(3); + + properties.put(CONFIG_SERIAL_NUMBER, serialNumber); + properties.put(CONFIG_HOSTNAME, hostAddress); + properties.put(PROPERTY_VERSION, version); + return DiscoveryResultBuilder.create(uid).withProperties(properties) + .withRepresentationProperty(CONFIG_SERIAL_NUMBER) + .withLabel("Enphase Envoy " + defaultPassword(serialNumber)).build(); + } + + @Override + public String getLastKnownHostAddress(final String serialNumber) { + final String hostAddress = lastKnownHostAddresses.get(serialNumber); + + return hostAddress == null ? "" : hostAddress; + } + + @Override + public @Nullable ThingUID getThingUID(final ServiceInfo info) { + final String name = info.getName(); + + if (!name.contains(ENVOY_MDNS_ID)) { + logger.trace("Found other type of device that is not recognized as an Envoy: {}", name); + return null; + } + if (info.getInet4Addresses().length == 0 || info.getInet4Addresses()[0] == null) { + logger.debug("Found an Envoy, but no ip address is given: {}", info); + return null; + } + logger.debug("ServiceInfo addr: {}", info.getInet4Addresses()[0]); + if (getServiceType().equals(info.getType())) { + final String serial = info.getPropertyString(DISCOVERY_SERIAL); + + logger.debug("Discovered an Envoy with serial number '{}'", serial); + return new ThingUID(THING_TYPE_ENPHASE_ENVOY, serial); + } + return null; + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/EnvoyEnergyDTO.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/EnvoyEnergyDTO.java new file mode 100644 index 0000000000000..8857858bf6158 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/EnvoyEnergyDTO.java @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal.dto; + +/** + * Data from api/v1/production api call. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +public class EnvoyEnergyDTO { + public int wattHoursToday; + public int wattHoursSevenDays; + public int wattHoursLifetime; + public int wattsNow; +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/EnvoyErrorDTO.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/EnvoyErrorDTO.java new file mode 100644 index 0000000000000..a8a29d8649cf6 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/EnvoyErrorDTO.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal.dto; + +/** + * Data class for handling errors returned by the Envoy gateway. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +public class EnvoyErrorDTO { + public int status; + public String error; + public String info; + public String moreInfo; + + @Override + public String toString() { + return "EnvoyErrorDTO [status=" + status + ", error=" + error + ", info=" + info + ", moreInfo=" + moreInfo + + "]"; + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/InventoryJsonDTO.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/InventoryJsonDTO.java new file mode 100644 index 0000000000000..d6efa5ae0c73f --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/InventoryJsonDTO.java @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal.dto; + +import com.google.gson.annotations.SerializedName; + +/** + * @author Hilbrand Bouwkamp - Initial contribution + */ +public class InventoryJsonDTO { + + public class DeviceDTO { + public String type; + + @SerializedName("part_num") + public String partNumber; + @SerializedName("serial_num") + public String serialNumber; + + @SerializedName("device_status") + private String[] deviceStatus; + @SerializedName("last_rpt_date") + public String lastReportDate; + public boolean producing; + public boolean communicating; + public boolean provisioned; + public boolean operating; + // NSRB data + public String relay; + @SerializedName("line1-connected") + public boolean line1Connected; + @SerializedName("line2-connected") + public boolean line2Connected; + @SerializedName("line3-connected") + public boolean line3Connected; + + public String getSerialNumber() { + return serialNumber; + } + + public String getDeviceStatus() { + return deviceStatus == null || deviceStatus.length == 0 ? "" : deviceStatus[0]; + } + } + + public String type; + public DeviceDTO[] devices; +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/InverterDTO.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/InverterDTO.java new file mode 100644 index 0000000000000..28a400054964b --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/InverterDTO.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal.dto; + +/** + * Data class for Enphase Inverter data. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +public class InverterDTO { + public String serialNumber; + public long lastReportDate; + public int devType; + public int lastReportWatts; + public int maxReportWatts; + + /** + * @return the serialNumber + */ + public String getSerialNumber() { + return serialNumber; + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/ProductionJsonDTO.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/ProductionJsonDTO.java new file mode 100644 index 0000000000000..3513e5cd33511 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/ProductionJsonDTO.java @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal.dto; + +/** + * Data class for Envoy production and consumption data from production.json api call. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +public class ProductionJsonDTO { + + public static class DataDTO { + public String type; + public int activeCount; + public float whLifetime; + public float whLastSevenDays; + public float whToday; + public float wNow; + public float rmsCurrent; + public float rmsVoltage; + public float reactPwr; + public float apprntPwr; + public float pwrFactor; + public long readingTime; + public float varhLeadToday; + public float varhLagToday; + public float vahToday; + public float varhLeadLifetime; + public float varhLagLifetime; + public float vahLifetime; + } + + public DataDTO[] production; + public DataDTO[] consumption; +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnphaseDeviceHandler.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnphaseDeviceHandler.java new file mode 100644 index 0000000000000..7ffcd0f7a795a --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnphaseDeviceHandler.java @@ -0,0 +1,146 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal.handler; + +import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.*; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.enphase.internal.EnphaseBindingConstants; +import org.openhab.binding.enphase.internal.MessageTranslator; +import org.openhab.binding.enphase.internal.dto.InventoryJsonDTO.DeviceDTO; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.UnDefType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Generic base Thing handler for different Enphase devices. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +@NonNullByDefault +abstract class EnphaseDeviceHandler extends BaseThingHandler { + protected final Logger logger = LoggerFactory.getLogger(getClass()); + + protected @Nullable DeviceDTO lastKnownDeviceState; + + private final MessageTranslator messageTranslator; + private String serialNumber = ""; + + public EnphaseDeviceHandler(final Thing thing, MessageTranslator messageTranslator) { + super(thing); + this.messageTranslator = messageTranslator; + } + + /** + * @return the serialNumber + */ + public String getSerialNumber() { + return serialNumber; + } + + protected void handleCommandRefresh(final String channelId) { + switch (channelId) { + case DEVICE_CHANNEL_STATUS: + refreshStatus(lastKnownDeviceState); + break; + case DEVICE_CHANNEL_PRODUCING: + refreshProducing(lastKnownDeviceState); + break; + case DEVICE_CHANNEL_COMMUNICATING: + refreshCommunicating(lastKnownDeviceState); + break; + case DEVICE_CHANNEL_PROVISIONED: + refreshProvisioned(lastKnownDeviceState); + break; + case DEVICE_CHANNEL_OPERATING: + refreshOperating(lastKnownDeviceState); + break; + } + } + + private void refreshStatus(final @Nullable DeviceDTO deviceDTO) { + updateState(DEVICE_CHANNEL_STATUS, deviceDTO == null ? UnDefType.UNDEF + : new StringType(messageTranslator.translate((deviceDTO.getDeviceStatus())))); + } + + private void refreshProducing(final @Nullable DeviceDTO deviceDTO) { + updateState(DEVICE_CHANNEL_PRODUCING, + deviceDTO == null ? UnDefType.UNDEF : OnOffType.from(deviceDTO.producing)); + } + + private void refreshCommunicating(final @Nullable DeviceDTO deviceDTO) { + updateState(DEVICE_CHANNEL_COMMUNICATING, + deviceDTO == null ? UnDefType.UNDEF : OnOffType.from(deviceDTO.communicating)); + } + + private void refreshProvisioned(final @Nullable DeviceDTO deviceDTO) { + updateState(DEVICE_CHANNEL_PROVISIONED, + deviceDTO == null ? UnDefType.UNDEF : OnOffType.from(deviceDTO.provisioned)); + } + + private void refreshOperating(final @Nullable DeviceDTO deviceDTO) { + updateState(DEVICE_CHANNEL_OPERATING, + deviceDTO == null ? UnDefType.UNDEF : OnOffType.from(deviceDTO.operating)); + } + + public void refreshDeviceState(final @Nullable DeviceDTO deviceDTO) { + refreshStatus(deviceDTO); + refreshProducing(deviceDTO); + refreshCommunicating(deviceDTO); + refreshProvisioned(deviceDTO); + refreshOperating(deviceDTO); + refreshProperties(deviceDTO); + refreshDeviceStatus(deviceDTO != null); + } + + public void refreshDeviceStatus(final boolean hasData) { + if (isInitialized()) { + if (hasData) { + if (getThing().getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + } + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + messageTranslator.translate(ERROR_NODATA)); + } + } + } + + private void refreshProperties(@Nullable final DeviceDTO deviceDTO) { + if (deviceDTO != null) { + final Map properties = editProperties(); + + properties.put(DEVICE_PROPERTY_PART_NUMBER, deviceDTO.partNumber); + updateProperties(properties); + } + } + + @Override + public void initialize() { + serialNumber = (String) getConfig().get(EnphaseBindingConstants.CONFIG_SERIAL_NUMBER); + if (!EnphaseBindingConstants.isValidSerial(serialNumber)) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Serial Number is not valid"); + } else { + updateStatus(ThingStatus.UNKNOWN); + } + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnphaseInverterHandler.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnphaseInverterHandler.java new file mode 100644 index 0000000000000..7e3af0a6e1740 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnphaseInverterHandler.java @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal.handler; + +import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.*; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.enphase.internal.MessageTranslator; +import org.openhab.binding.enphase.internal.dto.InverterDTO; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * The {@link EnphaseInverterHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +@NonNullByDefault +public class EnphaseInverterHandler extends EnphaseDeviceHandler { + + private @Nullable InverterDTO lastKnownState; + + public EnphaseInverterHandler(final Thing thing, MessageTranslator messageTranslator) { + super(thing, messageTranslator); + } + + @Override + public void handleCommand(final ChannelUID channelUID, final Command command) { + if (command instanceof RefreshType) { + final String channelId = channelUID.getId(); + + switch (channelId) { + case INVERTER_CHANNEL_LAST_REPORT_WATTS: + refreshLastReportWatts(lastKnownState); + break; + case INVERTER_CHANNEL_MAX_REPORT_WATTS: + refreshMaxReportWatts(lastKnownState); + break; + case INVERTER_CHANNEL_LAST_REPORT_DATE: + refreshLastReportDate(lastKnownState); + break; + default: + super.handleCommandRefresh(channelId); + break; + } + } + } + + public void refreshInverterChannels(final @Nullable InverterDTO inverterDTO) { + refreshLastReportWatts(inverterDTO); + refreshMaxReportWatts(inverterDTO); + refreshLastReportDate(inverterDTO); + lastKnownState = inverterDTO; + } + + private void refreshLastReportWatts(final @Nullable InverterDTO inverterDTO) { + updateState(INVERTER_CHANNEL_LAST_REPORT_WATTS, + inverterDTO == null ? UnDefType.UNDEF : new QuantityType<>(inverterDTO.lastReportWatts, Units.WATT)); + } + + private void refreshMaxReportWatts(final @Nullable InverterDTO inverterDTO) { + updateState(INVERTER_CHANNEL_MAX_REPORT_WATTS, + inverterDTO == null ? UnDefType.UNDEF : new QuantityType<>(inverterDTO.maxReportWatts, Units.WATT)); + } + + private void refreshLastReportDate(final @Nullable InverterDTO inverterDTO) { + final State state; + + if (inverterDTO == null) { + state = UnDefType.UNDEF; + } else { + final Instant instant = Instant.ofEpochSecond(inverterDTO.lastReportDate); + final ZonedDateTime zonedDateTime = instant.atZone(ZoneId.systemDefault()); + logger.trace("[{}] Epoch time {}, zonedDateTime: {}", getThing().getUID(), inverterDTO.lastReportDate, + zonedDateTime); + state = new DateTimeType(zonedDateTime); + } + updateState(INVERTER_CHANNEL_LAST_REPORT_DATE, state); + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnphaseRelayHandler.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnphaseRelayHandler.java new file mode 100644 index 0000000000000..aadee72348953 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnphaseRelayHandler.java @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal.handler; + +import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.enphase.internal.MessageTranslator; +import org.openhab.binding.enphase.internal.dto.InventoryJsonDTO.DeviceDTO; +import org.openhab.core.library.types.OpenClosedType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.UnDefType; + +/** + * The {@link EnphaseInverterHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +@NonNullByDefault +public class EnphaseRelayHandler extends EnphaseDeviceHandler { + + public EnphaseRelayHandler(final Thing thing, MessageTranslator messageTranslator) { + super(thing, messageTranslator); + } + + @Override + public void handleCommand(final ChannelUID channelUID, final Command command) { + if (command instanceof RefreshType) { + final String channelId = channelUID.getId(); + + switch (channelId) { + case RELAY_CHANNEL_RELAY: + refreshRelayChannel(lastKnownDeviceState); + break; + case RELAY_CHANNEL_LINE_1_CONNECTED: + refreshLine1Connect(lastKnownDeviceState); + break; + case RELAY_CHANNEL_LINE_2_CONNECTED: + refreshLine2Connect(lastKnownDeviceState); + break; + case RELAY_CHANNEL_LINE_3_CONNECTED: + refreshLine3Connect(lastKnownDeviceState); + break; + default: + super.handleCommandRefresh(channelId); + break; + } + } + } + + private void refreshRelayChannel(@Nullable final DeviceDTO deviceDTO) { + updateState(RELAY_CHANNEL_RELAY, deviceDTO == null ? UnDefType.UNDEF + : (RELAY_STATUS_CLOSED.equals(deviceDTO.relay) ? OpenClosedType.CLOSED : OpenClosedType.OPEN)); + } + + private void refreshLine1Connect(@Nullable final DeviceDTO deviceDTO) { + updateState(RELAY_CHANNEL_LINE_1_CONNECTED, deviceDTO == null ? UnDefType.UNDEF + : (deviceDTO.line1Connected ? OpenClosedType.CLOSED : OpenClosedType.OPEN)); + } + + private void refreshLine2Connect(@Nullable final DeviceDTO deviceDTO) { + updateState(RELAY_CHANNEL_LINE_2_CONNECTED, deviceDTO == null ? UnDefType.UNDEF + : (deviceDTO.line2Connected ? OpenClosedType.CLOSED : OpenClosedType.OPEN)); + } + + private void refreshLine3Connect(@Nullable final DeviceDTO deviceDTO) { + updateState(RELAY_CHANNEL_LINE_3_CONNECTED, deviceDTO == null ? UnDefType.UNDEF + : (deviceDTO.line3Connected ? OpenClosedType.CLOSED : OpenClosedType.OPEN)); + } + + @Override + public void refreshDeviceState(@Nullable final DeviceDTO deviceDTO) { + refreshRelayChannel(deviceDTO); + refreshLine1Connect(deviceDTO); + refreshLine2Connect(deviceDTO); + refreshLine3Connect(deviceDTO); + super.refreshDeviceState(deviceDTO); + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnvoyBridgeHandler.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnvoyBridgeHandler.java new file mode 100644 index 0000000000000..7074857c27b6d --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnvoyBridgeHandler.java @@ -0,0 +1,411 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal.handler; + +import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.CONFIG_HOSTNAME; +import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.ENVOY_CHANNELGROUP_CONSUMPTION; +import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.ENVOY_WATTS_NOW; +import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.ENVOY_WATT_HOURS_LIFETIME; +import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.ENVOY_WATT_HOURS_SEVEN_DAYS; +import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.ENVOY_WATT_HOURS_TODAY; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.openhab.binding.enphase.internal.EnphaseBindingConstants; +import org.openhab.binding.enphase.internal.EnvoyConfiguration; +import org.openhab.binding.enphase.internal.EnvoyConnectionException; +import org.openhab.binding.enphase.internal.EnvoyHostAddressCache; +import org.openhab.binding.enphase.internal.EnvoyNoHostnameException; +import org.openhab.binding.enphase.internal.discovery.EnphaseDevicesDiscoveryService; +import org.openhab.binding.enphase.internal.dto.EnvoyEnergyDTO; +import org.openhab.binding.enphase.internal.dto.InventoryJsonDTO.DeviceDTO; +import org.openhab.binding.enphase.internal.dto.InverterDTO; +import org.openhab.core.cache.ExpiringCache; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseBridgeHandler; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.UnDefType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * BridgeHandler for the Envoy gateway. + * + * @author Thomas Hentschel - Initial contribution + * @author Hilbrand Bouwkamp - Initial contribution + */ +@NonNullByDefault +public class EnvoyBridgeHandler extends BaseBridgeHandler { + + private enum FeatureStatus { + UNKNOWN, + SUPPORTED, + UNSUPPORTED + } + + private static final long RETRY_RECONNECT_SECONDS = 10; + + private final Logger logger = LoggerFactory.getLogger(EnvoyBridgeHandler.class); + private final EnvoyConnector connector; + private final EnvoyHostAddressCache envoyHostnameCache; + + private EnvoyConfiguration configuration = new EnvoyConfiguration(); + private @Nullable ScheduledFuture updataDataFuture; + private @Nullable ScheduledFuture updateHostnameFuture; + private @Nullable ExpiringCache> invertersCache; + private @Nullable ExpiringCache> devicesCache; + private @Nullable EnvoyEnergyDTO productionDTO; + private @Nullable EnvoyEnergyDTO consumptionDTO; + private FeatureStatus consumptionSupported = FeatureStatus.UNKNOWN; + private FeatureStatus jsonSupported = FeatureStatus.UNKNOWN; + + public EnvoyBridgeHandler(final Bridge thing, final HttpClient httpClient, + final EnvoyHostAddressCache envoyHostAddressCache) { + super(thing); + connector = new EnvoyConnector(httpClient); + this.envoyHostnameCache = envoyHostAddressCache; + } + + @Override + public void handleCommand(final ChannelUID channelUID, final Command command) { + if (command instanceof RefreshType) { + refresh(channelUID); + } + } + + private void refresh(final ChannelUID channelUID) { + final EnvoyEnergyDTO data = ENVOY_CHANNELGROUP_CONSUMPTION.equals(channelUID.getGroupId()) ? consumptionDTO + : productionDTO; + + if (data == null) { + updateState(channelUID, UnDefType.UNDEF); + } else { + switch (channelUID.getIdWithoutGroup()) { + case ENVOY_WATT_HOURS_TODAY: + updateState(channelUID, new QuantityType<>(data.wattHoursToday, Units.WATT_HOUR)); + break; + case ENVOY_WATT_HOURS_SEVEN_DAYS: + updateState(channelUID, new QuantityType<>(data.wattHoursSevenDays, Units.WATT_HOUR)); + break; + case ENVOY_WATT_HOURS_LIFETIME: + updateState(channelUID, new QuantityType<>(data.wattHoursLifetime, Units.WATT_HOUR)); + break; + case ENVOY_WATTS_NOW: + updateState(channelUID, new QuantityType<>(data.wattsNow, Units.WATT)); + break; + } + } + } + + @Override + public Collection> getServices() { + return Collections.singleton(EnphaseDevicesDiscoveryService.class); + } + + @Override + public void initialize() { + configuration = getConfigAs(EnvoyConfiguration.class); + if (!EnphaseBindingConstants.isValidSerial(configuration.serialNumber)) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Serial number is not valid"); + return; + } + updateStatus(ThingStatus.UNKNOWN); + connector.setConfiguration(configuration); + consumptionSupported = FeatureStatus.UNKNOWN; + jsonSupported = FeatureStatus.UNKNOWN; + invertersCache = new ExpiringCache<>(Duration.of(configuration.refresh, ChronoUnit.MINUTES), + this::refreshInverters); + devicesCache = new ExpiringCache<>(Duration.of(configuration.refresh, ChronoUnit.MINUTES), + this::refreshDevices); + updataDataFuture = scheduler.scheduleWithFixedDelay(this::updateData, 0, configuration.refresh, + TimeUnit.MINUTES); + } + + /** + * Method called by the ExpiringCache when no inverter data is present to get the data from the Envoy gateway. + * When there are connection problems it will start a scheduled job to try to reconnect to the + * + * @return the inverter data from the Envoy gateway or null if no data is available. + */ + private @Nullable Map refreshInverters() { + try { + return connector.getInverters().stream() + .collect(Collectors.toMap(InverterDTO::getSerialNumber, Function.identity())); + } catch (final EnvoyNoHostnameException e) { + // ignore hostname exception here. It's already handled by others. + } catch (final EnvoyConnectionException e) { + logger.trace("refreshInverters connection problem", e); + } + return null; + } + + private @Nullable Map refreshDevices() { + try { + if (jsonSupported != FeatureStatus.UNSUPPORTED) { + final Map devicesData = connector.getInventoryJson().stream() + .flatMap(inv -> Stream.of(inv.devices).map(d -> { + d.type = inv.type; + return d; + })).collect(Collectors.toMap(DeviceDTO::getSerialNumber, Function.identity())); + + jsonSupported = FeatureStatus.SUPPORTED; + return devicesData; + } + } catch (final EnvoyNoHostnameException e) { + // ignore hostname exception here. It's already handled by others. + } catch (final EnvoyConnectionException e) { + if (jsonSupported == FeatureStatus.UNKNOWN) { + logger.info( + "This Ephase Envoy device ({}) doesn't seem to support json data. So not all channels are set.", + getThing().getUID()); + jsonSupported = FeatureStatus.UNSUPPORTED; + } else if (consumptionSupported == FeatureStatus.SUPPORTED) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } + } + return null; + } + + /** + * Returns the data for the inverters. It get the data from cache or updates the cache if possible in case no data + * is available. + * + * @param force force a cache refresh + * @return data if present or null + */ + public @Nullable Map getInvertersData(final boolean force) { + final ExpiringCache> invertersCache = this.invertersCache; + + if (invertersCache == null || !isOnline()) { + return null; + } else { + if (force) { + invertersCache.invalidateValue(); + } + return invertersCache.getValue(); + } + } + + /** + * Returns the data for the devices. It get the data from cache or updates the cache if possible in case no data + * is available. + * + * @param force force a cache refresh + * @return data if present or null + */ + public @Nullable Map getDevices(final boolean force) { + final ExpiringCache> devicesCache = this.devicesCache; + + if (devicesCache == null || !isOnline()) { + return null; + } else { + if (force) { + devicesCache.invalidateValue(); + } + return devicesCache.getValue(); + } + } + + /** + * Method called by the refresh thread. + */ + public synchronized void updateData() { + try { + updateInverters(); + updateEnvoy(); + updateDevices(); + } catch (final EnvoyNoHostnameException e) { + scheduleHostnameUpdate(false); + } catch (final EnvoyConnectionException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + scheduleHostnameUpdate(false); + } catch (final RuntimeException e) { + logger.debug("Unexpected error in Enphase {}: ", getThing().getUID(), e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } + } + + private void updateEnvoy() throws EnvoyNoHostnameException, EnvoyConnectionException { + productionDTO = connector.getProduction(); + setConsumptionDTOData(); + getThing().getChannels().stream().map(Channel::getUID).filter(this::isLinked).forEach(this::refresh); + if (isInitialized() && !isOnline()) { + updateStatus(ThingStatus.ONLINE); + } + } + + /** + * Retrieve consumption data if supported, and keep track if this feature is supported by the device. + * + * @throws EnvoyConnectionException + */ + private void setConsumptionDTOData() throws EnvoyConnectionException { + if (consumptionSupported != FeatureStatus.UNSUPPORTED && isOnline()) { + try { + consumptionDTO = connector.getConsumption(); + consumptionSupported = FeatureStatus.SUPPORTED; + } catch (final EnvoyNoHostnameException e) { + // ignore hostname exception here. It's already handled by others. + } catch (final EnvoyConnectionException e) { + if (consumptionSupported == FeatureStatus.UNKNOWN) { + logger.info( + "This Enphase Envoy device ({}) doesn't seem to support consumption data. So no consumption channels are set.", + getThing().getUID()); + consumptionSupported = FeatureStatus.UNSUPPORTED; + } else if (consumptionSupported == FeatureStatus.SUPPORTED) { + throw e; + } + } + } + } + + /** + * Updates channels of the inverter things with inverter specific data. + */ + private void updateInverters() { + final Map inverters = getInvertersData(false); + + if (inverters != null) { + getThing().getThings().stream().map(Thing::getHandler).filter(h -> h instanceof EnphaseInverterHandler) + .map(EnphaseInverterHandler.class::cast) + .forEach(invHandler -> updateInverter(inverters, invHandler)); + } + } + + private void updateInverter(final @Nullable Map inverters, + final EnphaseInverterHandler invHandler) { + if (inverters == null) { + return; + } + final InverterDTO inverterDTO = inverters.get(invHandler.getSerialNumber()); + + invHandler.refreshInverterChannels(inverterDTO); + if (jsonSupported == FeatureStatus.UNSUPPORTED) { + // if inventory json is supported device status is set in #updateDevices + invHandler.refreshDeviceStatus(inverterDTO != null); + } + } + + /** + * Updates channels of the device things with device specific data. + * This data is not available on all envoy devices. + */ + private void updateDevices() { + final Map devices = getDevices(false); + + getThing().getThings().stream().map(Thing::getHandler).filter(h -> h instanceof EnphaseDeviceHandler) + .map(EnphaseDeviceHandler.class::cast).forEach(invHandler -> invHandler + .refreshDeviceState(devices == null ? null : devices.get(invHandler.getSerialNumber()))); + } + + /** + * Schedules a hostname update, but only schedules the task when not yet running or forced. + * Force is used to reschedule the task and should only be used from within {@link #updateHostname()}. + * + * @param force if true will always schedule the task + */ + private synchronized void scheduleHostnameUpdate(final boolean force) { + if (force || updateHostnameFuture == null) { + logger.debug("Schedule hostname/ip address update for thing {} in {} seconds.", getThing().getUID(), + RETRY_RECONNECT_SECONDS); + updateHostnameFuture = scheduler.schedule(this::updateHostname, RETRY_RECONNECT_SECONDS, TimeUnit.SECONDS); + } + } + + @Override + public void childHandlerInitialized(final ThingHandler childHandler, final Thing childThing) { + if (childHandler instanceof EnphaseInverterHandler) { + updateInverter(getInvertersData(false), (EnphaseInverterHandler) childHandler); + } + if (childHandler instanceof EnphaseDeviceHandler) { + final Map devices = getDevices(false); + + if (devices != null) { + ((EnphaseDeviceHandler) childHandler) + .refreshDeviceState(devices.get(((EnphaseDeviceHandler) childHandler).getSerialNumber())); + } + } + } + + /** + * Handles a host name / ip address update. + */ + private void updateHostname() { + final String lastKnownHostname = envoyHostnameCache.getLastKnownHostAddress(configuration.serialNumber); + + if (lastKnownHostname.isEmpty()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "No ip address known of the envoy gateway. If this isn't updated in a few minutes check your connection."); + scheduleHostnameUpdate(true); + } else { + final Configuration config = editConfiguration(); + + config.put(CONFIG_HOSTNAME, lastKnownHostname); + logger.info("Enphase Envoy ({}) hostname/ip address set to {}", getThing().getUID(), lastKnownHostname); + configuration.hostname = lastKnownHostname; + connector.setConfiguration(configuration); + updateConfiguration(config); + updateData(); + // The task is done so the future can be released by setting it to null. + updateHostnameFuture = null; + } + } + + @Override + public void dispose() { + final ScheduledFuture retryFuture = this.updateHostnameFuture; + if (retryFuture != null) { + retryFuture.cancel(true); + } + final ScheduledFuture inverterFuture = this.updataDataFuture; + + if (inverterFuture != null) { + inverterFuture.cancel(true); + } + } + + /** + * @return Returns true if the bridge is online and not has an configuration pending. + */ + public boolean isOnline() { + return getThing().getStatus() == ThingStatus.ONLINE; + } + + @Override + public String toString() { + return "EnvoyBridgeHandler(" + thing.getUID() + ") Status: " + thing.getStatus(); + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnvoyConnector.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnvoyConnector.java new file mode 100644 index 0000000000000..d604b3fc55b07 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnvoyConnector.java @@ -0,0 +1,197 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.enphase.internal.handler; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.Authentication; +import org.eclipse.jetty.client.api.Authentication.Result; +import org.eclipse.jetty.client.api.AuthenticationStore; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.util.DigestAuthentication; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.openhab.binding.enphase.internal.EnphaseBindingConstants; +import org.openhab.binding.enphase.internal.EnvoyConfiguration; +import org.openhab.binding.enphase.internal.EnvoyConnectionException; +import org.openhab.binding.enphase.internal.EnvoyNoHostnameException; +import org.openhab.binding.enphase.internal.dto.EnvoyEnergyDTO; +import org.openhab.binding.enphase.internal.dto.EnvoyErrorDTO; +import org.openhab.binding.enphase.internal.dto.InventoryJsonDTO; +import org.openhab.binding.enphase.internal.dto.InverterDTO; +import org.openhab.binding.enphase.internal.dto.ProductionJsonDTO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; + +/** + * Methods to make API calls to the Envoy gateway. + * + * @author Hilbrand Bouwkamp - Initial contribution + */ +@NonNullByDefault +class EnvoyConnector { + + private static final String HTTP = "http://"; + private static final String PRODUCTION_JSON_URL = "/production.json"; + private static final String INVENTORY_JSON_URL = "/inventory.json"; + private static final String PRODUCTION_URL = "/api/v1/production"; + private static final String CONSUMPTION_URL = "/api/v1/consumption"; + private static final String INVERTERS_URL = PRODUCTION_URL + "/inverters"; + private static final long CONNECT_TIMEOUT_SECONDS = 5; + + private final Logger logger = LoggerFactory.getLogger(EnvoyConnector.class); + private final Gson gson = new GsonBuilder().create(); + private final HttpClient httpClient; + private String hostname = ""; + private @Nullable DigestAuthentication envoyAuthn; + private @Nullable URI invertersURI; + + public EnvoyConnector(final HttpClient httpClient) { + this.httpClient = httpClient; + } + + /** + * Sets the Envoy connection configuration. + * + * @param configuration the configuration to set + */ + public void setConfiguration(final EnvoyConfiguration configuration) { + hostname = configuration.hostname; + if (hostname.isEmpty()) { + return; + } + final String password = configuration.password.isEmpty() + ? EnphaseBindingConstants.defaultPassword(configuration.serialNumber) + : configuration.password; + final String username = configuration.username.isEmpty() ? EnvoyConfiguration.DEFAULT_USERNAME + : configuration.username; + final AuthenticationStore store = httpClient.getAuthenticationStore(); + + if (envoyAuthn != null) { + store.removeAuthentication(envoyAuthn); + } + invertersURI = URI.create(HTTP + hostname + INVERTERS_URL); + envoyAuthn = new DigestAuthentication(invertersURI, Authentication.ANY_REALM, username, password); + store.addAuthentication(envoyAuthn); + } + + /** + * @return Returns the production data from the Envoy gateway. + */ + public EnvoyEnergyDTO getProduction() throws EnvoyConnectionException, EnvoyNoHostnameException { + return retrieveData(PRODUCTION_URL, this::jsonToEnvoyEnergyDTO); + } + + /** + * @return Returns the consumption data from the Envoy gateway. + */ + public EnvoyEnergyDTO getConsumption() throws EnvoyConnectionException, EnvoyNoHostnameException { + return retrieveData(CONSUMPTION_URL, this::jsonToEnvoyEnergyDTO); + } + + private @Nullable EnvoyEnergyDTO jsonToEnvoyEnergyDTO(final String json) { + return gson.fromJson(json, EnvoyEnergyDTO.class); + } + + /** + * @return Returns the production/consumption data from the Envoy gateway. + */ + public ProductionJsonDTO getProductionJson() throws EnvoyConnectionException, EnvoyNoHostnameException { + return retrieveData(PRODUCTION_JSON_URL, json -> gson.fromJson(json, ProductionJsonDTO.class)); + } + + /** + * @return Returns the inventory data from the Envoy gateway. + */ + public List getInventoryJson() throws EnvoyConnectionException, EnvoyNoHostnameException { + return retrieveData(INVENTORY_JSON_URL, this::jsonToEnvoyInventoryJson); + } + + private @Nullable List jsonToEnvoyInventoryJson(final String json) { + final InventoryJsonDTO @Nullable [] list = gson.fromJson(json, InventoryJsonDTO[].class); + + return list == null ? null : Arrays.asList(list); + } + + /** + * @return Returns the production data for the inverters. + */ + public List getInverters() throws EnvoyConnectionException, EnvoyNoHostnameException { + synchronized (this) { + final AuthenticationStore store = httpClient.getAuthenticationStore(); + final Result invertersResult = store.findAuthenticationResult(invertersURI); + + if (invertersResult != null) { + store.removeAuthenticationResult(invertersResult); + } + } + return retrieveData(INVERTERS_URL, json -> Arrays.asList(gson.fromJson(json, InverterDTO[].class))); + } + + private synchronized T retrieveData(final String urlPath, final Function jsonConverter) + throws EnvoyConnectionException, EnvoyNoHostnameException { + try { + if (hostname.isEmpty()) { + throw new EnvoyNoHostnameException("No host name/ip address known (yet)"); + } + final URI uri = URI.create(HTTP + hostname + urlPath); + logger.trace("Retrieving data from '{}'", uri); + final Request request = httpClient.newRequest(uri).method(HttpMethod.GET).timeout(CONNECT_TIMEOUT_SECONDS, + TimeUnit.SECONDS); + final ContentResponse response = request.send(); + final String content = response.getContentAsString(); + + logger.trace("Envoy returned data for '{}' with status {}: {}", urlPath, response.getStatus(), content); + try { + if (response.getStatus() == HttpStatus.OK_200) { + final T result = jsonConverter.apply(content); + if (result == null) { + throw new EnvoyConnectionException("No data received"); + } + return result; + } else { + final @Nullable EnvoyErrorDTO error = gson.fromJson(content, EnvoyErrorDTO.class); + + logger.debug("Envoy returned an error: {}", error); + throw new EnvoyConnectionException(error == null ? response.getReason() : error.info); + } + } catch (final JsonSyntaxException e) { + logger.debug("Error parsing json: {}", content, e); + throw new EnvoyConnectionException("Error parsing data: ", e); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new EnvoyConnectionException("Interrupted"); + } catch (final TimeoutException e) { + logger.debug("TimeoutException: {}", e.getMessage()); + throw new EnvoyConnectionException("Connection timeout: ", e); + } catch (final ExecutionException e) { + logger.debug("ExecutionException: {}", e.getMessage(), e); + throw new EnvoyConnectionException("Could not retrieve data: ", e.getCause()); + } + } +} diff --git a/bundles/org.openhab.binding.enphase/src/main/resources/OH-INF/binding/binding.xml b/bundles/org.openhab.binding.enphase/src/main/resources/OH-INF/binding/binding.xml new file mode 100644 index 0000000000000..ca4b31911c7ae --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/resources/OH-INF/binding/binding.xml @@ -0,0 +1,9 @@ + + + + Enphase Envoy Binding + This is the binding for Enphase Envoy solar panels. + + diff --git a/bundles/org.openhab.binding.enphase/src/main/resources/OH-INF/i18n/enphase_en.properties b/bundles/org.openhab.binding.enphase/src/main/resources/OH-INF/i18n/enphase_en.properties new file mode 100644 index 0000000000000..db42ff05d51e0 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/resources/OH-INF/i18n/enphase_en.properties @@ -0,0 +1,80 @@ +error.nodata=No Data +envoy.global.ok=Normal + +envoy.cond_flags.acb_ctrl.bmuhardwareerror=BMU Hardware Error +envoy.cond_flags.acb_ctrl.bmuimageerror=BMU Image Error +envoy.cond_flags.acb_ctrl.bmumaxcurrentwarning=BMU Max Current Warning +envoy.cond_flags.acb_ctrl.bmusenseerror=BMU Sense Error + +envoy.cond_flags.acb_ctrl.cellmaxtemperror=Cell Max Temperature Error +envoy.cond_flags.acb_ctrl.cellmaxtempwarning=Cell Max Temperature Warning +envoy.cond_flags.acb_ctrl.cellmaxvoltageerror=Cell Max Voltage Error +envoy.cond_flags.acb_ctrl.cellmaxvoltagewarning=Cell Max Voltage Warning +envoy.cond_flags.acb_ctrl.cellmintemperror=Cell Min Temperature Error +envoy.cond_flags.acb_ctrl.cellmintempwarning=Cell Min Temperature Warning +envoy.cond_flags.acb_ctrl.cellminvoltageerror=Cell Min Voltage Error +envoy.cond_flags.acb_ctrl.cellminvoltagewarning=Cell Min Voltage Warning +envoy.cond_flags.acb_ctrl.cibcanerror=CIB CAN Error +envoy.cond_flags.acb_ctrl.cibimageerror=CIB Image Error +envoy.cond_flags.acb_ctrl.cibspierror=CIB SPI Error" +envoy.cond_flags.obs_strs.discovering=Discovering +envoy.cond_flags.obs_strs.failure=Failure to report +envoy.cond_flags.obs_strs.flasherror=Flash Error +envoy.cond_flags.obs_strs.notmonitored=Not Monitored +envoy.cond_flags.obs_strs.ok=Normal +envoy.cond_flags.obs_strs.plmerror=PLM Error +envoy.cond_flags.obs_strs.secmodeenterfailure=Secure mode enter failure +envoy.cond_flags.obs_strs.secmodeexitfailure=Secure mode exit failure +envoy.cond_flags.obs_strs.sleeping=Sleeping" + +envoy.cond_flags.pcu_chan.acMonitorError=AC Monitor Error +envoy.cond_flags.pcu_chan.acfrequencyhigh=AC Frequency High +envoy.cond_flags.pcu_chan.acfrequencylow=AC Frequency Low +envoy.cond_flags.pcu_chan.acfrequencyoor=AC Frequency Out Of Range +envoy.cond_flags.pcu_chan.acvoltage_avg_hi=AC Voltage Average High +envoy.cond_flags.pcu_chan.acvoltagehigh=AC Voltage High +envoy.cond_flags.pcu_chan.acvoltagelow=AC Voltage Low +envoy.cond_flags.pcu_chan.acvoltageoor=AC Voltage Out Of Range +envoy.cond_flags.pcu_chan.acvoltageoosp1=AC Voltage Out Of Range - Phase 1 +envoy.cond_flags.pcu_chan.acvoltageoosp2=AC Voltage Out Of Range - Phase 2 +envoy.cond_flags.pcu_chan.acvoltageoosp3=AC Voltage Out Of Range - Phase 3 +envoy.cond_flags.pcu_chan.agfpowerlimiting=AGF Power Limiting +envoy.cond_flags.pcu_chan.dcresistancelow=DC Resistance Low +envoy.cond_flags.pcu_chan.dcresistancelowpoweroff=DC Resistance Low - Power Off +envoy.cond_flags.pcu_chan.dcvoltagetoohigh=DC Voltage Too High +envoy.cond_flags.pcu_chan.dcvoltagetoolow=DC Voltage Too Low +envoy.cond_flags.pcu_chan.dfdt=AC Frequency Changing too Fast +envoy.cond_flags.pcu_chan.gfitripped=GFI Tripped +envoy.cond_flags.pcu_chan.gridgone=Grid Gone +envoy.cond_flags.pcu_chan.gridinstability=Grid Instability +envoy.cond_flags.pcu_chan.gridoffsethi=Grid Offset Hi +envoy.cond_flags.pcu_chan.gridoffsetlow=Grid Offset Low +envoy.cond_flags.pcu_chan.hardwareError=Hardware Error +envoy.cond_flags.pcu_chan.hardwareWarning=Hardware Warning +envoy.cond_flags.pcu_chan.highskiprate=High Skip Rate +envoy.cond_flags.pcu_chan.invalidinterval=Invalid Interval +envoy.cond_flags.pcu_chan.pwrgenoffbycmd=Power generation off by command +envoy.cond_flags.pcu_chan.skippedcycles=Skipped Cycles +envoy.cond_flags.pcu_chan.vreferror=Voltage Ref Error" + +envoy.cond_flags.pcu_ctrl.alertactive=Alert Active +envoy.cond_flags.pcu_ctrl.altpwrgenmode=Alternate Power Generation Mode +envoy.cond_flags.pcu_ctrl.altvfsettings=Alternate Voltage and Frequency Settings +envoy.cond_flags.pcu_ctrl.badflashimage=Bad Flash Image +envoy.cond_flags.pcu_ctrl.bricked=No Grid Profile +envoy.cond_flags.pcu_ctrl.commandedreset=Commanded Reset +envoy.cond_flags.pcu_ctrl.criticaltemperature=Critical Temperature +envoy.cond_flags.pcu_ctrl.dc-pwr-low=DC Power Too Low +envoy.cond_flags.pcu_ctrl.iuplinkproblem=IUP Link Problem +envoy.cond_flags.pcu_ctrl.manutestmode=In Manu Test Mode +envoy.cond_flags.pcu_ctrl.nsync=Grid Perturbation Unsynchronized +envoy.cond_flags.pcu_ctrl.overtemperature=Over Temperature +envoy.cond_flags.pcu_ctrl.poweronreset=Power On Reset +envoy.cond_flags.pcu_ctrl.pwrgenoffbycmd=Power generation off by command +envoy.cond_flags.pcu_ctrl.runningonac=Running on AC +envoy.cond_flags.pcu_ctrl.tpmtest=Transient Grid Profile +envoy.cond_flags.pcu_ctrl.unexpectedreset=Unexpected Reset +envoy.cond_flags.pcu_ctrl.watchdogreset=Watchdog Reset + +envoy.cond_flags.rgm_chan.check_meter=Meter Error +envoy.cond_flags.rgm_chan.power_quality=Poor Power Quality diff --git a/bundles/org.openhab.binding.enphase/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.enphase/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 0000000000000..21d1a36fed798 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,228 @@ + + + + + + + Envoy gateway + + + + + Production data from the solar panels + + + + Consumption data from the solar panels + + + + serialNumber + + + + + The serial number of the Envoy gateway which can be found on the gateway + + + + The host name/ip address of the Envoy gateway. Leave empty to auto detect + true + + + + The user name to the Envoy gateway. Leave empty when using the default user name + envoy + true + + + password + + The password to the Envoy gateway. Leave empty when using the default password + true + + + + Period between updates. The default is 5 minutes, the refresh frequency of the Envoy itself + 5 + true + + + + + + + + + + + + Inverter + + + + + + + + + + + + + + + + + serialNumber + + + + + The serial number of the inverter + + + + + + + + + + + Network system relay controller + + + + + + + + + + + + + + + + + + + + + + + + serialNumber + + + + + The serial number of the inverter + + + + + + + + + + + + + + + + + Number:Energy + + Watt hours produced today + + + + Number:Energy + + Watt hours produced the last 7 days + + + + Number:Energy + + Watt hours produced over the lifetime + + + + Number:Power + + Latest watts produced + + + + + + Number:Power + + Last reported power delivery + + + + Number:Power + + Maximum reported power + + + + DateTime + + Date of last reported power delivery + + + + + + Contact + + + + + Contact + + When closed power line is connected + + + + + + String + + The status of the Enphase device + + + + + Switch + + + + + Switch + + + + + Switch + + + + + Switch + + + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index 96f2bad76e45b..f8b4b1e25999b 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -100,6 +100,7 @@ org.openhab.binding.energenie org.openhab.binding.enigma2 org.openhab.binding.enocean + org.openhab.binding.enphase org.openhab.binding.enturno org.openhab.binding.epsonprojector org.openhab.binding.etherrain