mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 04:24:23 +02:00
[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:
@@ -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 |
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
|
||||
+26
@@ -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;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+50
-12
@@ -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<Map<String, @Nullable InverterDTO>> invertersCache;
|
||||
private @Nullable ExpiringCache<Map<String, @Nullable DeviceDTO>> 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 <Q extends Quantity<Q>> State toState(final @Nullable Integer value, final Unit<Q> unit) {
|
||||
return value == null ? UnDefType.UNDEF : new QuantityType<>(value, unit);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Class<? extends ThingHandlerService>> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+84
@@ -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.
|
||||
* <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
|
||||
protected void constructRequest(final Request request) throws EnphaseException {
|
||||
// Check if we need a new session ID
|
||||
|
||||
Reference in New Issue
Block a user