From 65c38fdea9d609ee0aad0ce61261f86da5bc1d68 Mon Sep 17 00:00:00 2001 From: Andre Lackmann Date: Fri, 10 Jul 2026 07:06:15 +1000 Subject: [PATCH] [enphase] Fix connection stability (#21100) * [enphase] Read consumption from /ivp/meters/readings on firmware 7+ Signed-off-by: Andre Lackmann --- bundles/org.openhab.binding.enphase/README.md | 4 + .../enphase/internal/dto/EnvoyEnergyDTO.java | 8 +- .../enphase/internal/dto/IvpMetersDTO.java | 26 ++++++ .../internal/dto/IvpMetersReadingsDTO.java | 28 +++++++ .../internal/handler/EnvoyBridgeHandler.java | 62 +++++++++++--- .../handler/EnvoyEntrezConnector.java | 84 +++++++++++++++++++ 6 files changed, 196 insertions(+), 16 deletions(-) create mode 100644 bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/IvpMetersDTO.java create mode 100644 bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/IvpMetersReadingsDTO.java diff --git a/bundles/org.openhab.binding.enphase/README.md b/bundles/org.openhab.binding.enphase/README.md index 94bbef2a67..bf8f6c1c2a 100644 --- a/bundles/org.openhab.binding.enphase/README.md +++ b/bundles/org.openhab.binding.enphase/README.md @@ -84,6 +84,10 @@ An example of a production channel name is: `production#wattsNow`. | wattHoursLifetime | Number:Energy | Watt hours produced over the lifetime | | wattsNow | Number:Power | Latest watts produced | +On Envoy gateways with version 7 firmware and higher the `consumption` data is read from the metering endpoint. +This endpoint only provides the instantaneous `wattsNow` value (and `wattHoursLifetime` when a total-consumption meter is present). +The `wattHoursToday` and `wattHoursSevenDays` consumption channels are not available from this endpoint and stay undefined. + The `inverter` thing has the following channels: | channel | type | description | 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 index 784cca1d3a..5de95a2585 100644 --- 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 @@ -18,8 +18,8 @@ package org.openhab.binding.enphase.internal.dto; * @author Hilbrand Bouwkamp - Initial contribution */ public class EnvoyEnergyDTO { - public int wattHoursToday; - public int wattHoursSevenDays; - public int wattHoursLifetime; - public int wattsNow; + public Integer wattHoursToday; + public Integer wattHoursSevenDays; + public Integer wattHoursLifetime; + public Integer wattsNow; } diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/IvpMetersDTO.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/IvpMetersDTO.java new file mode 100644 index 0000000000..a2c2767651 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/IvpMetersDTO.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2010-2026 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 a meter as returned by the ivp/meters api call. Used to map the meter id ({@code eid}) to the type of + * measurement the meter reports. + * + * @author Andre Lackmann - Initial contribution + */ +public class IvpMetersDTO { + public long eid; + public String state; + public String measurementType; + public String meteringStatus; +} diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/IvpMetersReadingsDTO.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/IvpMetersReadingsDTO.java new file mode 100644 index 0000000000..7ce1b4ec42 --- /dev/null +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/dto/IvpMetersReadingsDTO.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2026 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 a meter reading as returned by the ivp/meters/readings api call. The {@code eid} maps to a meter from + * the ivp/meters call. {@code activePower} is the instantaneous power in Watt and {@code actEnergyDlvd} the cumulative + * delivered energy in Watt hour. + * + * @author Andre Lackmann - Initial contribution + */ +public class IvpMetersReadingsDTO { + public long eid; + public long timestamp; + public double actEnergyDlvd; + public double actEnergyRcvd; + public double activePower; +} 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 index 83fa6ed09e..fb5abacf00 100644 --- 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 @@ -32,6 +32,9 @@ import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; +import javax.measure.Quantity; +import javax.measure.Unit; + import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.HttpClient; @@ -64,6 +67,7 @@ import org.openhab.core.thing.binding.builder.BridgeBuilder; import org.openhab.core.thing.util.ThingHandlerHelper; import org.openhab.core.types.Command; import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; import org.openhab.core.types.UnDefType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -84,6 +88,9 @@ public class EnvoyBridgeHandler extends BaseBridgeHandler { } private static final long RETRY_RECONNECT_SECONDS = 10; + // Number of consecutive connection failures tolerated before the bridge is taken offline. The Envoy local API can + // be briefly unresponsive (e.g. during nightly firmware maintenance); a single timeout should not flap the bridge. + private static final int MAX_TRANSIENT_CONNECTION_ERRORS = 3; private final Logger logger = LoggerFactory.getLogger(EnvoyBridgeHandler.class); private final EnvoyHostAddressCache envoyHostnameCache; @@ -93,6 +100,7 @@ public class EnvoyBridgeHandler extends BaseBridgeHandler { private @Nullable ScheduledFuture updataDataFuture; private @Nullable ScheduledFuture updateHostnameFuture; + private int consecutiveConnectionErrors; private @Nullable ExpiringCache> invertersCache; private @Nullable ExpiringCache> devicesCache; private @Nullable EnvoyEnergyDTO productionDTO; @@ -123,21 +131,29 @@ public class EnvoyBridgeHandler extends BaseBridgeHandler { } else { switch (channelUID.getIdWithoutGroup()) { case ENVOY_WATT_HOURS_TODAY: - updateState(channelUID, new QuantityType<>(data.wattHoursToday, Units.WATT_HOUR)); + updateState(channelUID, toState(data.wattHoursToday, Units.WATT_HOUR)); break; case ENVOY_WATT_HOURS_SEVEN_DAYS: - updateState(channelUID, new QuantityType<>(data.wattHoursSevenDays, Units.WATT_HOUR)); + updateState(channelUID, toState(data.wattHoursSevenDays, Units.WATT_HOUR)); break; case ENVOY_WATT_HOURS_LIFETIME: - updateState(channelUID, new QuantityType<>(data.wattHoursLifetime, Units.WATT_HOUR)); + updateState(channelUID, toState(data.wattHoursLifetime, Units.WATT_HOUR)); break; case ENVOY_WATTS_NOW: - updateState(channelUID, new QuantityType<>(data.wattsNow, Units.WATT)); + updateState(channelUID, toState(data.wattsNow, Units.WATT)); break; } } } + /** + * Converts a possibly {@code null} measured value to a {@link QuantityType} state, or {@link UnDefType#UNDEF} when + * the value is not available (e.g. energy buckets that the meter readings endpoint does not provide). + */ + private > State toState(final @Nullable Integer value, final Unit unit) { + return value == null ? UnDefType.UNDEF : new QuantityType<>(value, unit); + } + @Override public Collection> getServices() { return Set.of(EnphaseDevicesDiscoveryService.class); @@ -202,8 +218,11 @@ public class EnvoyBridgeHandler extends BaseBridgeHandler { "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()); + } else { + // Inventory/device data is optional and the endpoint can be intermittently unavailable. Don't take the + // bridge offline (which would also gate production/consumption updates); keep the last known data. A + // genuinely unreachable gateway is detected by the production call in the main update path. + logger.trace("refreshDevices connection problem", e); } } catch (final EnphaseException e) { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); @@ -264,12 +283,22 @@ public class EnvoyBridgeHandler extends BaseBridgeHandler { updateEnvoy(); updateInverters(forceUpdate); updateDevices(forceUpdate); + consecutiveConnectionErrors = 0; } } catch (final EnvoyNoHostnameException e) { scheduleHostnameUpdate(false); } catch (final EnvoyConnectionException e) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); - scheduleHostnameUpdate(false); + // The Envoy local API is intermittently unresponsive on some firmware. Tolerate a few consecutive + // timeouts (keeping the last known values and the bridge online) before treating it as a real outage, so a + // brief blip doesn't flap the bridge - and its child things - offline or trigger a hostname rediscovery. + if (++consecutiveConnectionErrors < MAX_TRANSIENT_CONNECTION_ERRORS) { + logger.debug("Transient connection problem ({}/{}) for Enphase thing {}, keeping bridge online: {}", + consecutiveConnectionErrors, MAX_TRANSIENT_CONNECTION_ERRORS, getThing().getUID(), + e.getMessage()); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + scheduleHostnameUpdate(false); + } } catch (final EntrezConnectionException e) { logger.debug("EntrezConnectionException in Enphase thing {}: ", getThing().getUID(), e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); @@ -437,13 +466,22 @@ public class EnvoyBridgeHandler extends BaseBridgeHandler { private void updateHostname() { final String lastKnownHostname = envoyHostnameCache.getLastKnownHostAddress(configuration.serialNumber); - if (lastKnownHostname.isEmpty()) { + if (!lastKnownHostname.isEmpty()) { + updateConfigurationOnHostnameUpdate(lastKnownHostname); + updateData(true); + } else if (!configuration.hostname.isEmpty()) { + // No address from mDNS discovery, but a hostname is configured. Retry with the configured hostname rather + // than flapping the bridge OFFLINE with "No ip address known"; the gateway is most likely just temporarily + // unreachable (e.g. firmware maintenance) and will respond again shortly. updateData reschedules another + // attempt if it still fails. + logger.debug("No mDNS address for {}, retrying the configured hostname '{}'.", getThing().getUID(), + configuration.hostname); + updateHostnameFuture = null; + updateData(true); + } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "No ip address known of the Envoy gateway. If this isn't updated in a few minutes run discovery scan or check your connection."); scheduleHostnameUpdate(true); - } else { - updateConfigurationOnHostnameUpdate(lastKnownHostname); - updateData(true); } } diff --git a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnvoyEntrezConnector.java b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnvoyEntrezConnector.java index 1a68571a26..fdca7eb2c9 100644 --- a/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnvoyEntrezConnector.java +++ b/bundles/org.openhab.binding.enphase/src/main/java/org/openhab/binding/enphase/internal/handler/EnvoyEntrezConnector.java @@ -15,8 +15,10 @@ package org.openhab.binding.enphase.internal.handler; import java.net.HttpCookie; import java.net.URI; import java.time.Instant; +import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -30,6 +32,8 @@ import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpMethod; import org.openhab.binding.enphase.internal.EnvoyConfiguration; import org.openhab.binding.enphase.internal.dto.EnvoyEnergyDTO; +import org.openhab.binding.enphase.internal.dto.IvpMetersDTO; +import org.openhab.binding.enphase.internal.dto.IvpMetersReadingsDTO; import org.openhab.binding.enphase.internal.dto.PdmEnergyDTO; import org.openhab.binding.enphase.internal.dto.PdmEnergyDTO.PdmProductionDTO; import org.openhab.binding.enphase.internal.exception.EnphaseException; @@ -52,6 +56,12 @@ public class EnvoyEntrezConnector extends EnvoyConnector { private static final String LOGIN_URL = "/auth/check_jwt"; private static final String SESSION_COOKIE_NAME = "session"; private static final String IVP_PDM_ENERGY_URL = "/ivp/pdm/energy"; + private static final String IVP_METERS_URL = "/ivp/meters"; + private static final String IVP_METERS_READINGS_URL = "/ivp/meters/readings"; + private static final String METER_STATE_ENABLED = "enabled"; + private static final String MEASUREMENT_TYPE_PRODUCTION = "production"; + private static final String MEASUREMENT_TYPE_NET_CONSUMPTION = "net-consumption"; + private static final String MEASUREMENT_TYPE_TOTAL_CONSUMPTION = "total-consumption"; private static final EnvoyEnergyDTO NO_DATA = new EnvoyEnergyDTO(); private final Logger logger = LoggerFactory.getLogger(EnvoyEntrezConnector.class); @@ -106,6 +116,80 @@ public class EnvoyEntrezConnector extends EnvoyConnector { return gson.fromJson(json, PdmEnergyDTO.class); } + /** + * Retrieves consumption data from the {@code /ivp/meters/readings} endpoint instead of the + * {@code /api/v1/consumption} endpoint used by the base connector. On firmware version 7 and higher the + * {@code /api/v1/consumption} (and {@code /ivp/pdm/energy}) aggregated consumption values can stop tracking and + * report a constant, stale value after a firmware update, while the meter readings keep reporting live data. + *

+ * When the gateway has a {@code total-consumption} meter its reading is used directly. Otherwise the household load + * is derived from the {@code production} and {@code net-consumption} meters ({@code total = production + net}). + *

+ * Only the instantaneous power (and the lifetime energy when a {@code total-consumption} meter is present) are + * available from this endpoint; the daily and seven-day energy buckets are not provided and are reported as + * undefined. + * + * @return the consumption data from the Envoy gateway + */ + @Override + public EnvoyEnergyDTO getConsumption() throws EnphaseException { + final Map meters = retrieveData(IVP_METERS_URL, this::jsonToEnabledMeters); + final Map readings = retrieveData(IVP_METERS_READINGS_URL, + json -> jsonToReadingsByType(json, meters)); + + final IvpMetersReadingsDTO totalConsumption = readings.get(MEASUREMENT_TYPE_TOTAL_CONSUMPTION); + if (totalConsumption != null) { + return consumptionDTO((int) Math.round(totalConsumption.activePower), + (int) Math.round(totalConsumption.actEnergyDlvd)); + } + final IvpMetersReadingsDTO production = readings.get(MEASUREMENT_TYPE_PRODUCTION); + final IvpMetersReadingsDTO netConsumption = readings.get(MEASUREMENT_TYPE_NET_CONSUMPTION); + if (production != null && netConsumption != null) { + return consumptionDTO((int) Math.round(production.activePower + netConsumption.activePower), null); + } + throw new EnvoyConnectionException("No consumption meter data available from the Envoy."); + } + + private Map jsonToEnabledMeters(final String json) { + final IvpMetersDTO @Nullable [] meters = gson.fromJson(json, IvpMetersDTO[].class); + final Map map = new HashMap<>(); + + if (meters != null) { + for (final IvpMetersDTO meter : meters) { + if (METER_STATE_ENABLED.equals(meter.state) && meter.measurementType != null) { + map.put(meter.eid, meter.measurementType); + } + } + } + return map; + } + + private Map jsonToReadingsByType(final String json, final Map meters) { + final IvpMetersReadingsDTO @Nullable [] readings = gson.fromJson(json, IvpMetersReadingsDTO[].class); + final Map map = new HashMap<>(); + + if (readings != null) { + for (final IvpMetersReadingsDTO reading : readings) { + final String measurementType = meters.get(reading.eid); + if (measurementType != null) { + map.put(measurementType, reading); + } + } + } + return map; + } + + private EnvoyEnergyDTO consumptionDTO(final int wattsNow, final @Nullable Integer wattHoursLifetime) { + final EnvoyEnergyDTO dto = new EnvoyEnergyDTO(); + + dto.wattsNow = wattsNow; + dto.wattHoursLifetime = wattHoursLifetime; + // The /ivp/meters/readings endpoint does not provide daily or seven-day energy buckets. + dto.wattHoursToday = null; + dto.wattHoursSevenDays = null; + return dto; + } + @Override protected void constructRequest(final Request request) throws EnphaseException { // Check if we need a new session ID