2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.enphase.internal.handler;
15 import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.CONFIG_HOSTNAME;
16 import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.ENVOY_CHANNELGROUP_CONSUMPTION;
17 import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.ENVOY_WATTS_NOW;
18 import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.ENVOY_WATT_HOURS_LIFETIME;
19 import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.ENVOY_WATT_HOURS_SEVEN_DAYS;
20 import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.ENVOY_WATT_HOURS_TODAY;
21 import static org.openhab.binding.enphase.internal.EnphaseBindingConstants.PROPERTY_VERSION;
23 import java.time.Duration;
24 import java.time.temporal.ChronoUnit;
25 import java.util.Collection;
26 import java.util.HashMap;
29 import java.util.concurrent.ScheduledFuture;
30 import java.util.concurrent.TimeUnit;
31 import java.util.function.Function;
32 import java.util.stream.Collectors;
33 import java.util.stream.Stream;
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.eclipse.jdt.annotation.Nullable;
37 import org.eclipse.jetty.client.HttpClient;
38 import org.openhab.binding.enphase.internal.EnphaseBindingConstants;
39 import org.openhab.binding.enphase.internal.EnvoyConfiguration;
40 import org.openhab.binding.enphase.internal.EnvoyHostAddressCache;
41 import org.openhab.binding.enphase.internal.discovery.EnphaseDevicesDiscoveryService;
42 import org.openhab.binding.enphase.internal.dto.EnvoyEnergyDTO;
43 import org.openhab.binding.enphase.internal.dto.InventoryJsonDTO.DeviceDTO;
44 import org.openhab.binding.enphase.internal.dto.InverterDTO;
45 import org.openhab.binding.enphase.internal.exception.EnphaseException;
46 import org.openhab.binding.enphase.internal.exception.EntrezConnectionException;
47 import org.openhab.binding.enphase.internal.exception.EntrezJwtInvalidException;
48 import org.openhab.binding.enphase.internal.exception.EnvoyConnectionException;
49 import org.openhab.binding.enphase.internal.exception.EnvoyNoHostnameException;
50 import org.openhab.core.cache.ExpiringCache;
51 import org.openhab.core.config.core.Configuration;
52 import org.openhab.core.library.types.QuantityType;
53 import org.openhab.core.library.unit.Units;
54 import org.openhab.core.thing.Bridge;
55 import org.openhab.core.thing.Channel;
56 import org.openhab.core.thing.ChannelUID;
57 import org.openhab.core.thing.Thing;
58 import org.openhab.core.thing.ThingStatus;
59 import org.openhab.core.thing.ThingStatusDetail;
60 import org.openhab.core.thing.binding.BaseBridgeHandler;
61 import org.openhab.core.thing.binding.ThingHandler;
62 import org.openhab.core.thing.binding.ThingHandlerService;
63 import org.openhab.core.thing.binding.builder.BridgeBuilder;
64 import org.openhab.core.thing.util.ThingHandlerHelper;
65 import org.openhab.core.types.Command;
66 import org.openhab.core.types.RefreshType;
67 import org.openhab.core.types.UnDefType;
68 import org.slf4j.Logger;
69 import org.slf4j.LoggerFactory;
72 * BridgeHandler for the Envoy gateway.
74 * @author Thomas Hentschel - Initial contribution
75 * @author Hilbrand Bouwkamp - Initial contribution
78 public class EnvoyBridgeHandler extends BaseBridgeHandler {
80 private enum FeatureStatus {
86 private static final long RETRY_RECONNECT_SECONDS = 10;
88 private final Logger logger = LoggerFactory.getLogger(EnvoyBridgeHandler.class);
89 private final EnvoyHostAddressCache envoyHostnameCache;
90 private final EnvoyConnectorWrapper connectorWrapper;
92 private EnvoyConfiguration configuration = new EnvoyConfiguration();
94 private @Nullable ScheduledFuture<?> updataDataFuture;
95 private @Nullable ScheduledFuture<?> updateHostnameFuture;
96 private @Nullable ExpiringCache<Map<String, @Nullable InverterDTO>> invertersCache;
97 private @Nullable ExpiringCache<Map<String, @Nullable DeviceDTO>> devicesCache;
98 private @Nullable EnvoyEnergyDTO productionDTO;
99 private @Nullable EnvoyEnergyDTO consumptionDTO;
100 private FeatureStatus consumptionSupported = FeatureStatus.UNKNOWN;
101 private FeatureStatus jsonSupported = FeatureStatus.UNKNOWN;
103 public EnvoyBridgeHandler(final Bridge thing, final HttpClient httpClient,
104 final EnvoyHostAddressCache envoyHostAddressCache) {
106 this.envoyHostnameCache = envoyHostAddressCache;
107 connectorWrapper = new EnvoyConnectorWrapper(httpClient);
111 public void handleCommand(final ChannelUID channelUID, final Command command) {
112 if (command instanceof RefreshType) {
117 private void refresh(final ChannelUID channelUID) {
118 final EnvoyEnergyDTO data = ENVOY_CHANNELGROUP_CONSUMPTION.equals(channelUID.getGroupId()) ? consumptionDTO
122 updateState(channelUID, UnDefType.UNDEF);
124 switch (channelUID.getIdWithoutGroup()) {
125 case ENVOY_WATT_HOURS_TODAY:
126 updateState(channelUID, new QuantityType<>(data.wattHoursToday, Units.WATT_HOUR));
128 case ENVOY_WATT_HOURS_SEVEN_DAYS:
129 updateState(channelUID, new QuantityType<>(data.wattHoursSevenDays, Units.WATT_HOUR));
131 case ENVOY_WATT_HOURS_LIFETIME:
132 updateState(channelUID, new QuantityType<>(data.wattHoursLifetime, Units.WATT_HOUR));
134 case ENVOY_WATTS_NOW:
135 updateState(channelUID, new QuantityType<>(data.wattsNow, Units.WATT));
142 public Collection<Class<? extends ThingHandlerService>> getServices() {
143 return Set.of(EnphaseDevicesDiscoveryService.class);
147 public void initialize() {
148 configuration = getConfigAs(EnvoyConfiguration.class);
149 if (!EnphaseBindingConstants.isValidSerial(configuration.serialNumber)) {
150 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Serial number is not valid");
153 updateStatus(ThingStatus.UNKNOWN);
154 consumptionSupported = FeatureStatus.UNKNOWN;
155 jsonSupported = FeatureStatus.UNKNOWN;
156 invertersCache = new ExpiringCache<>(Duration.of(configuration.refresh, ChronoUnit.MINUTES),
157 this::refreshInverters);
158 devicesCache = new ExpiringCache<>(Duration.of(configuration.refresh, ChronoUnit.MINUTES),
159 this::refreshDevices);
160 connectorWrapper.setVersion(getVersion());
161 updataDataFuture = scheduler.scheduleWithFixedDelay(() -> updateData(false), 0, configuration.refresh,
166 * Method called by the ExpiringCache when no inverter data is present to get the data from the Envoy gateway.
167 * When there are connection problems it will start a scheduled job to try to reconnect to the
169 * @return the inverter data from the Envoy gateway or null if no data is available.
171 private @Nullable Map<String, @Nullable InverterDTO> refreshInverters() {
173 return connectorWrapper.getConnector().getInverters().stream()
174 .collect(Collectors.toMap(InverterDTO::getSerialNumber, Function.identity()));
175 } catch (final EnvoyNoHostnameException e) {
176 // ignore hostname exception here. It's already handled by others.
177 } catch (final EnvoyConnectionException e) {
178 logger.trace("refreshInverters connection problem", e);
179 } catch (final EnphaseException e) {
180 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
185 private @Nullable final Map<String, @Nullable DeviceDTO> refreshDevices() {
187 if (jsonSupported != FeatureStatus.UNSUPPORTED) {
188 final Map<String, @Nullable DeviceDTO> devicesData = connectorWrapper.getConnector().getInventoryJson()
189 .stream().flatMap(inv -> Stream.of(inv.devices).map(d -> {
192 })).collect(Collectors.toMap(DeviceDTO::getSerialNumber, Function.identity()));
194 jsonSupported = FeatureStatus.SUPPORTED;
197 } catch (final EnvoyNoHostnameException e) {
198 // ignore hostname exception here. It's already handled by others.
199 } catch (final EnvoyConnectionException e) {
200 if (jsonSupported == FeatureStatus.UNKNOWN) {
202 "This Ephase Envoy device ({}) doesn't seem to support json data. So not all channels are set.",
203 getThing().getUID());
204 jsonSupported = FeatureStatus.UNSUPPORTED;
205 } else if (consumptionSupported == FeatureStatus.SUPPORTED) {
206 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
208 } catch (final EnphaseException e) {
209 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
215 * Returns the data for the inverters. It get the data from cache or updates the cache if possible in case no data
218 * @param force force a cache refresh
219 * @return data if present or null
221 public @Nullable Map<String, @Nullable InverterDTO> getInvertersData(final boolean force) {
222 final ExpiringCache<Map<String, @Nullable InverterDTO>> invertersCache = this.invertersCache;
224 if (invertersCache == null || !isOnline()) {
228 invertersCache.invalidateValue();
230 return invertersCache.getValue();
235 * Returns the data for the devices. It get the data from cache or updates the cache if possible in case no data
238 * @param force force a cache refresh
239 * @return data if present or null
241 public @Nullable Map<String, @Nullable DeviceDTO> getDevices(final boolean force) {
242 final ExpiringCache<Map<String, @Nullable DeviceDTO>> devicesCache = this.devicesCache;
244 if (devicesCache == null || !isOnline()) {
248 devicesCache.invalidateValue();
250 return devicesCache.getValue();
255 * Method called by the refresh thread.
257 public synchronized void updateData(final boolean forceUpdate) {
259 if (!ThingHandlerHelper.isHandlerInitialized(this)) {
260 logger.debug("Not updating anything. Not initialized: {}", getThing().getStatus());
263 if (checkConnection()) {
265 updateInverters(forceUpdate);
266 updateDevices(forceUpdate);
268 } catch (final EnvoyNoHostnameException e) {
269 scheduleHostnameUpdate(false);
270 } catch (final EnvoyConnectionException e) {
271 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
272 scheduleHostnameUpdate(false);
273 } catch (final EntrezConnectionException e) {
274 logger.debug("EntrezConnectionException in Enphase thing {}: ", getThing().getUID(), e);
275 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
276 } catch (final EntrezJwtInvalidException e) {
277 logger.debug("EntrezJwtInvalidException in Enphase thing {}: ", getThing().getUID(), e);
278 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
279 } catch (final EnphaseException e) {
280 logger.debug("EnphaseException in Enphase thing {}: ", getThing().getUID(), e);
281 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
282 } catch (final RuntimeException e) {
283 logger.debug("Unexpected error in Enphase thing {}: ", getThing().getUID(), e);
284 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
289 * Checks if there is an active connection. If the configuration isn't valid it will set the bridge offline and
292 * @return true if an active connection was found, else returns false
293 * @throws EnphaseException
295 private boolean checkConnection() throws EnphaseException {
296 logger.trace("Check connection");
297 if (connectorWrapper.hasConnection()) {
300 final String configurationError = connectorWrapper.setConnector(configuration);
302 if (configurationError.isBlank()) {
304 logger.trace("No configuration error");
307 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configurationError);
312 private @Nullable String getVersion() {
313 return getThing().getProperties().get(PROPERTY_VERSION);
316 private void updateVersion() {
317 if (getVersion() == null) {
318 final String version = connectorWrapper.getVersion();
320 if (version != null) {
321 final BridgeBuilder builder = editThing();
322 final Map<String, String> properties = new HashMap<>(thing.getProperties());
324 properties.put(PROPERTY_VERSION, version);
325 builder.withProperties(properties);
326 updateThing(builder.build());
331 private void updateEnvoy() throws EnphaseException {
332 productionDTO = connectorWrapper.getConnector().getProduction();
333 setConsumptionDTOData();
334 getThing().getChannels().stream().map(Channel::getUID).filter(this::isLinked).forEach(this::refresh);
335 if (isInitialized() && !isOnline()) {
336 updateStatus(ThingStatus.ONLINE);
341 * Retrieve consumption data if supported, and keep track if this feature is supported by the device.
343 private void setConsumptionDTOData() throws EnphaseException {
344 if (consumptionSupported != FeatureStatus.UNSUPPORTED && isOnline()) {
346 consumptionDTO = connectorWrapper.getConnector().getConsumption();
347 consumptionSupported = FeatureStatus.SUPPORTED;
348 } catch (final EnvoyNoHostnameException e) {
349 // ignore hostname exception here. It's already handled by others.
350 } catch (final EnvoyConnectionException e) {
351 if (consumptionSupported == FeatureStatus.UNKNOWN) {
353 "This Enphase Envoy device ({}) doesn't seem to support consumption data. So no consumption channels are set.",
354 getThing().getUID());
355 consumptionSupported = FeatureStatus.UNSUPPORTED;
356 } else if (consumptionSupported == FeatureStatus.SUPPORTED) {
364 * Updates channels of the inverter things with inverter specific data.
366 * @param forceUpdate if true forces to update the data, otherwise gets the data from the cache
368 private void updateInverters(final boolean forceUpdate) {
369 final Map<String, @Nullable InverterDTO> inverters = getInvertersData(forceUpdate);
371 if (inverters != null) {
372 getThing().getThings().stream().map(Thing::getHandler).filter(h -> h instanceof EnphaseInverterHandler)
373 .map(EnphaseInverterHandler.class::cast)
374 .forEach(invHandler -> updateInverter(inverters, invHandler));
378 private void updateInverter(final @Nullable Map<String, @Nullable InverterDTO> inverters,
379 final EnphaseInverterHandler invHandler) {
380 if (inverters == null) {
383 final InverterDTO inverterDTO = inverters.get(invHandler.getSerialNumber());
385 invHandler.refreshInverterChannels(inverterDTO);
386 if (jsonSupported == FeatureStatus.UNSUPPORTED) {
387 // if inventory json is supported device status is set in #updateDevices
388 invHandler.refreshDeviceStatus(inverterDTO != null);
393 * Updates channels of the device things with device specific data.
394 * This data is not available on all envoy devices.
396 * @param forceUpdate if true forces to update the data, otherwise gets the data from the cache
398 private void updateDevices(final boolean forceUpdate) {
399 final Map<String, @Nullable DeviceDTO> devices = getDevices(forceUpdate);
401 getThing().getThings().stream().map(Thing::getHandler).filter(h -> h instanceof EnphaseDeviceHandler)
402 .map(EnphaseDeviceHandler.class::cast).forEach(invHandler -> invHandler
403 .refreshDeviceState(devices == null ? null : devices.get(invHandler.getSerialNumber())));
407 * Schedules a hostname update, but only schedules the task when not yet running or forced.
408 * Force is used to reschedule the task and should only be used from within {@link #updateHostname()}.
410 * @param force if true will always schedule the task
412 private synchronized void scheduleHostnameUpdate(final boolean force) {
413 if (force || updateHostnameFuture == null) {
414 logger.debug("Schedule hostname/ip address update for thing {} in {} seconds.", getThing().getUID(),
415 RETRY_RECONNECT_SECONDS);
416 updateHostnameFuture = scheduler.schedule(this::updateHostname, RETRY_RECONNECT_SECONDS, TimeUnit.SECONDS);
421 public void childHandlerInitialized(final ThingHandler childHandler, final Thing childThing) {
422 if (childHandler instanceof EnphaseInverterHandler handler) {
423 updateInverter(getInvertersData(false), handler);
425 if (childHandler instanceof EnphaseDeviceHandler handler) {
426 final Map<String, @Nullable DeviceDTO> devices = getDevices(false);
428 if (devices != null) {
429 handler.refreshDeviceState(devices.get(handler.getSerialNumber()));
435 * Handles a host name / ip address update.
437 private void updateHostname() {
438 final String lastKnownHostname = envoyHostnameCache.getLastKnownHostAddress(configuration.serialNumber);
440 if (lastKnownHostname.isEmpty()) {
441 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
442 "No ip address known of the Envoy gateway. If this isn't updated in a few minutes run discovery scan or check your connection.");
443 scheduleHostnameUpdate(true);
445 updateConfigurationOnHostnameUpdate(lastKnownHostname);
450 private void updateConfigurationOnHostnameUpdate(final String lastKnownHostname) {
451 final Configuration config = editConfiguration();
453 config.put(CONFIG_HOSTNAME, lastKnownHostname);
454 logger.info("Enphase Envoy ({}) hostname/ip address set to {}", getThing().getUID(), lastKnownHostname);
455 configuration.hostname = lastKnownHostname;
456 updateConfiguration(config);
457 // The task is done so the future can be released by setting it to null.
458 updateHostnameFuture = null;
462 public void dispose() {
463 final ScheduledFuture<?> retryFuture = this.updateHostnameFuture;
464 if (retryFuture != null) {
465 retryFuture.cancel(true);
467 final ScheduledFuture<?> inverterFuture = this.updataDataFuture;
469 if (inverterFuture != null) {
470 inverterFuture.cancel(true);
475 * @return Returns true if the bridge is online and not has a configuration pending.
477 public boolean isOnline() {
478 return getThing().getStatus() == ThingStatus.ONLINE;
482 public String toString() {
483 return "EnvoyBridgeHandler(" + thing.getUID() + ") Status: " + thing.getStatus();