/** * Copyright (c) 2010-2016 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.openhab.binding.nest.internal; import static org.apache.commons.lang.StringUtils.isNotBlank; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.Date; import java.util.Dictionary; import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.prefs.Preferences; import org.openhab.binding.nest.NestBindingProvider; import org.openhab.binding.nest.internal.messages.AbstractRequest; import org.openhab.binding.nest.internal.messages.AccessTokenRequest; import org.openhab.binding.nest.internal.messages.AccessTokenResponse; import org.openhab.binding.nest.internal.messages.DataModel; import org.openhab.binding.nest.internal.messages.DataModelRequest; import org.openhab.binding.nest.internal.messages.DataModelResponse; import org.openhab.binding.nest.internal.messages.UpdateDataModelRequest; import org.openhab.core.binding.AbstractActiveBinding; import org.openhab.core.binding.BindingProvider; import org.openhab.core.library.types.DateTimeType; import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.StringType; import org.openhab.core.types.Command; import org.openhab.core.types.State; import org.openhab.core.types.UnDefType; import org.osgi.service.cm.ConfigurationException; import org.osgi.service.cm.ManagedService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Binding that retrieves information about objects we're interested in every few minutes, and sends updates and * commands to Nest as they are made. * * @author John Cocula * @since 1.7.0 */ public class NestBinding extends AbstractActiveBinding<NestBindingProvider> implements ManagedService { private static final String DEFAULT_USER_ID = "DEFAULT_USER"; private static final Logger logger = LoggerFactory.getLogger(NestBinding.class); protected static final String CONFIG_REFRESH = "refresh"; protected static final String CONFIG_CLIENT_ID = "client_id"; protected static final String CONFIG_CLIENT_SECRET = "client_secret"; protected static final String CONFIG_PIN_CODE = "pin_code"; protected static final String CONFIG_TIMEOUT = "timeout"; /** * the refresh interval which is used to poll values from the Nest server (optional, defaults to 60000ms) */ private long refreshInterval = 60000; /** * A map of userids from the openhab.cfg file to OAuth credentials used to communicate with each app instance. * Multiple accounts is not implemented in the Nest binding due to the current prohibition 3 in the Terms of * Service, but some code is here for if/when that restriction is lifted. */ private Map<String, OAuthCredentials> credentialsCache = new HashMap<String, OAuthCredentials>(); /** * used to store events that we have sent ourselves; we need to remember them for not reacting to them */ private static class Update { private String itemName; private State state; Update(final String itemName, final State state) { this.itemName = itemName; this.state = state; } @Override public boolean equals(Object o) { if (o == null || !(o instanceof Update)) { return false; } return (this.itemName == null ? ((Update) o).itemName == null : this.itemName.equals(((Update) o).itemName)) && (this.state == null ? ((Update) o).state == null : this.state.equals(((Update) o).state)); } @Override public int hashCode() { return (this.itemName == null ? 0 : this.itemName.hashCode()) ^ (this.state == null ? 0 : this.state.hashCode()); } } private List<Update> ignoreEventList = Collections.synchronizedList(new ArrayList<Update>()); /** * The most recently received data model, or <code>null</code> if none have been retrieved yet. */ private DataModel oldDataModel = null; public NestBinding() { } /** * {@inheritDoc} */ @Override public void activate() { super.activate(); } /** * {@inheritDoc} */ @Override public void deactivate() { // deallocate resources here that are no longer needed and // should be reset when activating this binding again } /** * {@inheritDoc} */ @Override protected long getRefreshInterval() { return refreshInterval; } /** * {@inheritDoc} */ @Override protected String getName() { return "Nest Refresh Service"; } /** * {@inheritDoc} */ @Override protected void execute() { logger.trace("Querying Nest API"); try { for (String userid : credentialsCache.keySet()) { OAuthCredentials oauthCredentials = getOAuthCredentials(userid); if (oauthCredentials.noAccessToken()) { if (!oauthCredentials.retrieveAccessToken()) { logger.warn("Periodic poll skipped."); continue; } } readNest(oauthCredentials); } } catch (Exception e) { if (logger.isDebugEnabled()) { logger.warn("Exception reading from Nest.", e); } else { logger.warn("Exception reading from Nest: {}", e.getMessage()); } } } /** * Given the credentials to use and what to select from the Nest API, read any changed information from Nest and * update the affected items. * * @param oauthCredentials * the credentials to use */ private void readNest(OAuthCredentials oauthCredentials) throws Exception { DataModelRequest dmreq = new DataModelRequest(oauthCredentials.accessToken); DataModelResponse dmres = dmreq.execute(); if (dmres.isError()) { logger.error("Error retrieving data model: {}", dmres.getError()); return; } else { logger.trace("Retrieved data model: {}", dmres); } DataModel newDataModel = dmres; this.oldDataModel = newDataModel; // Iterate through bindings and update all inbound values. for (final NestBindingProvider provider : this.providers) { for (final String itemName : provider.getItemNames()) { if (provider.isInBound(itemName)) { final String property = provider.getProperty(itemName); final State newState = getState(newDataModel, property); logger.trace("Updating itemName '{}' with newState '{}'", itemName, newState); /* * we need to make sure that we won't send out this event to Nest again, when receiving it on the * openHAB bus */ ignoreEventList.add(new Update(itemName, newState)); logger.trace("Added event (item='{}', newState='{}') to the ignore event list (size={})", itemName, newState, ignoreEventList.size()); this.eventPublisher.postUpdate(itemName, newState); } } } } /** * Give a binding provider, a data model, and an item name, return the corresponding state object. * * @param provider * the Nest binding provider * @param dataModel * a data model from which to retrieve the value * @param itemName * the item name from the items file. * @return the State object for the named item */ private State getState(final DataModel dataModel, final String property) { if (dataModel != null) { try { return createState(dataModel.getProperty(property)); } catch (Exception e) { logger.error("Unable to get state from data model", e); } } return UnDefType.NULL; } /** * Creates an openHAB {@link State} in accordance to the class of the given {@code propertyValue}. Currently * {@link Date}, {@link BigDecimal}, {@link Temperature} and {@link Boolean} are handled explicitly. All other * {@code dataTypes} are mapped to {@link StringType}. * <p> * If {@code propertyValue} is {@code null}, {@link UnDefType#NULL} will be returned. * * Copied/adapted from the Koubachi binding. * * @param propertyValue * * @return the new {@link State} in accordance with {@code dataType}. Will never be {@code null}. */ private State createState(Object propertyValue) { if (propertyValue == null) { return UnDefType.NULL; } Class<?> dataType = propertyValue.getClass(); if (Date.class.isAssignableFrom(dataType)) { Calendar calendar = Calendar.getInstance(); calendar.setTime((Date) propertyValue); return new DateTimeType(calendar); } else if (Integer.class.isAssignableFrom(dataType)) { return new DecimalType((Integer) propertyValue); } else if (BigDecimal.class.isAssignableFrom(dataType)) { return new DecimalType((BigDecimal) propertyValue); } else if (Boolean.class.isAssignableFrom(dataType)) { if ((Boolean) propertyValue) { return OnOffType.ON; } else { return OnOffType.OFF; } } else { return new StringType(propertyValue.toString()); } } /** * {@inheritDoc} */ @Override protected void internalReceiveCommand(String itemName, Command command) { logger.trace("internalReceiveCommand(item='{}', command='{}')", itemName, command); commandNest(itemName, command); } /** * {@inheritDoc} */ @Override protected void internalReceiveUpdate(final String itemName, final State newState) { logger.trace("Received update (item='{}', state='{}')", itemName, newState); if (!isEcho(itemName, newState)) { updateNest(itemName, newState); } } /** * Perform the given {@code command} against all targets referenced in {@code itemName}. * * @param command * the command to execute * @param the * target(s) against which to execute this command */ private void commandNest(final String itemName, final Command command) { if (command instanceof State) { updateNest(itemName, (State) command); } } private boolean isEcho(String itemName, State state) { if (ignoreEventList.remove(new Update(itemName, state))) { logger.debug( "We received this event (item='{}', state='{}') from Nest, so we don't send it back again -> ignore!", itemName, state); return true; } else { return false; } } /** * Send the {@code newState} for the given {@code itemName} to Nest. * * @param itemName * @param newState */ private void updateNest(final String itemName, final State newState) { // Find the first binding provider for this itemName. NestBindingProvider provider = null; String property = null; for (NestBindingProvider p : this.providers) { property = p.getProperty(itemName); if (property != null) { provider = p; break; } } if (provider == null) { logger.warn("no matching binding provider found [itemName={}, newState={}]", itemName, newState); return; } if (!provider.isOutBound(itemName)) { logger.warn("attempt to update non-outbound item skipped [itemName={}, newState={}]", itemName, newState); return; } try { logger.debug("About to set property '{}' to '{}'", property, newState); // Ask the old DataModel to generate a new DataModel that only contains the update we want to send DataModel updateDataModel = oldDataModel.updateDataModel(property, newState); logger.trace("Data model for update: {}", updateDataModel); if (updateDataModel == null) { return; } OAuthCredentials oauthCredentials = getOAuthCredentials(DEFAULT_USER_ID); if (oauthCredentials == null) { logger.warn("Unable to locate credentials for item {}; aborting update.", itemName); return; } // If we don't have an access token yet, retrieve one. if (oauthCredentials.noAccessToken()) { if (!oauthCredentials.retrieveAccessToken()) { logger.warn("Sending update skipped."); return; } } UpdateDataModelRequest request = new UpdateDataModelRequest(oauthCredentials.accessToken, updateDataModel); DataModelResponse response = request.execute(); if (response.isError()) { logger.error("Error updating data model: {}", response); } } catch (Exception e) { logger.error("Unable to update data model", e); } } /** * {@inheritDoc} */ @Override public void bindingChanged(BindingProvider provider, String itemName) { if (provider instanceof NestBindingProvider) { this.oldDataModel = null; } } /** * {@inheritDoc} */ @Override public void allBindingsChanged(BindingProvider provider) { if (provider instanceof NestBindingProvider) { this.oldDataModel = null; } } /** * Returns the cached {@link OAuthCredentials} for the given {@code userid}. If their is no such cached * {@link OAuthCredentials} element, the cache is searched with the {@code DEFAULT_USER_ID}. If there is still no * cached element found {@code NULL} is returned. * * @param userid * the userid to find the {@link OAuthCredentials} * @return the cached {@link OAuthCredentials} or {@code NULL} */ private OAuthCredentials getOAuthCredentials(String userid) { if (credentialsCache.containsKey(userid)) { return credentialsCache.get(userid); } else { return credentialsCache.get(DEFAULT_USER_ID); } } protected void addBindingProvider(NestBindingProvider bindingProvider) { super.addBindingProvider(bindingProvider); } protected void removeBindingProvider(NestBindingProvider bindingProvider) { super.removeBindingProvider(bindingProvider); } /** * {@inheritDoc} */ @Override public void updated(Dictionary<String, ?> config) throws ConfigurationException { if (config != null) { // to override the default refresh interval one has to add a // parameter to openhab.cfg like nest:refresh=120000 String refreshIntervalString = Objects.toString(config.get(CONFIG_REFRESH), null); if (isNotBlank(refreshIntervalString)) { refreshInterval = Long.parseLong(refreshIntervalString); } // to override the default HTTP request timeout one has to add a // parameter to openhab.cfg like nest:timeout=20000 String timeoutString = Objects.toString(config.get(CONFIG_TIMEOUT), null); if (isNotBlank(timeoutString)) { AbstractRequest.setHttpRequestTimeout(Integer.parseInt(timeoutString)); } Enumeration<String> configKeys = config.keys(); while (configKeys.hasMoreElements()) { String configKey = configKeys.nextElement(); // the config-key enumeration contains additional keys that we // don't want to process here ... if (CONFIG_REFRESH.equals(configKey) || CONFIG_TIMEOUT.equals(configKey) || "service.pid".equals(configKey)) { continue; } String userid = DEFAULT_USER_ID; String configKeyTail = configKey; OAuthCredentials credentials = credentialsCache.get(userid); if (credentials == null) { credentials = new OAuthCredentials(userid); credentialsCache.put(userid, credentials); } String value = Objects.toString(config.get(configKey), null); if (CONFIG_CLIENT_ID.equals(configKeyTail)) { credentials.clientId = value; } else if (CONFIG_CLIENT_SECRET.equals(configKeyTail)) { credentials.clientSecret = value; } else if (CONFIG_PIN_CODE.equals(configKeyTail)) { credentials.pinCode = value; } else { throw new ConfigurationException(configKey, "the given configKey '" + configKey + "' is unknown"); } } // Verify the completeness of each OAuthCredentials entry // to make sure we can get started. boolean properlyConfigured = true; for (String userid : credentialsCache.keySet()) { OAuthCredentials oauthCredentials = getOAuthCredentials(userid); String userString = (DEFAULT_USER_ID.equals(userid)) ? "" : (userid + "."); if (oauthCredentials.clientId == null) { logger.error("Required nest:{}{} is missing.", userString, CONFIG_CLIENT_ID); properlyConfigured = false; break; } if (oauthCredentials.clientSecret == null) { logger.error("Required nest:{}{} is missing.", userString, CONFIG_CLIENT_SECRET); properlyConfigured = false; break; } if (oauthCredentials.pinCode == null) { logger.error("Required nest:{}{} is missing.", userString, CONFIG_PIN_CODE); properlyConfigured = false; break; } // Load persistently stored values for this credential set oauthCredentials.load(); } setProperlyConfigured(properlyConfigured); } } /** * This internal class holds the credentials necessary for the OAuth2 flow to work. It also provides basic methods * to retrieve an access token. * * @author John Cocula * @since 1.7.0 */ static class OAuthCredentials { private static final String ACCESS_TOKEN = "accessToken"; private static final String PIN_CODE = "pinCode"; private String userid; /** * The private client_id needed in order to interact with the Nest API. This must be provided in the * <code>openhab.cfg</code> file. */ private String clientId; /** * The client_secret needed when authorizing this client to the Nest API. * * @see AccessTokenRequest */ private String clientSecret; /** * The pincode needed when authorizing this client to the Nest API. * * @see AccessTokenRequest */ private String pinCode; /** * The access token to access the Nest API. Automatically renewed from the API using the refresh token and * persisted for use across activations. * * @see #refreshTokens() */ private String accessToken; public OAuthCredentials(String userid) { try { this.userid = userid; } catch (Exception e) { throw new NestException("Cannot create OAuthCredentials.", e); } } private Preferences getPrefsNode() { return Preferences.userRoot().node("org.openhab.nest." + userid); } /** * Only load the accessToken if the pinCode that was saved with it matches the current pinCode. Otherwise, we * could continue to try to use an accessToken that does not match the credentials in openhab.cfg. */ private void load() { Preferences prefs = getPrefsNode(); String pinCode = prefs.get(PIN_CODE, null); if (this.pinCode.equals(pinCode)) { this.accessToken = prefs.get(ACCESS_TOKEN, null); } } private void save() { Preferences prefs = getPrefsNode(); if (this.accessToken != null) { prefs.put(ACCESS_TOKEN, this.accessToken); } else { prefs.remove(ACCESS_TOKEN); } if (this.pinCode != null) { prefs.put(PIN_CODE, this.pinCode); } else { prefs.remove(PIN_CODE); } } /** * Determine if we have an access token. * * @return <code>true</code> if we have an access token; <code>false</code> otherwise. */ public boolean noAccessToken() { return this.accessToken == null; } /** * Retrieve an access token from the Nest API. * * @return <code>true</code> if we were successful, <code>false</code> otherwise */ public boolean retrieveAccessToken() { logger.trace("Retrieving access token in order to access the Nest API."); final AccessTokenRequest request = new AccessTokenRequest(clientId, clientSecret, pinCode); logger.trace("Request: {}", request); final AccessTokenResponse response = request.execute(); logger.trace("Response: {}", response); if (response.isError()) { logger.error("Error retrieving access token: {}'", response); } this.accessToken = response.getAccessToken(); save(); return !noAccessToken(); } } }