/** * 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.ecobee.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.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import java.util.prefs.Preferences; import org.apache.commons.beanutils.ConvertUtils; import org.apache.commons.beanutils.Converter; import org.openhab.binding.ecobee.EcobeeActionProvider; import org.openhab.binding.ecobee.EcobeeBindingProvider; import org.openhab.binding.ecobee.messages.AbstractFunction; import org.openhab.binding.ecobee.messages.AbstractRequest; import org.openhab.binding.ecobee.messages.ApiResponse; import org.openhab.binding.ecobee.messages.AuthorizeRequest; import org.openhab.binding.ecobee.messages.AuthorizeResponse; import org.openhab.binding.ecobee.messages.RefreshTokenRequest; import org.openhab.binding.ecobee.messages.Request; import org.openhab.binding.ecobee.messages.Selection; import org.openhab.binding.ecobee.messages.Selection.SelectionType; import org.openhab.binding.ecobee.messages.Status; import org.openhab.binding.ecobee.messages.Temperature; import org.openhab.binding.ecobee.messages.Thermostat; import org.openhab.binding.ecobee.messages.Thermostat.HvacMode; import org.openhab.binding.ecobee.messages.Thermostat.VentilatorMode; import org.openhab.binding.ecobee.messages.ThermostatRequest; import org.openhab.binding.ecobee.messages.ThermostatResponse; import org.openhab.binding.ecobee.messages.ThermostatSummaryRequest; import org.openhab.binding.ecobee.messages.ThermostatSummaryResponse; import org.openhab.binding.ecobee.messages.ThermostatSummaryResponse.Revision; import org.openhab.binding.ecobee.messages.TokenRequest; import org.openhab.binding.ecobee.messages.TokenResponse; import org.openhab.binding.ecobee.messages.UpdateThermostatRequest; 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 thermostats we're interested in every few minutes, and sends updates and * commands to Ecobee as they are made. Reviewed lots of other binding implementations, particularly Netatmo and XBMC. * * @author John Cocula * @since 1.7.0 */ public class EcobeeBinding extends AbstractActiveBinding<EcobeeBindingProvider> implements ManagedService, EcobeeActionProvider { private static final String DEFAULT_USER_ID = "DEFAULT_USER"; private static final long DEFAULT_GRANULARITY = 5000; private static final long DEFAULT_REFRESH = 180000; private static final long DEFAULT_QUICKPOLL = 6000; private static final Logger logger = LoggerFactory.getLogger(EcobeeBinding.class); protected static final String CONFIG_GRANULARITY = "granularity"; protected static final String CONFIG_REFRESH = "refresh"; protected static final String CONFIG_QUICKPOLL = "quickpoll"; protected static final String CONFIG_TIMEOUT = "timeout"; protected static final String CONFIG_APP_KEY = "appkey"; protected static final String CONFIG_SCOPE = "scope"; protected static final String CONFIG_TEMP_SCALE = "tempscale"; static { // Register bean type converters ConvertUtils.register(new Converter() { @SuppressWarnings("rawtypes") @Override public Object convert(Class type, Object value) { if (value instanceof DecimalType) { return Temperature.fromLocalTemperature(((DecimalType) value).toBigDecimal()); } else { return null; } } }, Temperature.class); ConvertUtils.register(new Converter() { @SuppressWarnings("rawtypes") @Override public Object convert(Class type, Object value) { if (value instanceof StringType) { return HvacMode.forValue(value.toString()); } else { return null; } } }, HvacMode.class); ConvertUtils.register(new Converter() { @SuppressWarnings("rawtypes") @Override public Object convert(Class type, Object value) { if (value instanceof DecimalType) { return ((DecimalType) value).intValue(); } else { return null; } } }, Integer.class); ConvertUtils.register(new Converter() { @SuppressWarnings("rawtypes") @Override public Object convert(Class type, Object value) { if (value instanceof StringType) { return VentilatorMode.forValue(value.toString()); } else { return null; } } }, VentilatorMode.class); ConvertUtils.register(new Converter() { @SuppressWarnings("rawtypes") @Override public Object convert(Class type, Object value) { if (value instanceof OnOffType) { return ((OnOffType) value) == OnOffType.ON; } else { return null; } } }, Boolean.class); ConvertUtils.register(new Converter() { @SuppressWarnings("rawtypes") @Override public Object convert(Class type, Object value) { return value.toString(); } }, String.class); } /** * the interval which is used to call the execute() method */ private long granularity = DEFAULT_GRANULARITY; /** * the normal refresh interval which is used to poll values from the Ecobee server */ private long refreshInterval = DEFAULT_REFRESH; /** * the quick refresh interval which is used to poll values from the Ecobee server after a command was sent, a state * was updated or an action was called */ private long quickPollInterval = DEFAULT_QUICKPOLL; /** * A map of userids from the openhab.cfg file to OAuth credentials used to communicate with each app instance. */ 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>()); public EcobeeBinding() { } /** * {@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 granularity; } /** * {@inheritDoc} */ @Override protected String getName() { return "Ecobee Refresh Service"; } /** * {@inheritDoc} */ @Override protected void execute() { try { for (String userid : credentialsCache.keySet()) { OAuthCredentials oauthCredentials = getOAuthCredentials(userid); if (oauthCredentials.pollTimeExpired()) { // schedule the next poll at the standard refresh interval oauthCredentials.schedulePoll(this.refreshInterval); logger.trace("Querying Ecobee API for instance {}", oauthCredentials.userid); Selection selection = createSelection(oauthCredentials); if (selection == null) { logger.debug("Nothing to retrieve for '{}'; skipping thermostat retrieval.", oauthCredentials.userid); continue; } if (oauthCredentials.noAccessToken()) { if (!oauthCredentials.refreshTokens()) { logger.warn("Periodic poll skipped for '{}'.", oauthCredentials.userid); continue; } } readEcobee(oauthCredentials, selection); } } } catch (Exception e) { if (logger.isDebugEnabled()) { logger.warn("Exception reading from Ecobee:", e); } else { logger.warn("Exception reading from Ecobee: {}", e.getMessage()); } } } /** * Given the credentials to use and what to select from the Ecobee API, read any changed information from Ecobee and * update the affected items. * * @param oauthCredentials * the credentials to use * @param selection * the selection of data to retrieve */ private void readEcobee(OAuthCredentials oauthCredentials, Selection selection) throws Exception { logger.debug("Requesting summaries for {}", selection); ThermostatSummaryRequest request = new ThermostatSummaryRequest(oauthCredentials.accessToken, selection); ThermostatSummaryResponse response = request.execute(); if (response.isError()) { final Status status = response.getStatus(); if (status.isAccessTokenExpired()) { logger.debug("Access token has expired: {}", status); if (oauthCredentials.refreshTokens()) { readEcobee(oauthCredentials, selection); } } else { logger.error(status.getMessage()); } return; // abort processing } logger.debug("Retrieved summaries for {} thermostat(s).", response.getRevisionList().size()); // Identify which thermostats have changed since the last fetch Map<String, Revision> newRevisionMap = new HashMap<String, Revision>(); for (Revision r : response.getRevisionList()) { newRevisionMap.put(r.getThermostatIdentifier(), r); } // Accumulate the thermostat IDs for thermostats that have updated // since the last fetch. Set<String> thermostatIdentifiers = new HashSet<String>(); for (Revision newRevision : newRevisionMap.values()) { Revision lastRevision = oauthCredentials.getLastRevisionMap().get(newRevision.getThermostatIdentifier()); // If this thermostat's values have changed, // add it to the list for full retrieval /* * NOTE: The following tests may be more eager than they should be, because we may have a settings binding * for one thermostat and not another, and a runtime binding for another thermostat but not this one, but we * will now retrieve both thermostats. A small sin. If the Ecobee binding is only working with a single * thermostat, these tests will be perfectly accurate. */ boolean changed = false; changed = changed || (newRevision.hasRuntimeChanged(lastRevision) && (selection.includeRuntime() || selection.includeExtendedRuntime() || selection.includeSensors())); changed = changed || (newRevision.hasThermostatChanged(lastRevision) && (selection.includeSettings() || selection.includeProgram())); if (changed) { thermostatIdentifiers.add(newRevision.getThermostatIdentifier()); } } // Remember the new revisions for the next execute() call. oauthCredentials.setLastRevisionMap(newRevisionMap); if (0 == thermostatIdentifiers.size()) { logger.debug("No changes detected."); return; } logger.debug("Requesting full retrieval for {} thermostat(s).", thermostatIdentifiers.size()); // Potentially decrease the number of thermostats for the full // retrieval. selection.setSelectionMatch(thermostatIdentifiers); // TODO loop through possibly multiple pages (@watou) ThermostatRequest treq = new ThermostatRequest(oauthCredentials.accessToken, selection, null); ThermostatResponse tres = treq.execute(); if (tres.isError()) { logger.error("Error retrieving thermostats: {}", tres.getStatus()); return; } // Create a ID-based map of the thermostats we retrieved. Map<String, Thermostat> thermostats = new HashMap<String, Thermostat>(); for (Thermostat t : tres.getThermostatList()) { thermostats.put(t.getIdentifier(), t); } // Iterate through bindings and update all inbound values. for (final EcobeeBindingProvider provider : this.providers) { for (final String itemName : provider.getItemNames()) { if (provider.isInBound(itemName) && credentialsMatch(provider, itemName, oauthCredentials) && thermostats.containsKey(provider.getThermostatIdentifier(itemName))) { final State newState = getState(provider, thermostats, itemName); logger.debug("readEcobee: Updating itemName '{}' with newState '{}'", itemName, newState); /* * we need to make sure that we won't send out this event to Ecobee 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 map of thermostats, and an item name, return the corresponding state object. * * @param provider * the Ecobee binding provider * @param thermostats * a map of thermostat identifiers to {@link Thermostat} objects * @param itemName * the item name from the items file. * @return the State object for the named item */ private State getState(EcobeeBindingProvider provider, Map<String, Thermostat> thermostats, String itemName) { final String thermostatIdentifier = provider.getThermostatIdentifier(itemName); final String property = provider.getProperty(itemName); final Thermostat thermostat = thermostats.get(thermostatIdentifier); if (thermostat == null) { logger.error("Did not receive thermostat '{}' for item '{}'; skipping.", thermostatIdentifier, itemName); } else { try { return createState(thermostat.getProperty(property)); } catch (Exception e) { logger.debug("Unable to get state from thermostat", 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 if (Temperature.class.isAssignableFrom(dataType)) { return new DecimalType(((Temperature) propertyValue).toLocalTemperature()); } else if (State.class.isAssignableFrom(dataType)) { return (State) propertyValue; } else { return new StringType(propertyValue.toString()); } } /** * {@inheritDoc} */ @Override protected void internalReceiveCommand(String itemName, Command command) { logger.trace("internalReceiveCommand(item='{}', command='{}')", itemName, command); commandEcobee(itemName, command); } /** * {@inheritDoc} */ @Override protected void internalReceiveUpdate(final String itemName, final State newState) { logger.trace("Received update (item='{}', state='{}')", itemName, newState.toString()); if (!isEcho(itemName, newState)) { updateEcobee(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 commandEcobee(final String itemName, final Command command) { if (command instanceof State) { updateEcobee(itemName, (State) command); } } private boolean isEcho(String itemName, State state) { if (ignoreEventList.remove(new Update(itemName, state))) { logger.trace( "We received this event (item='{}', state='{}') from Ecobee, so we don't send it back again -> ignore!", itemName, state.toString()); return true; } else { return false; } } /** * Send the {@code newState} for the given {@code itemName} to Ecobee. * * @param itemName * @param newState */ private void updateEcobee(final String itemName, final State newState) { // Find the first binding provider for this itemName. EcobeeBindingProvider provider = null; String selectionMatch = null; for (EcobeeBindingProvider p : this.providers) { selectionMatch = p.getThermostatIdentifier(itemName); if (selectionMatch != null) { provider = p; break; } } if (provider == null) { logger.warn("no matching binding provider found [itemName={}, newState={}]", itemName, newState); return; } if (!provider.isOutBound(itemName)) { logger.debug("attempt to update non-outbound item skipped [itemName={}, newState={}]", itemName, newState); return; } final Selection selection = new Selection(selectionMatch); List<AbstractFunction> functions = null; logger.trace("Selection for update: {}", selection); String property = provider.getProperty(itemName); try { final Thermostat thermostat = new Thermostat(null); logger.debug("About to set property '{}' to '{}'", property, newState); thermostat.setProperty(property, newState); logger.trace("Thermostat for update: {}", thermostat); OAuthCredentials oauthCredentials = getOAuthCredentials(provider.getUserid(itemName)); if (oauthCredentials == null) { logger.warn("Unable to locate credentials for item {}; aborting update.", itemName); return; } if (oauthCredentials.noAccessToken()) { if (!oauthCredentials.refreshTokens()) { logger.warn("Sending update skipped."); return; } } UpdateThermostatRequest request = new UpdateThermostatRequest(oauthCredentials.accessToken, selection, functions, thermostat); ApiResponse response = request.execute(); if (response.isError()) { final Status status = response.getStatus(); if (status.isAccessTokenExpired()) { if (oauthCredentials.refreshTokens()) { updateEcobee(itemName, newState); } } else { logger.error("Error updating thermostat(s): {}", response); } } else { // schedule the next poll to happen quickly oauthCredentials.schedulePoll(this.quickPollInterval); } } catch (Exception e) { logger.error("Unable to update thermostat(s)", e); } } /** * {@inheritDoc} */ @Override public boolean callEcobee(final String selection, final AbstractFunction func) { try { logger.trace("Function to call: {}", func); String userid = null; String selectionMatch = selection; if (selectionMatch.contains(".")) { String[] parts = selectionMatch.split("\\."); userid = parts[0]; selectionMatch = parts[1]; } OAuthCredentials oauthCredentials = getOAuthCredentials(userid); if (oauthCredentials == null) { logger.warn("Unable to locate credentials for selection {}; aborting function call.", selection); return false; } final Selection sel = new Selection(selectionMatch); logger.trace("Selection for function: {}", sel); if (oauthCredentials.noAccessToken()) { if (!oauthCredentials.refreshTokens()) { logger.warn("Calling function skipped."); return false; } } List<AbstractFunction> functions = new ArrayList<AbstractFunction>(1); functions.add(func); UpdateThermostatRequest request = new UpdateThermostatRequest(oauthCredentials.accessToken, sel, functions, null); ApiResponse response = request.execute(); if (response.isError()) { final Status status = response.getStatus(); if (status.isAccessTokenExpired()) { if (oauthCredentials.refreshTokens()) { return callEcobee(selection, func); } } else { logger.error("Error calling function: {}", response); } return false; } else { // schedule the next poll to happen quickly oauthCredentials.schedulePoll(this.quickPollInterval); return true; } } catch (Exception e) { logger.error("Unable to call function", e); return false; } } /** * {@inheritDoc} */ @Override public void bindingChanged(BindingProvider provider, String itemName) { // Forget prior revisions because we may be concerned with // different thermostats or properties than before. if (provider instanceof EcobeeBindingProvider) { String userid = ((EcobeeBindingProvider) provider).getUserid(itemName); if (userid != null) { getOAuthCredentials(userid).getLastRevisionMap().clear(); } } super.bindingChanged(provider, itemName); } /** * {@inheritDoc} */ @Override public void allBindingsChanged(BindingProvider provider) { // Forget prior revisions because we may be concerned with // different thermostats or properties than before. if (provider instanceof EcobeeBindingProvider) { for (String userid : this.credentialsCache.keySet()) { getOAuthCredentials(userid).getLastRevisionMap().clear(); } } super.allBindingsChanged(provider); } /** * 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}. 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(EcobeeBindingProvider bindingProvider) { super.addBindingProvider(bindingProvider); } protected void removeBindingProvider(EcobeeBindingProvider bindingProvider) { super.removeBindingProvider(bindingProvider); } /** * {@inheritDoc} */ @Override public void updated(Dictionary<String, ?> config) throws ConfigurationException { if (config != null) { // to override the default granularity one has to add a // parameter to openhab.cfg like ecobee:granularity=2000 String granularityString = Objects.toString(config.get(CONFIG_GRANULARITY), null); granularity = isNotBlank(granularityString) ? Long.parseLong(granularityString) : DEFAULT_GRANULARITY; // to override the default refresh interval one has to add a // parameter to openhab.cfg like ecobee:refresh=240000 String refreshIntervalString = Objects.toString(config.get(CONFIG_REFRESH), null); refreshInterval = isNotBlank(refreshIntervalString) ? Long.parseLong(refreshIntervalString) : DEFAULT_REFRESH; // to override the default quickPoll interval one has to add a // parameter to openhab.cfg like ecobee:quickpoll=4000 String quickPollIntervalString = Objects.toString(config.get(CONFIG_QUICKPOLL), null); quickPollInterval = isNotBlank(quickPollIntervalString) ? Long.parseLong(quickPollIntervalString) : DEFAULT_QUICKPOLL; // to override the default HTTP timeout one has to add a // parameter to openhab.cfg like ecobee:timeout=20000 String timeoutString = Objects.toString(config.get(CONFIG_TIMEOUT), null); if (isNotBlank(timeoutString)) { AbstractRequest.setHttpRequestTimeout(Integer.parseInt(timeoutString)); } // to override the default usage of Fahrenheit one has to add a // parameter to openhab.cfg, as in ecobee:tempscale=C String tempScaleString = Objects.toString(config.get(CONFIG_TEMP_SCALE), null); if (isNotBlank(tempScaleString)) { try { Temperature.setLocalScale(Temperature.Scale.forValue(tempScaleString)); } catch (IllegalArgumentException iae) { throw new ConfigurationException(CONFIG_TEMP_SCALE, "Unsupported temperature scale '" + tempScaleString + "'."); } } 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_GRANULARITY.equals(configKey) || CONFIG_REFRESH.equals(configKey) || CONFIG_QUICKPOLL.equals(configKey) || CONFIG_TIMEOUT.equals(configKey) || CONFIG_TEMP_SCALE.equals(configKey) || "service.pid".equals(configKey)) { continue; } String userid; String configKeyTail; if (configKey.contains(".")) { String[] keyElements = configKey.split("\\."); userid = keyElements[0]; configKeyTail = keyElements[1]; } else { userid = DEFAULT_USER_ID; 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_APP_KEY.equals(configKeyTail)) { credentials.appKey = value; } else if (CONFIG_SCOPE.equals(configKeyTail)) { credentials.scope = 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.appKey == null) { logger.error("Required ecobee:{}{} is missing.", userString, CONFIG_APP_KEY); properlyConfigured = false; break; } if (oauthCredentials.scope == null) { logger.error("Required ecobee:{}{} is missing.", userString, CONFIG_SCOPE); properlyConfigured = false; break; } // Knowing this OAuthCredentials object is complete, load its tokens from persistent storage. oauthCredentials.load(); } setProperlyConfigured(properlyConfigured); } } /** * Return true if the given itemName pertains to the given OAuthCredentials. Since there is a single userid-based * mapping of credential objects for the binding, if the credentials object is the same object as the one in the * userid-based map, then we know that this item pertains to these credentials. * * @param provider * the binding provider * @param itemName * the item name * @param oauthCredentials * the OAuthCredentials to compare * @return true if the given itemName pertains to the given OAuthCredentials. */ private boolean credentialsMatch(EcobeeBindingProvider provider, String itemName, OAuthCredentials oauthCredentials) { return oauthCredentials == getOAuthCredentials(provider.getUserid(itemName)); } /** * Creates the necessary {@link Selection} object to request all information required from the Ecobee API for all * thermostats and sub-objects that have a binding, per set of credentials configured in openhab.cfg. One * {@link ThermostatRequest} can then query all information in one go. * * @param oauthCredentials * constrain the resulting Selection object to only select the thermostats which the configuration * indicates can be reached using these credentials. * @returns the Selection object, or <code>null</code> if only an unsuitable Selection is possible. */ private Selection createSelection(OAuthCredentials oauthCredentials) { final Selection selection = new Selection(SelectionType.THERMOSTATS, null); final Set<String> thermostatIdentifiers = new HashSet<String>(); for (final EcobeeBindingProvider provider : this.providers) { for (final String itemName : provider.getItemNames()) { final String thermostatIdentifier = provider.getThermostatIdentifier(itemName); final String property = provider.getProperty(itemName); /* * We are only concerned with inbound items, so there would be no point to including the criteria for * this item. * * We are also only concerned with items that can be reached by the given credentials. */ if (!provider.isInBound(itemName) || !credentialsMatch(provider, itemName, oauthCredentials)) { continue; } thermostatIdentifiers.add(thermostatIdentifier); if (property.startsWith("settings")) { selection.setIncludeSettings(true); } else if (property.startsWith("runtime")) { selection.setIncludeRuntime(true); } else if (property.startsWith("alerts")) { selection.setIncludeAlerts(true); } else if (property.startsWith("extendedRuntime")) { selection.setIncludeExtendedRuntime(true); } else if (property.startsWith("electricity")) { selection.setIncludeElectricity(true); } else if (property.startsWith("devices")) { selection.setIncludeDevice(true); } else if (property.startsWith("electricity")) { selection.setIncludeElectricity(true); } else if (property.startsWith("location")) { selection.setIncludeLocation(true); } else if (property.startsWith("technician")) { selection.setIncludeTechnician(true); } else if (property.startsWith("utility")) { selection.setIncludeUtility(true); } else if (property.startsWith("management")) { selection.setIncludeManagement(true); } else if (property.startsWith("weather")) { selection.setIncludeWeather(true); } else if (property.startsWith("events") || property.startsWith("runningEvent")) { selection.setIncludeEvents(true); } else if (property.startsWith("program")) { selection.setIncludeProgram(true); } else if (property.startsWith("houseDetails")) { selection.setIncludeHouseDetails(true); } else if (property.startsWith("oemCfg")) { selection.setIncludeOemCfg(true); } else if (property.startsWith("equipmentStatus")) { selection.setIncludeEquipmentStatus(true); } else if (property.startsWith("notificationSettings")) { selection.setIncludeNotificationSettings(true); } else if (property.startsWith("privacy")) { selection.setIncludePrivacy(true); } else if (property.startsWith("version")) { selection.setIncludeVersion(true); } else if (property.startsWith("remoteSensors")) { selection.setIncludeSensors(true); } } } if (thermostatIdentifiers.isEmpty()) { logger.info("No Ecobee in-bindings have been found for selection."); return null; } // include all the thermostats we found in the bindings selection.setSelectionMatch(thermostatIdentifiers); return selection; } /** * This internal class holds the different credentials necessary for the OAuth2 flow to work. It also provides basic * methods to refresh the tokens. * * <p> * OAuth States * <table> * <thead> * <tr> * <th>authToken</th> * <th>refreshToken</th> * <th>accessToken</th> * <th>State</th> * </tr> * <thead> <tbody> * <tr> * <td>null</td> * <td></td> * <td></td> * <td>authorize</td> * </tr> * <tr> * <td>non-null</td> * <td>null</td> * <td></td> * <td>request tokens</td> * </tr> * <tr> * <td>non-null</td> * <td>non-null</td> * <td>null</td> * <td>refresh tokens</td> * </tr> * <tr> * <td>non-null</td> * <td>non-null</td> * <td>non-null</td> * <td>if expired, refresh if any error, authorize</td> * </tr> * </tbody> * </table> * * @author John Cocula * @since 1.7.0 */ static class OAuthCredentials { private static final String APP_KEY = "appKey"; private static final String AUTH_TOKEN = "authToken"; private static final String REFRESH_TOKEN = "refreshToken"; private static final String ACCESS_TOKEN = "accessToken"; private String userid; /** * The private app key needed in order to interact with the Ecobee API. This must be provided in the * <code>openhab.cfg</code> file. */ private String appKey; /** * The scope needed when authorizing this client to the Ecobee API. * * @see AuthorizeRequest */ private String scope; /** * The authorization token needed to request the refresh and access tokens. Obtained and persisted when * {@code authorize()} is called. * * @see AuthorizeRequest * @see #authorize() */ private String authToken; /** * The refresh token to access the Ecobee API. Initial token is received using the <code>authToken</code>, * periodically refreshed using the previous refreshToken, and saved in persistent storage so it can be used * across activations. * * @see TokenRequest * @see RefreshTokenRequest */ private String refreshToken; /** * The access token to access the Ecobee API. Automatically renewed from the API using the refresh token and * persisted for use across activations. * * @see #refreshTokens() */ private String accessToken; /** * The next time to poll this instance. Initially 0 so pollTimeExpired() initially returns true. */ private final AtomicLong pollTime = new AtomicLong(0); /** * The most recently received list of revisions, or an empty Map if none have been retrieved yet. */ private Map<String, Revision> lastRevisionMap = new HashMap<String, Revision>(); public OAuthCredentials(String userid) { this.userid = userid; } public Map<String, Revision> getLastRevisionMap() { return this.lastRevisionMap; } public void setLastRevisionMap(final Map<String, Revision> lastRevisionMap) { this.lastRevisionMap = lastRevisionMap; } private Preferences getPrefsNode() { return Preferences.userRoot().node("org.openhab.ecobee." + userid); } private void load() { Preferences prefs = getPrefsNode(); /* * Only load the tokens if they were not saved with the app key used to create them (backwards * compatibility), or if the saved app key matches the current app key specified in openhab.cfg. This * properly ignores saved tokens when the app key has been changed. */ final String savedAppKey = prefs.get(APP_KEY, null); if (savedAppKey == null || savedAppKey.equals(this.appKey)) { this.authToken = prefs.get(AUTH_TOKEN, null); this.refreshToken = prefs.get(REFRESH_TOKEN, null); this.accessToken = prefs.get(ACCESS_TOKEN, null); } } private void save() { Preferences prefs = getPrefsNode(); prefs.put(APP_KEY, this.appKey); if (this.authToken != null) { prefs.put(AUTH_TOKEN, this.authToken); } else { prefs.remove(AUTH_TOKEN); } if (this.refreshToken != null) { prefs.put(REFRESH_TOKEN, this.refreshToken); } else { prefs.remove(REFRESH_TOKEN); } if (this.accessToken != null) { prefs.put(ACCESS_TOKEN, this.accessToken); } else { prefs.remove(ACCESS_TOKEN); } } public boolean noAccessToken() { return this.accessToken == null; } public void authorize() { logger.trace("Authorizing this binding with the Ecobee API."); final AuthorizeRequest request = new AuthorizeRequest(this.appKey, this.scope); logger.trace("Request: {}", request); final AuthorizeResponse response = request.execute(); logger.trace("Response: {}", response); this.authToken = response.getAuthToken(); this.refreshToken = null; this.accessToken = null; save(); logger.info("#########################################################################################"); logger.info("# Ecobee-Integration: U S E R I N T E R A C T I O N R E Q U I R E D !!"); logger.info("# 1. Login to www.ecobee.com using your '{}' account", this.userid); logger.info("# 2. Enter the PIN '{}' in My Apps within the next {} minutes.", response.getEcobeePin(), response.getExpiresIn()); logger.info("# NOTE: Any API attempts will fail in the meantime."); logger.info("#########################################################################################"); } /** * This method attempts to advance the authorization process by retrieving the tokens needed to use the API. It * returns <code>true</code> if there is reason to believe that an immediately subsequent API call would * succeed. * <p> * This method requests access and refresh tokens to use the Ecobee API. If there is a <code>refreshToken</code> * , it will be used to obtain the tokens, but if there is only an <code>authToken</code>, that will be used * instead. * * @return <code>true</code> if there is reason to believe that an immediately subsequent API call would * succeed. */ public boolean refreshTokens() { if (this.authToken == null) { authorize(); return false; } else { logger.trace("Refreshing tokens."); Request request; if (this.refreshToken == null) { request = new TokenRequest(this.authToken, this.appKey); } else { request = new RefreshTokenRequest(this.refreshToken, this.appKey); } logger.trace("Request: {}", request); final TokenResponse response = (TokenResponse) request.execute(); logger.trace("Response: {}", response); if (response.isError()) { logger.error("Error retrieving tokens: {}", response.getError()); if ("authorization_expired".equals(response.getError())) { this.refreshToken = null; this.accessToken = null; if (request instanceof TokenRequest) { this.authToken = null; } save(); } return false; } else { this.refreshToken = response.getRefreshToken(); this.accessToken = response.getAccessToken(); save(); return true; } } } /** * Return true if this instance is at or past the time to poll. * * @return if this instance is at or past the time to poll. */ private boolean pollTimeExpired() { return System.currentTimeMillis() >= this.pollTime.get(); } /** * Record the earliest time in the future at which we are allowed to poll this instance. * * @param future * the number of milliseconds in the future */ private void schedulePoll(long future) { this.pollTime.set(System.currentTimeMillis() + future); } } }