]> git.basschouten.com Git - openhab-addons.git/blob
4ebb1f6eca3243ed800aba84075248ffa7bea3a2
[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
22 import java.time.Duration;
23 import java.time.temporal.ChronoUnit;
24 import java.util.Collection;
25 import java.util.Map;
26 import java.util.Set;
27 import java.util.concurrent.ScheduledFuture;
28 import java.util.concurrent.TimeUnit;
29 import java.util.function.Function;
30 import java.util.stream.Collectors;
31 import java.util.stream.Stream;
32
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.eclipse.jetty.client.HttpClient;
36 import org.openhab.binding.enphase.internal.EnphaseBindingConstants;
37 import org.openhab.binding.enphase.internal.EnvoyConfiguration;
38 import org.openhab.binding.enphase.internal.EnvoyConnectionException;
39 import org.openhab.binding.enphase.internal.EnvoyHostAddressCache;
40 import org.openhab.binding.enphase.internal.EnvoyNoHostnameException;
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.core.cache.ExpiringCache;
46 import org.openhab.core.config.core.Configuration;
47 import org.openhab.core.library.types.QuantityType;
48 import org.openhab.core.library.unit.Units;
49 import org.openhab.core.thing.Bridge;
50 import org.openhab.core.thing.Channel;
51 import org.openhab.core.thing.ChannelUID;
52 import org.openhab.core.thing.Thing;
53 import org.openhab.core.thing.ThingStatus;
54 import org.openhab.core.thing.ThingStatusDetail;
55 import org.openhab.core.thing.binding.BaseBridgeHandler;
56 import org.openhab.core.thing.binding.ThingHandler;
57 import org.openhab.core.thing.binding.ThingHandlerService;
58 import org.openhab.core.types.Command;
59 import org.openhab.core.types.RefreshType;
60 import org.openhab.core.types.UnDefType;
61 import org.slf4j.Logger;
62 import org.slf4j.LoggerFactory;
63
64 /**
65  * BridgeHandler for the Envoy gateway.
66  *
67  * @author Thomas Hentschel - Initial contribution
68  * @author Hilbrand Bouwkamp - Initial contribution
69  */
70 @NonNullByDefault
71 public class EnvoyBridgeHandler extends BaseBridgeHandler {
72
73     private enum FeatureStatus {
74         UNKNOWN,
75         SUPPORTED,
76         UNSUPPORTED
77     }
78
79     private static final long RETRY_RECONNECT_SECONDS = 10;
80
81     private final Logger logger = LoggerFactory.getLogger(EnvoyBridgeHandler.class);
82     private final EnvoyConnector connector;
83     private final EnvoyHostAddressCache envoyHostnameCache;
84
85     private EnvoyConfiguration configuration = new EnvoyConfiguration();
86     private @Nullable ScheduledFuture<?> updataDataFuture;
87     private @Nullable ScheduledFuture<?> updateHostnameFuture;
88     private @Nullable ExpiringCache<Map<String, @Nullable InverterDTO>> invertersCache;
89     private @Nullable ExpiringCache<Map<String, @Nullable DeviceDTO>> devicesCache;
90     private @Nullable EnvoyEnergyDTO productionDTO;
91     private @Nullable EnvoyEnergyDTO consumptionDTO;
92     private FeatureStatus consumptionSupported = FeatureStatus.UNKNOWN;
93     private FeatureStatus jsonSupported = FeatureStatus.UNKNOWN;
94
95     public EnvoyBridgeHandler(final Bridge thing, final HttpClient httpClient,
96             final EnvoyHostAddressCache envoyHostAddressCache) {
97         super(thing);
98         connector = new EnvoyConnector(httpClient);
99         this.envoyHostnameCache = envoyHostAddressCache;
100     }
101
102     @Override
103     public void handleCommand(final ChannelUID channelUID, final Command command) {
104         if (command instanceof RefreshType) {
105             refresh(channelUID);
106         }
107     }
108
109     private void refresh(final ChannelUID channelUID) {
110         final EnvoyEnergyDTO data = ENVOY_CHANNELGROUP_CONSUMPTION.equals(channelUID.getGroupId()) ? consumptionDTO
111                 : productionDTO;
112
113         if (data == null) {
114             updateState(channelUID, UnDefType.UNDEF);
115         } else {
116             switch (channelUID.getIdWithoutGroup()) {
117                 case ENVOY_WATT_HOURS_TODAY:
118                     updateState(channelUID, new QuantityType<>(data.wattHoursToday, Units.WATT_HOUR));
119                     break;
120                 case ENVOY_WATT_HOURS_SEVEN_DAYS:
121                     updateState(channelUID, new QuantityType<>(data.wattHoursSevenDays, Units.WATT_HOUR));
122                     break;
123                 case ENVOY_WATT_HOURS_LIFETIME:
124                     updateState(channelUID, new QuantityType<>(data.wattHoursLifetime, Units.WATT_HOUR));
125                     break;
126                 case ENVOY_WATTS_NOW:
127                     updateState(channelUID, new QuantityType<>(data.wattsNow, Units.WATT));
128                     break;
129             }
130         }
131     }
132
133     @Override
134     public Collection<Class<? extends ThingHandlerService>> getServices() {
135         return Set.of(EnphaseDevicesDiscoveryService.class);
136     }
137
138     @Override
139     public void initialize() {
140         configuration = getConfigAs(EnvoyConfiguration.class);
141         if (!EnphaseBindingConstants.isValidSerial(configuration.serialNumber)) {
142             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Serial number is not valid");
143             return;
144         }
145         updateStatus(ThingStatus.UNKNOWN);
146         connector.setConfiguration(configuration);
147         consumptionSupported = FeatureStatus.UNKNOWN;
148         jsonSupported = FeatureStatus.UNKNOWN;
149         invertersCache = new ExpiringCache<>(Duration.of(configuration.refresh, ChronoUnit.MINUTES),
150                 this::refreshInverters);
151         devicesCache = new ExpiringCache<>(Duration.of(configuration.refresh, ChronoUnit.MINUTES),
152                 this::refreshDevices);
153         updataDataFuture = scheduler.scheduleWithFixedDelay(this::updateData, 0, configuration.refresh,
154                 TimeUnit.MINUTES);
155     }
156
157     /**
158      * Method called by the ExpiringCache when no inverter data is present to get the data from the Envoy gateway.
159      * When there are connection problems it will start a scheduled job to try to reconnect to the
160      *
161      * @return the inverter data from the Envoy gateway or null if no data is available.
162      */
163     private @Nullable Map<String, @Nullable InverterDTO> refreshInverters() {
164         try {
165             return connector.getInverters().stream()
166                     .collect(Collectors.toMap(InverterDTO::getSerialNumber, Function.identity()));
167         } catch (final EnvoyNoHostnameException e) {
168             // ignore hostname exception here. It's already handled by others.
169         } catch (final EnvoyConnectionException e) {
170             logger.trace("refreshInverters connection problem", e);
171         }
172         return null;
173     }
174
175     private @Nullable Map<String, @Nullable DeviceDTO> refreshDevices() {
176         try {
177             if (jsonSupported != FeatureStatus.UNSUPPORTED) {
178                 final Map<String, @Nullable DeviceDTO> devicesData = connector.getInventoryJson().stream()
179                         .flatMap(inv -> Stream.of(inv.devices).map(d -> {
180                             d.type = inv.type;
181                             return d;
182                         })).collect(Collectors.toMap(DeviceDTO::getSerialNumber, Function.identity()));
183
184                 jsonSupported = FeatureStatus.SUPPORTED;
185                 return devicesData;
186             }
187         } catch (final EnvoyNoHostnameException e) {
188             // ignore hostname exception here. It's already handled by others.
189         } catch (final EnvoyConnectionException e) {
190             if (jsonSupported == FeatureStatus.UNKNOWN) {
191                 logger.info(
192                         "This Ephase Envoy device ({}) doesn't seem to support json data. So not all channels are set.",
193                         getThing().getUID());
194                 jsonSupported = FeatureStatus.UNSUPPORTED;
195             } else if (consumptionSupported == FeatureStatus.SUPPORTED) {
196                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
197             }
198         }
199         return null;
200     }
201
202     /**
203      * Returns the data for the inverters. It get the data from cache or updates the cache if possible in case no data
204      * is available.
205      *
206      * @param force force a cache refresh
207      * @return data if present or null
208      */
209     public @Nullable Map<String, @Nullable InverterDTO> getInvertersData(final boolean force) {
210         final ExpiringCache<Map<String, @Nullable InverterDTO>> invertersCache = this.invertersCache;
211
212         if (invertersCache == null || !isOnline()) {
213             return null;
214         } else {
215             if (force) {
216                 invertersCache.invalidateValue();
217             }
218             return invertersCache.getValue();
219         }
220     }
221
222     /**
223      * Returns the data for the devices. It get the data from cache or updates the cache if possible in case no data
224      * is available.
225      *
226      * @param force force a cache refresh
227      * @return data if present or null
228      */
229     public @Nullable Map<String, @Nullable DeviceDTO> getDevices(final boolean force) {
230         final ExpiringCache<Map<String, @Nullable DeviceDTO>> devicesCache = this.devicesCache;
231
232         if (devicesCache == null || !isOnline()) {
233             return null;
234         } else {
235             if (force) {
236                 devicesCache.invalidateValue();
237             }
238             return devicesCache.getValue();
239         }
240     }
241
242     /**
243      * Method called by the refresh thread.
244      */
245     public synchronized void updateData() {
246         try {
247             updateInverters();
248             updateEnvoy();
249             updateDevices();
250         } catch (final EnvoyNoHostnameException e) {
251             scheduleHostnameUpdate(false);
252         } catch (final EnvoyConnectionException e) {
253             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
254             scheduleHostnameUpdate(false);
255         } catch (final RuntimeException e) {
256             logger.debug("Unexpected error in Enphase {}: ", getThing().getUID(), e);
257             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
258         }
259     }
260
261     private void updateEnvoy() throws EnvoyNoHostnameException, EnvoyConnectionException {
262         productionDTO = connector.getProduction();
263         setConsumptionDTOData();
264         getThing().getChannels().stream().map(Channel::getUID).filter(this::isLinked).forEach(this::refresh);
265         if (isInitialized() && !isOnline()) {
266             updateStatus(ThingStatus.ONLINE);
267         }
268     }
269
270     /**
271      * Retrieve consumption data if supported, and keep track if this feature is supported by the device.
272      *
273      * @throws EnvoyConnectionException
274      */
275     private void setConsumptionDTOData() throws EnvoyConnectionException {
276         if (consumptionSupported != FeatureStatus.UNSUPPORTED && isOnline()) {
277             try {
278                 consumptionDTO = connector.getConsumption();
279                 consumptionSupported = FeatureStatus.SUPPORTED;
280             } catch (final EnvoyNoHostnameException e) {
281                 // ignore hostname exception here. It's already handled by others.
282             } catch (final EnvoyConnectionException e) {
283                 if (consumptionSupported == FeatureStatus.UNKNOWN) {
284                     logger.info(
285                             "This Enphase Envoy device ({}) doesn't seem to support consumption data. So no consumption channels are set.",
286                             getThing().getUID());
287                     consumptionSupported = FeatureStatus.UNSUPPORTED;
288                 } else if (consumptionSupported == FeatureStatus.SUPPORTED) {
289                     throw e;
290                 }
291             }
292         }
293     }
294
295     /**
296      * Updates channels of the inverter things with inverter specific data.
297      */
298     private void updateInverters() {
299         final Map<String, @Nullable InverterDTO> inverters = getInvertersData(false);
300
301         if (inverters != null) {
302             getThing().getThings().stream().map(Thing::getHandler).filter(h -> h instanceof EnphaseInverterHandler)
303                     .map(EnphaseInverterHandler.class::cast)
304                     .forEach(invHandler -> updateInverter(inverters, invHandler));
305         }
306     }
307
308     private void updateInverter(final @Nullable Map<String, @Nullable InverterDTO> inverters,
309             final EnphaseInverterHandler invHandler) {
310         if (inverters == null) {
311             return;
312         }
313         final InverterDTO inverterDTO = inverters.get(invHandler.getSerialNumber());
314
315         invHandler.refreshInverterChannels(inverterDTO);
316         if (jsonSupported == FeatureStatus.UNSUPPORTED) {
317             // if inventory json is supported device status is set in #updateDevices
318             invHandler.refreshDeviceStatus(inverterDTO != null);
319         }
320     }
321
322     /**
323      * Updates channels of the device things with device specific data.
324      * This data is not available on all envoy devices.
325      */
326     private void updateDevices() {
327         final Map<String, @Nullable DeviceDTO> devices = getDevices(false);
328
329         getThing().getThings().stream().map(Thing::getHandler).filter(h -> h instanceof EnphaseDeviceHandler)
330                 .map(EnphaseDeviceHandler.class::cast).forEach(invHandler -> invHandler
331                         .refreshDeviceState(devices == null ? null : devices.get(invHandler.getSerialNumber())));
332     }
333
334     /**
335      * Schedules a hostname update, but only schedules the task when not yet running or forced.
336      * Force is used to reschedule the task and should only be used from within {@link #updateHostname()}.
337      *
338      * @param force if true will always schedule the task
339      */
340     private synchronized void scheduleHostnameUpdate(final boolean force) {
341         if (force || updateHostnameFuture == null) {
342             logger.debug("Schedule hostname/ip address update for thing {} in {} seconds.", getThing().getUID(),
343                     RETRY_RECONNECT_SECONDS);
344             updateHostnameFuture = scheduler.schedule(this::updateHostname, RETRY_RECONNECT_SECONDS, TimeUnit.SECONDS);
345         }
346     }
347
348     @Override
349     public void childHandlerInitialized(final ThingHandler childHandler, final Thing childThing) {
350         if (childHandler instanceof EnphaseInverterHandler handler) {
351             updateInverter(getInvertersData(false), handler);
352         }
353         if (childHandler instanceof EnphaseDeviceHandler handler) {
354             final Map<String, @Nullable DeviceDTO> devices = getDevices(false);
355
356             if (devices != null) {
357                 handler.refreshDeviceState(devices.get(handler.getSerialNumber()));
358             }
359         }
360     }
361
362     /**
363      * Handles a host name / ip address update.
364      */
365     private void updateHostname() {
366         final String lastKnownHostname = envoyHostnameCache.getLastKnownHostAddress(configuration.serialNumber);
367
368         if (lastKnownHostname.isEmpty()) {
369             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
370                     "No ip address known of the envoy gateway. If this isn't updated in a few minutes check your connection.");
371             scheduleHostnameUpdate(true);
372         } else {
373             final Configuration config = editConfiguration();
374
375             config.put(CONFIG_HOSTNAME, lastKnownHostname);
376             logger.info("Enphase Envoy ({}) hostname/ip address set to {}", getThing().getUID(), lastKnownHostname);
377             configuration.hostname = lastKnownHostname;
378             connector.setConfiguration(configuration);
379             updateConfiguration(config);
380             updateData();
381             // The task is done so the future can be released by setting it to null.
382             updateHostnameFuture = null;
383         }
384     }
385
386     @Override
387     public void dispose() {
388         final ScheduledFuture<?> retryFuture = this.updateHostnameFuture;
389         if (retryFuture != null) {
390             retryFuture.cancel(true);
391         }
392         final ScheduledFuture<?> inverterFuture = this.updataDataFuture;
393
394         if (inverterFuture != null) {
395             inverterFuture.cancel(true);
396         }
397     }
398
399     /**
400      * @return Returns true if the bridge is online and not has a configuration pending.
401      */
402     public boolean isOnline() {
403         return getThing().getStatus() == ThingStatus.ONLINE;
404     }
405
406     @Override
407     public String toString() {
408         return "EnvoyBridgeHandler(" + thing.getUID() + ") Status: " + thing.getStatus();
409     }
410 }