[enphase] Fix connection stability (#21100)

* [enphase] Read consumption from /ivp/meters/readings on firmware 7+

Signed-off-by: Andre Lackmann <andre@lackmann.net>
This commit is contained in:
Andre Lackmann
2026-07-09 23:06:15 +02:00
committed by GitHub
parent 9cbd1456eb
commit 65c38fdea9
6 changed files with 196 additions and 16 deletions
@@ -84,6 +84,10 @@ An example of a production channel name is: `production#wattsNow`.
| wattHoursLifetime | Number:Energy | Watt hours produced over the lifetime | | wattHoursLifetime | Number:Energy | Watt hours produced over the lifetime |
| wattsNow | Number:Power | Latest watts produced | | 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: The `inverter` thing has the following channels:
| channel | type | description | | channel | type | description |
@@ -18,8 +18,8 @@ package org.openhab.binding.enphase.internal.dto;
* @author Hilbrand Bouwkamp - Initial contribution * @author Hilbrand Bouwkamp - Initial contribution
*/ */
public class EnvoyEnergyDTO { public class EnvoyEnergyDTO {
public int wattHoursToday; public Integer wattHoursToday;
public int wattHoursSevenDays; public Integer wattHoursSevenDays;
public int wattHoursLifetime; public Integer wattHoursLifetime;
public int wattsNow; public Integer wattsNow;
} }
@@ -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;
}
@@ -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;
}
@@ -32,6 +32,9 @@ import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import javax.measure.Quantity;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient; 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.thing.util.ThingHandlerHelper;
import org.openhab.core.types.Command; import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType; import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType; import org.openhab.core.types.UnDefType;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -84,6 +88,9 @@ public class EnvoyBridgeHandler extends BaseBridgeHandler {
} }
private static final long RETRY_RECONNECT_SECONDS = 10; 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 Logger logger = LoggerFactory.getLogger(EnvoyBridgeHandler.class);
private final EnvoyHostAddressCache envoyHostnameCache; private final EnvoyHostAddressCache envoyHostnameCache;
@@ -93,6 +100,7 @@ public class EnvoyBridgeHandler extends BaseBridgeHandler {
private @Nullable ScheduledFuture<?> updataDataFuture; private @Nullable ScheduledFuture<?> updataDataFuture;
private @Nullable ScheduledFuture<?> updateHostnameFuture; private @Nullable ScheduledFuture<?> updateHostnameFuture;
private int consecutiveConnectionErrors;
private @Nullable ExpiringCache<Map<String, @Nullable InverterDTO>> invertersCache; private @Nullable ExpiringCache<Map<String, @Nullable InverterDTO>> invertersCache;
private @Nullable ExpiringCache<Map<String, @Nullable DeviceDTO>> devicesCache; private @Nullable ExpiringCache<Map<String, @Nullable DeviceDTO>> devicesCache;
private @Nullable EnvoyEnergyDTO productionDTO; private @Nullable EnvoyEnergyDTO productionDTO;
@@ -123,21 +131,29 @@ public class EnvoyBridgeHandler extends BaseBridgeHandler {
} else { } else {
switch (channelUID.getIdWithoutGroup()) { switch (channelUID.getIdWithoutGroup()) {
case ENVOY_WATT_HOURS_TODAY: case ENVOY_WATT_HOURS_TODAY:
updateState(channelUID, new QuantityType<>(data.wattHoursToday, Units.WATT_HOUR)); updateState(channelUID, toState(data.wattHoursToday, Units.WATT_HOUR));
break; break;
case ENVOY_WATT_HOURS_SEVEN_DAYS: case ENVOY_WATT_HOURS_SEVEN_DAYS:
updateState(channelUID, new QuantityType<>(data.wattHoursSevenDays, Units.WATT_HOUR)); updateState(channelUID, toState(data.wattHoursSevenDays, Units.WATT_HOUR));
break; break;
case ENVOY_WATT_HOURS_LIFETIME: case ENVOY_WATT_HOURS_LIFETIME:
updateState(channelUID, new QuantityType<>(data.wattHoursLifetime, Units.WATT_HOUR)); updateState(channelUID, toState(data.wattHoursLifetime, Units.WATT_HOUR));
break; break;
case ENVOY_WATTS_NOW: case ENVOY_WATTS_NOW:
updateState(channelUID, new QuantityType<>(data.wattsNow, Units.WATT)); updateState(channelUID, toState(data.wattsNow, Units.WATT));
break; 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 <Q extends Quantity<Q>> State toState(final @Nullable Integer value, final Unit<Q> unit) {
return value == null ? UnDefType.UNDEF : new QuantityType<>(value, unit);
}
@Override @Override
public Collection<Class<? extends ThingHandlerService>> getServices() { public Collection<Class<? extends ThingHandlerService>> getServices() {
return Set.of(EnphaseDevicesDiscoveryService.class); 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.", "This Ephase Envoy device ({}) doesn't seem to support json data. So not all channels are set.",
getThing().getUID()); getThing().getUID());
jsonSupported = FeatureStatus.UNSUPPORTED; jsonSupported = FeatureStatus.UNSUPPORTED;
} else if (consumptionSupported == FeatureStatus.SUPPORTED) { } else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); // 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) { } catch (final EnphaseException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
@@ -264,12 +283,22 @@ public class EnvoyBridgeHandler extends BaseBridgeHandler {
updateEnvoy(); updateEnvoy();
updateInverters(forceUpdate); updateInverters(forceUpdate);
updateDevices(forceUpdate); updateDevices(forceUpdate);
consecutiveConnectionErrors = 0;
} }
} catch (final EnvoyNoHostnameException e) { } catch (final EnvoyNoHostnameException e) {
scheduleHostnameUpdate(false); scheduleHostnameUpdate(false);
} catch (final EnvoyConnectionException e) { } catch (final EnvoyConnectionException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); // The Envoy local API is intermittently unresponsive on some firmware. Tolerate a few consecutive
scheduleHostnameUpdate(false); // 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) { } catch (final EntrezConnectionException e) {
logger.debug("EntrezConnectionException in Enphase thing {}: ", getThing().getUID(), e); logger.debug("EntrezConnectionException in Enphase thing {}: ", getThing().getUID(), e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
@@ -437,13 +466,22 @@ public class EnvoyBridgeHandler extends BaseBridgeHandler {
private void updateHostname() { private void updateHostname() {
final String lastKnownHostname = envoyHostnameCache.getLastKnownHostAddress(configuration.serialNumber); 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, 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."); "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); scheduleHostnameUpdate(true);
} else {
updateConfigurationOnHostnameUpdate(lastKnownHostname);
updateData(true);
} }
} }
@@ -15,8 +15,10 @@ package org.openhab.binding.enphase.internal.handler;
import java.net.HttpCookie; import java.net.HttpCookie;
import java.net.URI; import java.net.URI;
import java.time.Instant; import java.time.Instant;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@@ -30,6 +32,8 @@ import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod; import org.eclipse.jetty.http.HttpMethod;
import org.openhab.binding.enphase.internal.EnvoyConfiguration; import org.openhab.binding.enphase.internal.EnvoyConfiguration;
import org.openhab.binding.enphase.internal.dto.EnvoyEnergyDTO; 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;
import org.openhab.binding.enphase.internal.dto.PdmEnergyDTO.PdmProductionDTO; import org.openhab.binding.enphase.internal.dto.PdmEnergyDTO.PdmProductionDTO;
import org.openhab.binding.enphase.internal.exception.EnphaseException; 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 LOGIN_URL = "/auth/check_jwt";
private static final String SESSION_COOKIE_NAME = "session"; private static final String SESSION_COOKIE_NAME = "session";
private static final String IVP_PDM_ENERGY_URL = "/ivp/pdm/energy"; 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 static final EnvoyEnergyDTO NO_DATA = new EnvoyEnergyDTO();
private final Logger logger = LoggerFactory.getLogger(EnvoyEntrezConnector.class); private final Logger logger = LoggerFactory.getLogger(EnvoyEntrezConnector.class);
@@ -106,6 +116,80 @@ public class EnvoyEntrezConnector extends EnvoyConnector {
return gson.fromJson(json, PdmEnergyDTO.class); 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.
* <p>
* 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}).
* <p>
* 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<Long, String> meters = retrieveData(IVP_METERS_URL, this::jsonToEnabledMeters);
final Map<String, IvpMetersReadingsDTO> 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<Long, String> jsonToEnabledMeters(final String json) {
final IvpMetersDTO @Nullable [] meters = gson.fromJson(json, IvpMetersDTO[].class);
final Map<Long, String> 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<String, IvpMetersReadingsDTO> jsonToReadingsByType(final String json, final Map<Long, String> meters) {
final IvpMetersReadingsDTO @Nullable [] readings = gson.fromJson(json, IvpMetersReadingsDTO[].class);
final Map<String, IvpMetersReadingsDTO> 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 @Override
protected void constructRequest(final Request request) throws EnphaseException { protected void constructRequest(final Request request) throws EnphaseException {
// Check if we need a new session ID // Check if we need a new session ID