/** * Copyright (c) 2014-2017 by the respective copyright holders. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html */ package org.eclipse.smarthome.binding.hue.handler; import static org.eclipse.smarthome.binding.hue.HueBindingConstants.*; import java.io.IOException; import java.math.BigDecimal; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import org.eclipse.smarthome.binding.hue.internal.Config; import org.eclipse.smarthome.binding.hue.internal.FullConfig; import org.eclipse.smarthome.binding.hue.internal.FullLight; import org.eclipse.smarthome.binding.hue.internal.HueBridge; import org.eclipse.smarthome.binding.hue.internal.HueConfigStatusMessage; import org.eclipse.smarthome.binding.hue.internal.State; import org.eclipse.smarthome.binding.hue.internal.StateUpdate; import org.eclipse.smarthome.binding.hue.internal.exceptions.ApiException; import org.eclipse.smarthome.binding.hue.internal.exceptions.DeviceOffException; import org.eclipse.smarthome.binding.hue.internal.exceptions.LinkButtonException; import org.eclipse.smarthome.binding.hue.internal.exceptions.UnauthorizedException; import org.eclipse.smarthome.config.core.Configuration; import org.eclipse.smarthome.config.core.status.ConfigStatusMessage; import org.eclipse.smarthome.core.library.types.OnOffType; import org.eclipse.smarthome.core.thing.Bridge; import org.eclipse.smarthome.core.thing.ChannelUID; import org.eclipse.smarthome.core.thing.Thing; import org.eclipse.smarthome.core.thing.ThingStatus; import org.eclipse.smarthome.core.thing.ThingStatusDetail; import org.eclipse.smarthome.core.thing.ThingTypeUID; import org.eclipse.smarthome.core.thing.binding.ConfigStatusBridgeHandler; import org.eclipse.smarthome.core.types.Command; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * {@link HueBridgeHandler} is the handler for a hue bridge and connects it to * the framework. All {@link HueLightHandler}s use the {@link HueBridgeHandler} to execute the actual commands. * * @author Dennis Nobel - Initial contribution of hue binding * @author Oliver Libutzki * @author Kai Kreuzer - improved state handling * @author Andre Fuechsel - implemented getFullLights(), startSearch() * @author Thomas Höfer - added thing properties * @author Stefan Bußweiler - Added new thing status handling * @author Jochen Hiller - fixed status updates, use reachable=true/false for state compare * @author Denis Dudnik - switched to internally integrated source of Jue library */ public class HueBridgeHandler extends ConfigStatusBridgeHandler { private static final String LIGHT_STATE_ADDED = "added"; private static final String LIGHT_STATE_CHANGED = "changed"; public final static Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_BRIDGE); private static final int DEFAULT_POLLING_INTERVAL = 10; // in seconds private static final String DEVICE_TYPE = "EclipseSmartHome"; private Logger logger = LoggerFactory.getLogger(HueBridgeHandler.class); private Map<String, FullLight> lastLightStates = new ConcurrentHashMap<>(); private boolean lastBridgeConnectionState = false; private List<LightStatusListener> lightStatusListeners = new CopyOnWriteArrayList<>(); private ScheduledFuture<?> pollingJob; private Runnable pollingRunnable = new Runnable() { @Override public void run() { try { try { FullConfig fullConfig = bridge.getFullConfig(); if (!lastBridgeConnectionState) { lastBridgeConnectionState = tryResumeBridgeConnection(); } if (lastBridgeConnectionState) { Map<String, FullLight> lastLightStateCopy = new HashMap<>(lastLightStates); for (final FullLight fullLight : fullConfig.getLights()) { final String lightId = fullLight.getId(); if (lastLightStateCopy.containsKey(lightId)) { final FullLight lastFullLight = lastLightStateCopy.remove(lightId); final State lastFullLightState = lastFullLight.getState(); lastLightStates.put(lightId, fullLight); if (!isEqual(lastFullLightState, fullLight.getState())) { logger.debug("Status update for Hue light {} detected.", lightId); notifyLightStatusListeners(fullLight, LIGHT_STATE_CHANGED); } } else { lastLightStates.put(lightId, fullLight); logger.debug("Hue light {} added.", lightId); notifyLightStatusListeners(fullLight, LIGHT_STATE_ADDED); } } // Check for removed lights for (Entry<String, FullLight> fullLightEntry : lastLightStateCopy.entrySet()) { lastLightStates.remove(fullLightEntry.getKey()); logger.debug("Hue light {} removed.", fullLightEntry.getKey()); for (LightStatusListener lightStatusListener : lightStatusListeners) { try { lightStatusListener.onLightRemoved(bridge, fullLightEntry.getValue()); } catch (Exception e) { logger.error("An exception occurred while calling the BridgeHeartbeatListener", e); } } } final Config config = fullConfig.getConfig(); if (config != null) { Map<String, String> properties = editProperties(); properties.put(Thing.PROPERTY_SERIAL_NUMBER, config.getMACAddress()); properties.put(Thing.PROPERTY_FIRMWARE_VERSION, config.getSoftwareVersion()); updateProperties(properties); } } } catch (UnauthorizedException | IllegalStateException e) { if (isReachable(bridge.getIPAddress())) { lastBridgeConnectionState = false; onNotAuthenticated(bridge); } else { if (lastBridgeConnectionState || thing.getStatus() == ThingStatus.INITIALIZING) { lastBridgeConnectionState = false; onConnectionLost(bridge); } } } catch (Exception e) { if (bridge != null) { if (lastBridgeConnectionState) { logger.debug("Connection to Hue Bridge {} lost.", bridge.getIPAddress()); lastBridgeConnectionState = false; onConnectionLost(bridge); } } } } catch (Throwable t) { logger.error("An unexpected error occurred: {}", t.getMessage(), t); } } private boolean isReachable(String ipAddress) { try { // note that InetAddress.isReachable is unreliable, see // http://stackoverflow.com/questions/9922543/why-does-inetaddress-isreachable-return-false-when-i-can-ping-the-ip-address // That's why we do an HTTP access instead // If there is no connection, this line will fail bridge.authenticate("invalid"); } catch (IOException e) { return false; } catch (ApiException e) { if (e.getMessage().contains("SocketTimeout") || e.getMessage().contains("ConnectException") || e.getMessage().contains("SocketException") || e.getMessage().contains("NoRouteToHostException")) { return false; } else { // this seems to be only an authentication issue return true; } } return true; } }; private HueBridge bridge = null; public HueBridgeHandler(Bridge hueBridge) { super(hueBridge); } @Override public void handleCommand(ChannelUID channelUID, Command command) { // not needed } public void updateLightState(FullLight light, StateUpdate stateUpdate) { if (bridge != null) { try { bridge.setLightState(light, stateUpdate); } catch (DeviceOffException e) { updateLightState(light, LightStateConverter.toOnOffLightState(OnOffType.ON)); updateLightState(light, stateUpdate); } catch (IOException | ApiException e) { throw new RuntimeException(e); } catch (IllegalStateException e) { logger.trace("Error while accessing light: {}", e.getMessage()); } } else { logger.warn("No bridge connected or selected. Cannot set light state."); } } @Override public void dispose() { logger.debug("Handler disposed."); if (pollingJob != null && !pollingJob.isCancelled()) { pollingJob.cancel(true); pollingJob = null; } if (bridge != null) { bridge = null; } } @Override public void initialize() { logger.debug("Initializing hue bridge handler."); if (getConfig().get(HOST) != null) { if (bridge == null) { bridge = new HueBridge((String) getConfig().get(HOST)); bridge.setTimeout(5000); } onUpdate(); } else { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "Cannot connect to hue bridge. IP address not set."); } } private synchronized void onUpdate() { if (bridge != null) { if (pollingJob == null || pollingJob.isCancelled()) { int pollingInterval = DEFAULT_POLLING_INTERVAL; try { Object pollingIntervalConfig = getConfig().get(POLLING_INTERVAL); if (pollingIntervalConfig != null) { pollingInterval = ((BigDecimal) pollingIntervalConfig).intValue(); } else { logger.info("Polling interval not configured for this hue bridge. Using default value: {}s", pollingInterval); } } catch (NumberFormatException ex) { logger.info("Wrong configuration value for polling interval. Using default value: {}s", pollingInterval); } pollingJob = scheduler.scheduleAtFixedRate(pollingRunnable, 1, pollingInterval, TimeUnit.SECONDS); } } } /** * This method is called whenever the connection to the given {@link HueBridge} is lost. * * @param bridge the hue bridge the connection is lost to */ public void onConnectionLost(HueBridge bridge) { logger.debug("Bridge connection lost. Updating thing status to OFFLINE."); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE); } /** * This method is called whenever the connection to the given {@link HueBridge} is resumed. * * @param bridge the hue bridge the connection is resumed to */ public void onConnectionResumed(HueBridge bridge) { logger.debug("Bridge connection resumed. Updating thing status to ONLINE."); updateStatus(ThingStatus.ONLINE); } /** * Check USER_NAME config for null. Call onConnectionResumed() otherwise. * * @return True if USER_NAME was not null. */ private boolean tryResumeBridgeConnection() { logger.debug("Connection to Hue Bridge {} established.", bridge.getIPAddress()); if (getConfig().get(USER_NAME) == null) { logger.warn("User name for Hue bridge authentication not available in configuration. " + "Setting ThingStatus to offline."); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "User name is not properly configured - please check log files"); return false; } else { onConnectionResumed(bridge); return true; } } /** * This method is called whenever the connection to the given {@link HueBridge} is available, * but requests are not allowed due to a missing or invalid authentication. * * @param bridge the hue bridge the connection is not authorized */ public void onNotAuthenticated(HueBridge bridge) { String userName = (String) getConfig().get(USER_NAME); if (userName == null) { createUser(bridge); } else { try { bridge.authenticate(userName); } catch (Exception e) { handleAuthenticationFailure(e, userName); } } } private void createUser(HueBridge bridge) { try { String newUser = createUserOnPhysicalBridge(bridge); updateBridgeThingConfiguration(newUser); } catch (LinkButtonException ex) { handleLinkButtonNotPressed(ex); } catch (Exception ex) { handleExceptionWhileCreatingUser(ex); } } private String createUserOnPhysicalBridge(HueBridge bridge) throws IOException, ApiException { logger.info("Creating new user on Hue bridge {} - please press the pairing button on the bridge.", getConfig().get(HOST)); String userName = bridge.link(DEVICE_TYPE); logger.info("User '{}' has been successfully added to Hue bridge.", userName); return userName; } private void updateBridgeThingConfiguration(String userName) { Configuration config = editConfiguration(); config.put(USER_NAME, userName); try { updateConfiguration(config); logger.debug("Updated configuration parameter {} to '{}'", USER_NAME, userName); } catch (IllegalStateException e) { logger.trace("Configuration update failed.", e); logger.warn("Unable to update configuration of Hue bridge."); logger.warn("Please configure the following user name manually: {}", userName); } } private void handleAuthenticationFailure(Exception ex, String userName) { logger.warn("User {} is not authenticated on Hue bridge {}", userName, getConfig().get(HOST)); logger.warn("Please configure a valid user or remove user from configuration to generate a new one."); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "Authentication failed - remove user name from configuration to generate a new one."); } private void handleLinkButtonNotPressed(LinkButtonException ex) { logger.debug("Failed creating new user on Hue bridge: {}", ex.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "Not authenticated - press pairing button on the bridge."); } private void handleExceptionWhileCreatingUser(Exception ex) { logger.warn("Failed creating new user on Hue bridge", ex); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR, "Failed to create new user on bridge: " + ex.getMessage()); } public boolean registerLightStatusListener(LightStatusListener lightStatusListener) { if (lightStatusListener == null) { throw new IllegalArgumentException("It's not allowed to pass a null LightStatusListener."); } boolean result = lightStatusListeners.add(lightStatusListener); if (result) { onUpdate(); // inform the listener initially about all lights and their states for (FullLight light : lastLightStates.values()) { lightStatusListener.onLightAdded(bridge, light); } } return result; } public boolean unregisterLightStatusListener(LightStatusListener lightStatusListener) { boolean result = lightStatusListeners.remove(lightStatusListener); if (result) { onUpdate(); } return result; } public FullLight getLightById(String lightId) { return lastLightStates.get(lightId); } public List<FullLight> getFullLights() { List<FullLight> lights = null; if (bridge != null) { try { try { lights = bridge.getFullConfig().getLights(); } catch (UnauthorizedException | IllegalStateException e) { lastBridgeConnectionState = false; onNotAuthenticated(bridge); lights = bridge.getFullConfig().getLights(); } } catch (Exception e) { logger.error("Bridge cannot search for new lights.", e); } } return lights; } public void startSearch() { if (bridge != null) { try { bridge.startSearch(); } catch (Exception e) { logger.error("Bridge cannot start search mode", e); } } } public void startSearch(List<String> serialNumbers) { if (bridge != null) { try { bridge.startSearch(serialNumbers); } catch (Exception e) { logger.error("Bridge cannot start search mode", e); } } } /** * Iterate through lightStatusListeners and notify them about a changed ot added light state. * * @param fullLight * @param type Can be "changed" if just a state has changed or "added" if this is a new light on the bridge. */ private void notifyLightStatusListeners(final FullLight fullLight, final String type) { for (LightStatusListener lightStatusListener : lightStatusListeners) { try { switch (type) { case LIGHT_STATE_ADDED: lightStatusListener.onLightAdded(bridge, fullLight); break; case LIGHT_STATE_CHANGED: lightStatusListener.onLightStateChanged(bridge, fullLight); break; default: throw new IllegalArgumentException( "Could not notify lightStatusListeners for unknown event type " + type); } } catch (Exception e) { logger.error("An exception occurred while calling the BridgeHeartbeatListener", e); } } } /** * Because the State can produce NPEs on getColorMode() and getEffect(), at first we check for the common * properties which are set for every light type. If they equal, we additionally try to check the colorMode. If we * get an NPE, * the light does not support color mode and the common properties equality is our result: true. Otherwise if no NPE * occurs * the equality of colorMode is our result. * * @param state1 Reference state * @param state2 State which is checked for equality. * @return True if the available informations of both states are equal. */ private boolean isEqual(State state1, State state2) { boolean commonStateIsEqual = state1.getAlertMode().equals(state2.getAlertMode()) && state1.isOn() == state2.isOn() && state1.getBrightness() == state2.getBrightness() && state1.getColorTemperature() == state2.getColorTemperature() && state1.getHue() == state2.getHue() && state1.getSaturation() == state2.getSaturation() && state1.isReachable() == state2.isReachable(); if (!commonStateIsEqual) { return false; } boolean colorModeIsEqual = true; boolean effectIsEqual = true; try { colorModeIsEqual = state1.getColorMode().equals(state2.getColorMode()); } catch (NullPointerException npe) { logger.trace("Light does not support color mode."); } try { effectIsEqual = state1.getEffect().equals(state2.getEffect()); } catch (NullPointerException npe) { logger.trace("Light does not support effect."); } return colorModeIsEqual && effectIsEqual; } @Override public Collection<ConfigStatusMessage> getConfigStatus() { // The bridge IP address to be used for checks final String bridgeIpAddress = (String) getThing().getConfiguration().get(HOST); Collection<ConfigStatusMessage> configStatusMessages; // Check whether an IP address is provided if (bridgeIpAddress == null || bridgeIpAddress.isEmpty()) { configStatusMessages = Collections.singletonList(ConfigStatusMessage.Builder.error(HOST) .withMessageKeySuffix(HueConfigStatusMessage.IP_ADDRESS_MISSING).withArguments(HOST).build()); } else { configStatusMessages = Collections.emptyList(); } return configStatusMessages; } }