]> git.basschouten.com Git - openhab-addons.git/blob
e24474944caa8e2665fb377a1c78471e2366f301
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.enphase.internal.handler;
14
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;
22
23 import java.time.Duration;
24 import java.time.temporal.ChronoUnit;
25 import java.util.Collection;
26 import java.util.HashMap;
27 import java.util.Map;
28 import java.util.Set;
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;
34
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;
70
71 /**
72  * BridgeHandler for the Envoy gateway.
73  *
74  * @author Thomas Hentschel - Initial contribution
75  * @author Hilbrand Bouwkamp - Initial contribution
76  */
77 @NonNullByDefault
78 public class EnvoyBridgeHandler extends BaseBridgeHandler {
79
80     private enum FeatureStatus {
81         UNKNOWN,
82         SUPPORTED,
83         UNSUPPORTED
84     }
85
86     private static final long RETRY_RECONNECT_SECONDS = 10;
87
88     private final Logger logger = LoggerFactory.getLogger(EnvoyBridgeHandler.class);
89     private final EnvoyHostAddressCache envoyHostnameCache;
90     private final EnvoyConnectorWrapper connectorWrapper;
91
92     private EnvoyConfiguration configuration = new EnvoyConfiguration();
93
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;
102
103     public EnvoyBridgeHandler(final Bridge thing, final HttpClient httpClient,
104             final EnvoyHostAddressCache envoyHostAddressCache) {
105         super(thing);
106         this.envoyHostnameCache = envoyHostAddressCache;
107         connectorWrapper = new EnvoyConnectorWrapper(httpClient);
108     }
109
110     @Override
111     public void handleCommand(final ChannelUID channelUID, final Command command) {
112         if (command instanceof RefreshType) {
113             refresh(channelUID);
114         }
115     }
116
117     private void refresh(final ChannelUID channelUID) {
118         final EnvoyEnergyDTO data = ENVOY_CHANNELGROUP_CONSUMPTION.equals(channelUID.getGroupId()) ? consumptionDTO
119                 : productionDTO;
120
121         if (data == null) {
122             updateState(channelUID, UnDefType.UNDEF);
123         } else {
124             switch (channelUID.getIdWithoutGroup()) {
125                 case ENVOY_WATT_HOURS_TODAY:
126                     updateState(channelUID, new QuantityType<>(data.wattHoursToday, Units.WATT_HOUR));
127                     break;
128                 case ENVOY_WATT_HOURS_SEVEN_DAYS:
129                     updateState(channelUID, new QuantityType<>(data.wattHoursSevenDays, Units.WATT_HOUR));
130                     break;
131                 case ENVOY_WATT_HOURS_LIFETIME:
132                     updateState(channelUID, new QuantityType<>(data.wattHoursLifetime, Units.WATT_HOUR));
133                     break;
134                 case ENVOY_WATTS_NOW:
135                     updateState(channelUID, new QuantityType<>(data.wattsNow, Units.WATT));
136                     break;
137             }
138         }
139     }
140
141     @Override
142     public Collection<Class<? extends ThingHandlerService>> getServices() {
143         return Set.of(EnphaseDevicesDiscoveryService.class);
144     }
145
146     @Override
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");
151             return;
152         }
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,
162                 TimeUnit.MINUTES);
163     }
164
165     /**
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
168      *
169      * @return the inverter data from the Envoy gateway or null if no data is available.
170      */
171     private @Nullable Map<String, @Nullable InverterDTO> refreshInverters() {
172         try {
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());
181         }
182         return null;
183     }
184
185     private @Nullable final Map<String, @Nullable DeviceDTO> refreshDevices() {
186         try {
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 -> {
190                             d.type = inv.type;
191                             return d;
192                         })).collect(Collectors.toMap(DeviceDTO::getSerialNumber, Function.identity()));
193
194                 jsonSupported = FeatureStatus.SUPPORTED;
195                 return devicesData;
196             }
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) {
201                 logger.info(
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());
207             }
208         } catch (final EnphaseException e) {
209             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
210         }
211         return null;
212     }
213
214     /**
215      * Returns the data for the inverters. It get the data from cache or updates the cache if possible in case no data
216      * is available.
217      *
218      * @param force force a cache refresh
219      * @return data if present or null
220      */
221     public @Nullable Map<String, @Nullable InverterDTO> getInvertersData(final boolean force) {
222         final ExpiringCache<Map<String, @Nullable InverterDTO>> invertersCache = this.invertersCache;
223
224         if (invertersCache == null || !isOnline()) {
225             return null;
226         } else {
227             if (force) {
228                 invertersCache.invalidateValue();
229             }
230             return invertersCache.getValue();
231         }
232     }
233
234     /**
235      * Returns the data for the devices. It get the data from cache or updates the cache if possible in case no data
236      * is available.
237      *
238      * @param force force a cache refresh
239      * @return data if present or null
240      */
241     public @Nullable Map<String, @Nullable DeviceDTO> getDevices(final boolean force) {
242         final ExpiringCache<Map<String, @Nullable DeviceDTO>> devicesCache = this.devicesCache;
243
244         if (devicesCache == null || !isOnline()) {
245             return null;
246         } else {
247             if (force) {
248                 devicesCache.invalidateValue();
249             }
250             return devicesCache.getValue();
251         }
252     }
253
254     /**
255      * Method called by the refresh thread.
256      */
257     public synchronized void updateData(final boolean forceUpdate) {
258         try {
259             if (!ThingHandlerHelper.isHandlerInitialized(this)) {
260                 logger.debug("Not updating anything. Not initialized: {}", getThing().getStatus());
261                 return;
262             }
263             if (checkConnection()) {
264                 updateEnvoy();
265                 updateInverters(forceUpdate);
266                 updateDevices(forceUpdate);
267             }
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());
285         }
286     }
287
288     /**
289      * Checks if there is an active connection. If the configuration isn't valid it will set the bridge offline and
290      * return false.
291      *
292      * @return true if an active connection was found, else returns false
293      * @throws EnphaseException
294      */
295     private boolean checkConnection() throws EnphaseException {
296         logger.trace("Check connection");
297         if (connectorWrapper.hasConnection()) {
298             return true;
299         }
300         final String configurationError = connectorWrapper.setConnector(configuration);
301
302         if (configurationError.isBlank()) {
303             updateVersion();
304             logger.trace("No configuration error");
305             return true;
306         } else {
307             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configurationError);
308             return false;
309         }
310     }
311
312     private @Nullable String getVersion() {
313         return getThing().getProperties().get(PROPERTY_VERSION);
314     }
315
316     private void updateVersion() {
317         if (getVersion() == null) {
318             final String version = connectorWrapper.getVersion();
319
320             if (version != null) {
321                 final BridgeBuilder builder = editThing();
322                 final Map<String, String> properties = new HashMap<>(thing.getProperties());
323
324                 properties.put(PROPERTY_VERSION, version);
325                 builder.withProperties(properties);
326                 updateThing(builder.build());
327             }
328         }
329     }
330
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);
337         }
338     }
339
340     /**
341      * Retrieve consumption data if supported, and keep track if this feature is supported by the device.
342      */
343     private void setConsumptionDTOData() throws EnphaseException {
344         if (consumptionSupported != FeatureStatus.UNSUPPORTED && isOnline()) {
345             try {
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) {
352                     logger.info(
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) {
357                     throw e;
358                 }
359             }
360         }
361     }
362
363     /**
364      * Updates channels of the inverter things with inverter specific data.
365      *
366      * @param forceUpdate if true forces to update the data, otherwise gets the data from the cache
367      */
368     private void updateInverters(final boolean forceUpdate) {
369         final Map<String, @Nullable InverterDTO> inverters = getInvertersData(forceUpdate);
370
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));
375         }
376     }
377
378     private void updateInverter(final @Nullable Map<String, @Nullable InverterDTO> inverters,
379             final EnphaseInverterHandler invHandler) {
380         if (inverters == null) {
381             return;
382         }
383         final InverterDTO inverterDTO = inverters.get(invHandler.getSerialNumber());
384
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);
389         }
390     }
391
392     /**
393      * Updates channels of the device things with device specific data.
394      * This data is not available on all envoy devices.
395      *
396      * @param forceUpdate if true forces to update the data, otherwise gets the data from the cache
397      */
398     private void updateDevices(final boolean forceUpdate) {
399         final Map<String, @Nullable DeviceDTO> devices = getDevices(forceUpdate);
400
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())));
404     }
405
406     /**
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()}.
409      *
410      * @param force if true will always schedule the task
411      */
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);
417         }
418     }
419
420     @Override
421     public void childHandlerInitialized(final ThingHandler childHandler, final Thing childThing) {
422         if (childHandler instanceof EnphaseInverterHandler handler) {
423             updateInverter(getInvertersData(false), handler);
424         }
425         if (childHandler instanceof EnphaseDeviceHandler handler) {
426             final Map<String, @Nullable DeviceDTO> devices = getDevices(false);
427
428             if (devices != null) {
429                 handler.refreshDeviceState(devices.get(handler.getSerialNumber()));
430             }
431         }
432     }
433
434     /**
435      * Handles a host name / ip address update.
436      */
437     private void updateHostname() {
438         final String lastKnownHostname = envoyHostnameCache.getLastKnownHostAddress(configuration.serialNumber);
439
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);
444         } else {
445             updateConfigurationOnHostnameUpdate(lastKnownHostname);
446             updateData(true);
447         }
448     }
449
450     private void updateConfigurationOnHostnameUpdate(final String lastKnownHostname) {
451         final Configuration config = editConfiguration();
452
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;
459     }
460
461     @Override
462     public void dispose() {
463         final ScheduledFuture<?> retryFuture = this.updateHostnameFuture;
464         if (retryFuture != null) {
465             retryFuture.cancel(true);
466         }
467         final ScheduledFuture<?> inverterFuture = this.updataDataFuture;
468
469         if (inverterFuture != null) {
470             inverterFuture.cancel(true);
471         }
472     }
473
474     /**
475      * @return Returns true if the bridge is online and not has a configuration pending.
476      */
477     public boolean isOnline() {
478         return getThing().getStatus() == ThingStatus.ONLINE;
479     }
480
481     @Override
482     public String toString() {
483         return "EnvoyBridgeHandler(" + thing.getUID() + ") Status: " + thing.getStatus();
484     }
485 }