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;
22 import java.time.Duration;
23 import java.time.temporal.ChronoUnit;
24 import java.util.Collection;
25 import java.util.Collections;
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;
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;
65 * BridgeHandler for the Envoy gateway.
67 * @author Thomas Hentschel - Initial contribution
68 * @author Hilbrand Bouwkamp - Initial contribution
71 public class EnvoyBridgeHandler extends BaseBridgeHandler {
73 private enum FeatureStatus {
79 private static final long RETRY_RECONNECT_SECONDS = 10;
81 private final Logger logger = LoggerFactory.getLogger(EnvoyBridgeHandler.class);
82 private final EnvoyConnector connector;
83 private final EnvoyHostAddressCache envoyHostnameCache;
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;
95 public EnvoyBridgeHandler(final Bridge thing, final HttpClient httpClient,
96 final EnvoyHostAddressCache envoyHostAddressCache) {
98 connector = new EnvoyConnector(httpClient);
99 this.envoyHostnameCache = envoyHostAddressCache;
103 public void handleCommand(final ChannelUID channelUID, final Command command) {
104 if (command instanceof RefreshType) {
109 private void refresh(final ChannelUID channelUID) {
110 final EnvoyEnergyDTO data = ENVOY_CHANNELGROUP_CONSUMPTION.equals(channelUID.getGroupId()) ? consumptionDTO
114 updateState(channelUID, UnDefType.UNDEF);
116 switch (channelUID.getIdWithoutGroup()) {
117 case ENVOY_WATT_HOURS_TODAY:
118 updateState(channelUID, new QuantityType<>(data.wattHoursToday, Units.WATT_HOUR));
120 case ENVOY_WATT_HOURS_SEVEN_DAYS:
121 updateState(channelUID, new QuantityType<>(data.wattHoursSevenDays, Units.WATT_HOUR));
123 case ENVOY_WATT_HOURS_LIFETIME:
124 updateState(channelUID, new QuantityType<>(data.wattHoursLifetime, Units.WATT_HOUR));
126 case ENVOY_WATTS_NOW:
127 updateState(channelUID, new QuantityType<>(data.wattsNow, Units.WATT));
134 public Collection<Class<? extends ThingHandlerService>> getServices() {
135 return Collections.singleton(EnphaseDevicesDiscoveryService.class);
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");
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,
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
161 * @return the inverter data from the Envoy gateway or null if no data is available.
163 private @Nullable Map<String, @Nullable InverterDTO> refreshInverters() {
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);
175 private @Nullable Map<String, @Nullable DeviceDTO> refreshDevices() {
177 if (jsonSupported != FeatureStatus.UNSUPPORTED) {
178 final Map<String, @Nullable DeviceDTO> devicesData = connector.getInventoryJson().stream()
179 .flatMap(inv -> Stream.of(inv.devices).map(d -> {
182 })).collect(Collectors.toMap(DeviceDTO::getSerialNumber, Function.identity()));
184 jsonSupported = FeatureStatus.SUPPORTED;
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) {
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());
203 * Returns the data for the inverters. It get the data from cache or updates the cache if possible in case no data
206 * @param force force a cache refresh
207 * @return data if present or null
209 public @Nullable Map<String, @Nullable InverterDTO> getInvertersData(final boolean force) {
210 final ExpiringCache<Map<String, @Nullable InverterDTO>> invertersCache = this.invertersCache;
212 if (invertersCache == null || !isOnline()) {
216 invertersCache.invalidateValue();
218 return invertersCache.getValue();
223 * Returns the data for the devices. It get the data from cache or updates the cache if possible in case no data
226 * @param force force a cache refresh
227 * @return data if present or null
229 public @Nullable Map<String, @Nullable DeviceDTO> getDevices(final boolean force) {
230 final ExpiringCache<Map<String, @Nullable DeviceDTO>> devicesCache = this.devicesCache;
232 if (devicesCache == null || !isOnline()) {
236 devicesCache.invalidateValue();
238 return devicesCache.getValue();
243 * Method called by the refresh thread.
245 public synchronized void updateData() {
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());
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);
271 * Retrieve consumption data if supported, and keep track if this feature is supported by the device.
273 * @throws EnvoyConnectionException
275 private void setConsumptionDTOData() throws EnvoyConnectionException {
276 if (consumptionSupported != FeatureStatus.UNSUPPORTED && isOnline()) {
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) {
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) {
296 * Updates channels of the inverter things with inverter specific data.
298 private void updateInverters() {
299 final Map<String, @Nullable InverterDTO> inverters = getInvertersData(false);
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));
308 private void updateInverter(final @Nullable Map<String, @Nullable InverterDTO> inverters,
309 final EnphaseInverterHandler invHandler) {
310 if (inverters == null) {
313 final InverterDTO inverterDTO = inverters.get(invHandler.getSerialNumber());
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);
323 * Updates channels of the device things with device specific data.
324 * This data is not available on all envoy devices.
326 private void updateDevices() {
327 final Map<String, @Nullable DeviceDTO> devices = getDevices(false);
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())));
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()}.
338 * @param force if true will always schedule the task
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);
349 public void childHandlerInitialized(final ThingHandler childHandler, final Thing childThing) {
350 if (childHandler instanceof EnphaseInverterHandler) {
351 updateInverter(getInvertersData(false), (EnphaseInverterHandler) childHandler);
353 if (childHandler instanceof EnphaseDeviceHandler) {
354 final Map<String, @Nullable DeviceDTO> devices = getDevices(false);
356 if (devices != null) {
357 ((EnphaseDeviceHandler) childHandler)
358 .refreshDeviceState(devices.get(((EnphaseDeviceHandler) childHandler).getSerialNumber()));
364 * Handles a host name / ip address update.
366 private void updateHostname() {
367 final String lastKnownHostname = envoyHostnameCache.getLastKnownHostAddress(configuration.serialNumber);
369 if (lastKnownHostname.isEmpty()) {
370 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
371 "No ip address known of the envoy gateway. If this isn't updated in a few minutes check your connection.");
372 scheduleHostnameUpdate(true);
374 final Configuration config = editConfiguration();
376 config.put(CONFIG_HOSTNAME, lastKnownHostname);
377 logger.info("Enphase Envoy ({}) hostname/ip address set to {}", getThing().getUID(), lastKnownHostname);
378 configuration.hostname = lastKnownHostname;
379 connector.setConfiguration(configuration);
380 updateConfiguration(config);
382 // The task is done so the future can be released by setting it to null.
383 updateHostnameFuture = null;
388 public void dispose() {
389 final ScheduledFuture<?> retryFuture = this.updateHostnameFuture;
390 if (retryFuture != null) {
391 retryFuture.cancel(true);
393 final ScheduledFuture<?> inverterFuture = this.updataDataFuture;
395 if (inverterFuture != null) {
396 inverterFuture.cancel(true);
401 * @return Returns true if the bridge is online and not has a configuration pending.
403 public boolean isOnline() {
404 return getThing().getStatus() == ThingStatus.ONLINE;
408 public String toString() {
409 return "EnvoyBridgeHandler(" + thing.getUID() + ") Status: " + thing.getStatus();