Honeywell Home API with openHAB using just rules

EDIT 1: I moved a whole lot to the library.
EDIT 2: Fixed URL in the update config rule.

As we are all aware, there is not a binding available for the Honeywell Home API. Every now and then over the past few weeks I’ve spent a little bit of time trying to access and interact with their REST API using OH rules and I finally made a break through.

This tutorial is to show what I’ve done in the hopes that it’s useful to someone. I am not suggesting that this approach is the best nor that anyone should pursue it. But it is a good example of what is possible. Ideally this would be written into a binding. Perhaps some day I will get around to doing just that, but don’t let that stop anyone who has time to start on such a binding now.

Prerequisites

  • A Honeywell Home device like a thermostat.
  • A myopenhab.org account and OH successfully connected to it (for the OAuth2 interactions).

Create the app

Navigate to https://developer.honeywellhome.com/ and create an account by clicking on “Sign Up”.

Once logged in click on “Create new APP” with what ever name you want to give it.

Make the “Callback URL” be https://myopenhab.org/static/oauth2.html. If you have exposed your OH to the internet, use that URL instead. We will populate the oauth2.html file in the next step.

Once created the app should be approved and you will have a Consumer Key and Consumer Secret. We will need these later.

That’s all we need to do on the Honeywell side.

OAuth2 Flow

The authentication flow is as follows:

  1. User navigates to a URL, logs into their Honeywell Home account, and gives access to the devices.
  2. Once done Honeywell will make a call to the Callback URL with an auth code.
  3. The auth code is used to request an auth_token and refresh_token.
  4. The auth_token is used in subsequent requests. The auth_token only lasts for a given amount of time. When it expires a new pair of tokens can be requested with the refresh_token.

Callback

We need a way to receive that auth code that Honeywell will send to us via the callback. To receive and process that place the following HTML into $OH_CONF/html/oauth2.html. This contains a little bit of JavaScript to parse out the auth token from the callback and command the Honeywell_AuthCode Item in OH.

<!DOCTYPE html>
<html>
	<head>
		<title>OAuth2 Catcher</title>
	<script type="text/javascript">
	function getAllUrlParams(url) {

  // get query string from url (optional) or window
  var queryString = url ? url.split('?')[1] : window.location.search.slice(1);

  // we'll store the parameters here
  var obj = {};

  // if query string exists
  if (queryString) {

    // stuff after # is not part of query string, so get rid of it
    queryString = queryString.split('#')[0];

    // split our query string into its component parts
    var arr = queryString.split('&');

    for (var i=0; i<arr.length; i++) {
      // separate the keys and the values
      var a = arr[i].split('=');

      // in case params look like: list[]=thing1&list[]=thing2
      var paramNum = undefined;
      var paramName = a[0].replace(/\[\d*\]/, function(v) {
        paramNum = v.slice(1,-1);
        return '';
      });

      // set parameter value (use 'true' if empty)
      var paramValue = typeof(a[1])==='undefined' ? true : a[1];

      // (optional) keep case consistent
      paramName = paramName.toLowerCase();
      paramValue = paramValue.toLowerCase();

      // if parameter name already exists
      if (obj[paramName]) {
        // convert value to array (if still string)
        if (typeof obj[paramName] === 'string') {
          obj[paramName] = [obj[paramName]];
        }
        // if no array index number specified...
        if (typeof paramNum === 'undefined') {
          // put the value on the end of the array
          obj[paramName].push(paramValue);
        }
        // if array index number specified...
        else {
          // put the value at that index number
          obj[paramName][paramNum] = paramValue;
        }
      }
      // if param name doesn't exist yet, set it
      else {
        obj[paramName] = paramValue;
      }
    }
  }

  return obj;
}
var code = getAllUrlParams().code;
	</script>
	</head>
<script>
var item = 'Honeywell_AuthCode'
document.write("Preparing to update " + item + " Item...")
var code = getAllUrlParams().code;
var xhr = new XMLHttpRequest();
xhr.open('POST', "https://myopenhab.org/rest/items/"+item);
xhr.setRequestHeader("Content-Type", "text/plain");
xhr.setRequestHeader("Accept", "application/json");
xhr.onreadystatechange = function() {
	if(xhr.readyState == XMLHttpRequest.DONE && xhr.status == 200) {
		document.write("Success! Received  authcode " + code);
	}
	else if(xhr.status != 200) {
		document.write("Error in callback: " + xhr.status + " " + xhr.responseText);
	}
}
document.write("Sent " + code + "...");
xhr.send(code)
</script>

</html>

Items

We will need a bunch of Items to store a lot of this information. Configure these Items with restoreOnStartup .

String Honeywell_AuthCode     // Stores the most recent auth-code returned by the HTML above
String Honeywell_APIKey       // Stores the API Consumer Key
String Honeywell_APISecret    // Stores the API Consumer Secret
String Honeywell_AccessToken  // Stores the token used for subsequent API requests
String Honeywell_RefreshToken // Stores the token used to request another AccessToken
Number Honeywell_Expiration   // Stores when the auth token expires (don't know the units, minutes perhaps?)
Switch Honeywell_Refresh.     // A switch to run a rule to request a new auth token.

// not shown are the "functional" items that hold stuff like heating setpoints and such

The AuthCode Item gets populated by the OAuth2 Catcher code above. APIKey and APISecret need to be populated manually. See OH 3 Examples: How to boot strap the state of an Item for a way to do that.

The AccessToken, RefreshToken and Expiration will be populated when a request is made with the AuthCode or RefreshToken to get a new one.

These are the Items needed to handle the authentication and authorization necessary to get the AuthToken.

Rules

These rules can be written in any language, even Rules DSL. Because the API is mostly returning JSON I’m using JavaScript.

Until recently the HTTP binding was unable to request the state for an Item using a POST or PUT so I’ve implemented the HTTP calls using the openHAB HTTP Actions.

Library

There are a couple of things that will need to be done over and over from multiple rules: encode the authorzation and process the results from a request for a new AuthToken. I’ve implemented these in a library that gets loaded in the rules that need them.

var HashMap = Java.type("java.util.HashMap");
var HTTP = Java.type("org.openhab.core.model.script.actions.HTTP");

var encodeAuth = function(){
  var Base64 = Java.type("java.util.Base64");
  var Encoder = Base64.getEncoder();

  var authStr = items["Honeywell_APIKey"].toString() + ":" + items["Honeywell_APISecret"].toString();              
  var authEncoded = Encoder.encodeToString(authStr.getBytes("UTF-8"));
  return authEncoded;
};

var processAuth = function(jsonStr){
  var parsed = JSON.parse(jsonStr);
  if(parsed.access_token === undefined || parsed.refresh_token === undefined || parsed.expires_in === undefined){
    logger.error("Received unparsable auth JSON:\n" + jsonStr);
  }
  else {
    events.postUpdate("Honeywell_AccessToken", parsed.access_token);
    events.postUpdate("Honeywell_RefreshToken", parsed.refresh_token);
    events.postUpdate("Honeywell_Expiration", parsed.expires_in);

    while(items["Honeywell_AccessToken"].toString() != parsed.access_token) {
      logger.info("Waiting for Item to update: item="+items["Honeywell_AccessToken"]+ " new="+ parsed.access_token);
      java.lang.Thread.sleep(500);
    }
  }
};

var requestAuthToken = function(refresh){
  refresh = refresh || true;

  if(refresh && items["Honeywell_RefreshToken"].class === UnDefType.class){
    logger.error("Refresh token is undefined!");
    return false;
  }
  else if(!refresh && items["Honeywell_AuthCode"].class === UnDefType.class){
    logger.error("Auth code is undefined!");
    return false;
  }

  var TOKEN_URL = "https://api.honeywell.com/oauth2/token";
  var GT_AUTHCODE = "grant_type="+((refresh) ? "refresh_token" : "authorization_code");
  var REDIRECT_URL = "redirect_uri=https%3A%2F%2Fmyopenhab.org%2Fstatic%2Foauth2.html";
  var CODE = (refresh) ? "refresh_token="+items["Honeywell_RefreshToken"] : REDIRECT_URL+"&code="+items["Honeywell_AuthCode"];

  var authEncoded = encodeAuth();

  logger.info(items["Honeywell_RefreshToken"] + " " + items["Honeywell_AuthCode"]);
  logger.info("URL: " + TOKEN_URL + " Auth Code: " + GT_AUTHCODE + " CODE: " + CODE);

  // Build the header
  var header = new HashMap();
  header.put("Authorization", "Basic " + authEncoded);
  header.put("Accept", "application/json");

  var results = HTTP.sendHttpPostRequest("https://api.honeywell.com/oauth2/token",
                                         "application/x-www-form-urlencoded",
                                         GT_AUTHCODE+"&"+CODE,
                                         header,
                                         15000);

  logger.debug("Results from request for auth token: " + results);
  if(results !== null && results != "" && results !== undefined) {
    processAuth(results);
    return true;
  }
  else {
    logger.error("Failed to retrieve auth token!");
    return false;
  }

};

var buildBearerHeader = function() {
  var header = new HashMap();
  header.put("Authorization", "Bearer " + items["Honeywell_AccessToken"]);
  return header;
}

var httpRequest = function(call) {
  var results = call(buildBearerHeader());
  logger.debug("Results from HTTP call:\n"+results);

  var unauthTest = /.*Unauthorized.*/;
  if(unauthTest.test(results)){

    if(requestAuthToken()){
      results = call(buildBearerHeader());
      logger.debug("Results from second HTTP call:\n"+results);

      if(unauthTest.test(results)){
        logger.error("Still unauthorized after second call");
        results = null;
      }
    }
    else {
      logger.error("Failed to request a new auth token");
      results = null;
    }
  }
  return results;
};

var makeRequest = function(url) {

  return httpRequest(function(header){ return HTTP.sendHttpGetRequest(url, header, 15000); });

};

var makePost = function(url, payload){

  return httpRequest(function(header) { return HTTP.sendHttpPostRequest(url,
                                                    "application/json",
                                                     payload,
                                                     header,
                                                     15000); });
};

encodeAuth

When making the requests for an AuthToken, the requests use Basic Authentication using the APIKey as the username and APISecret as the password. These need to be concatenated and encoded using Base64 and added as a header in the HTTP call.

processAuth

The JSON that is returned is in JSON an has four values. The three relevant values (the fourth will always be “Bearer”) are parsed out and the relevant Items updated. This function will block until the AuthToken Item has been updated.

requestAuthToken

This function will be called when a new authcode is received or when using the refresh token. It makes the API call to get new tokens.

buildBearerHeader

When making an API request the code will make two tries at the call. If the first one fails with an unauthorized message the AuthToken will be refreshed and then the call will be made again. This separates out the building of the header with the AuthToken so it’s easier to create that second request.

httpRequest

This function checks the return from the HTTP GET or HTTP POST command and if it’s unauthorized it implements the actual refresh of the auth token and second attempt to call the API.

makeRequest

This function is called by rules to make an HTTP GET request.

makePost

This function is used to send commands to the API through POST requests.

Retrieve Access Token

triggers:
  - id: "1"
    configuration:
      itemName: Honeywell_AuthCode
    type: core.ItemCommandTrigger
conditions: []
actions:
  - inputs: {}
    id: "3"
    label: Use the new authcode to request a new authtoken
    configuration:
      type: application/javascript
      script: >-
        var logger =
        Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Honeywell");

        this.OPENHAB_CONF = (this.OPENHAB_CONF === undefined) ? java.lang.System.getProperty("openhab.conf") : this.OPENHAB_CONF;

        load(OPENHAB_CONF + "/automation/lib/javascript/personal/honeywell.js");


        logger.info("Requesting an Access Token using code " + CODE);

        requestAuthToken(false);
    type: script.ScriptAction
  - inputs: {}
    id: "2"
    label: Get the location ID
    configuration:
      considerConditions: true
      ruleUIDs:
        - honeywell_getlocationid
    type: core.RunRuleAction
  - inputs: {}
    id: "4"
    label: Get the thermostat ID
    configuration:
      considerConditions: true
      ruleUIDs:
        - honeywell-getthermostatid
    type: core.RunRuleAction

This rule is triggered by the oauth2.html file. In other words, it gets called when a new authcode has been requested. It encodes the BasicAuth and sends the authcode to Honeywell and if all works as expected a new AuthToken and RefreshToken will be returned. It then kicks off the rule that gets the location ID and the termostat ID (see below).

Refresh AuthToken

Note, there appears to be a timeout or some other control built in that will only allow one authcode to work for a period of time. If you request a new authcode it won’t work. I’m not 100% how long or how this works. But I know I spent a ton of time banging my head trying to get an AuthToken and kept getting an “unauthorized” error.

Once you get an authcode, use it to get the AuthToken and RefreshToken (the rule above). From that point forward, only use the Refresh Rule to request new tokens.

triggers:
  - id: "1"
    configuration:
      itemName: Honeywell_Refresh
    type: core.ItemCommandTrigger
conditions: []
actions:
  - inputs: {}
    id: "2"
    label: Use the refresh token to get a new auth token
    configuration:
      type: application/javascript
      script: >
        var logger =
        Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Honeywell");

        this.OPENHAB_CONF = (this.OPENHAB_CONF === undefined) ? java.lang.System.getProperty("openhab.conf") : this.OPENHAB_CONF;

        load(OPENHAB_CONF + "/automation/lib/javascript/personal/honeywell.js");


        requestAuthToken();
    type: script.ScriptAction

The purpose of this rule is to make it easier to request a refresh of the tokens manually. If the token expires, the tokes will automatically be refreshed.

Get Locations

We now have a valid access token and can start to make calls to the API to discover and interact with our devices.

The first thing one needs to do is get the locations. Similar to other similar services, Honeywell allows one to have more than one “house” per account. So part of the unique identifier for a given device is the location.

I’ve only one location so I’ve hard coded a rule to extract the LocationID from the first result.

triggers: []
conditions: []
actions:
  - inputs: {}
    id: "1"
    label: Request the Honeywell LocationID
    configuration:
      type: application/javascript
      script: >-
        var logger =
        Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Honeywell");

        this.OPENHAB_CONF = (this.OPENHAB_CONF === undefined) ? java.lang.System.getProperty("openhab.conf") : this.OPENHAB_CONF;

        load(OPENHAB_CONF + "/automation/lib/javascript/personal/honeywell.js");


        var results = makeRequest("https://api.honeywell.com/v2/locations?apikey="+items["Honeywell_APIKey"]);

        logger.debug("Locations:\n" + results);

        if(results === null){
          logger.error("Failed to get locationID");
        }

        else {
          var locationID = JSON.parse(results)[0].locationID;
          logger.info("First locationID is " + locationID);
          events.postUpdate("Honeywell_LocationID", locationID);
        }
    type: script.ScriptAction

Because I know I only have the one location I parse out the locationID from the first element in the array that is returned and update the Item that stores that value. Thanks to the library, all I really have to deal with here is constructing the URI and parsing the JSON.

Get the Thermostat’s ID

triggers: []
conditions: []
actions:
  - inputs: {}
    id: "1"
    label: Request the Thermostat ID
    description: Assumes the first and only device is the thermostat
    configuration:
      type: application/javascript
      script: >-
        var logger =
        Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Honeywell");

        this.OPENHAB_CONF = (this.OPENHAB_CONF === undefined) ? java.lang.System.getProperty("openhab.conf") : this.OPENHAB_CONF;

        load(OPENHAB_CONF + "/automation/lib/javascript/personal/honeywell.js");


        var results = makeRequest("https://api.honeywell.com/v2/devices?apikey="+items["Honeywell_APIKey"]+"&locationId="+items["Honeywell_LocationID"]);

        logger.debug("Devices:\n" + results);

        if(results === null){
          logger.error("Failed to get devices");
        }

        else {
          var thermostatID = JSON.parse(results)[0].deviceID;
          logger.info("First deviceID is " + thermostatID);
          events.postUpdate("Honeywell_ThermostatID", thermostatID);
        }
    type: script.ScriptAction

This rule is very similar to the Location Id rule. Once again all we really have to do is build the URI and parse the JSON.

Get the Thermostat Status

triggers:
  - id: "2"
    configuration:
      cronExpression: 0 0/5 * * * ? *
    type: timer.GenericCronTrigger
conditions: []
actions:
  - inputs: {}
    id: "1"
    label: Pull the latest thermostat status  and update the relevant items
    configuration:
      type: application/javascript
      script: >-
        var logger =
        Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Honeywell");

        this.OPENHAB_CONF = (this.OPENHAB_CONF === undefined) ? java.lang.System.getProperty("openhab.conf") : this.OPENHAB_CONF;

        load(OPENHAB_CONF + "/automation/lib/javascript/personal/honeywell.js");


        var results = makeRequest("https://api.honeywell.com/v2/devices/thermostats/"+items["Honeywell_ThermostatID"]+"?apikey="+items["Honeywell_APIKey"]+"&locationId="+items["Honeywell_LocationID"]);

        logger.debug("Thermostat status:\n" + results);

        if(results === null){
          logger.error("Failed to get locationID");
        }

        else {
          var parsed = JSON.parse(results);
          var isAlive = parsed.isAlive;
          var currTemp = parsed.indoorTemperature;
          var heatSP = parsed.changeableValues.heatSetpoint;
          var coolSP = parsed.changeableValues.coolSetpoint;
          var mode = parsed.operationStatus.mode;
          var fanMode = parsed.settings.fan.changeableValues.mode;
          var fanRequest = parsed.operationStatus.fanRequest;
          var circulationFanRequest = parsed.operationStatus.circulationFanRequest;

          logger.info("isAlive: " + isAlive + " currTemp: " + currTemp + " heatSP: " + heatSP + " coolSP: " + coolSP + " mode: " + mode 
                      + " fanMode: " + fanMode + " fanRequest: " + fanRequest + " circ: " + circulationFanRequest);
          
          events.postUpdate("Honeywell_isAlive", (isAlive == "true") ? "ON" : "OFF");
          events.postUpdate("Honeywell_CurrentTemp", currTemp + " °F");
          events.postUpdate("Honeywell_HeatSP", heatSP + " °F");
          events.postUpdate("Honeywell_CoolSP", coolSP + " °F");
          events.postUpdate("Honeywell_Mode", mode);
          events.postUpdate("Honeywell_FanMode", fanMode);
          events.postUpdate("Honeywell_Fan", (fanRequest == "true") ? "ON" : "OFF");
          events.postUpdate("Honeywell_FanCirculation", (circulationFanRequest == "true") ? "ON" : "OFF");
        }
    type: script.ScriptAction

This rule runs every five minutes to get the current status of the thermostat. There is a ton of information, I only extract a small portion of it.

Commanding the Fan

triggers:
  - id: "1"
    configuration:
      itemName: Honeywell_Fan
    type: core.ItemCommandTrigger
conditions: []
actions:
  - inputs: {}
    id: "2"
    configuration:
      type: application/javascript
      script: >
        var logger =
        Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Honeywell");

        this.OPENHAB_CONF = (this.OPENHAB_CONF === undefined) ? java.lang.System.getProperty("openhab.conf") : this.OPENHAB_CONF;

        load(OPENHAB_CONF + "/automation/lib/javascript/personal/honeywell.js");


        var url = "https://api.honeywell.com/v2/devices/thermostats/"+items["Honeywell_ThermostatID"]+"/fan?apikey="+items["Honeywell_APIKey"]+"&locationId="+items["Honeywell_LocationID"];

        var data = '{"mode": "'+ ((event.itemCommand == ON) ? 'On' : 'Auto') +'"}';

        makePost(url, data);
    type: script.ScriptAction

This sends a command to the API to turn on/off/auto the fan. To allow for the thermostat to work more autonomously I have a switch that sets it to ON when commanded ON and Auto when commanded to OFF.

Change the Mode or Setpoints

NOTE: this isn’t quite working. I get “One or more fields did not validate correctly.”

triggers:
  - id: "1"
    configuration:
      itemName: Honeywell_CoolSP
    type: core.ItemCommandTrigger
  - id: "2"
    configuration:
      itemName: Honeywell_HeatSP
    type: core.ItemCommandTrigger
  - id: "3"
    configuration:
      itemName: Honeywell_Mode
    type: core.ItemCommandTrigger
conditions: []
actions:
  - inputs: {}
    id: "4"
    configuration:
      type: application/javascript
      script: >-
        var logger =
        Java.type("org.slf4j.LoggerFactory").getLogger("org.openhab.model.script.Honeywell");

        this.OPENHAB_CONF = (this.OPENHAB_CONF === undefined) ? java.lang.System.getProperty("openhab.conf") : this.OPENHAB_CONF;

        load(OPENHAB_CONF + "/automation/lib/javascript/personal/honeywell.js");

        var ScriptExecution = Java.type("org.openhab.core.model.script.actions.ScriptExecution");

        var ZonedDateTime = Java.type("java.time.ZonedDateTime");


        logger.debug("Received a command on a thermostat items, sleeping for a sec to aggregate subsequent commands");

        this.timer = (this.timer === undefined) ? null : this.timer;


        if(this.timer !== null) {
          logger.debug("There already is a timer, rescheduling");
          this.timer.reschedule(ZonedDateTime.now().plusSeconds(3));
        }

        else {
         
          var runme = function() {
            var url = "https://api.honeywell.com/v2/devices/thermostats/"+items["Honeywell_ThermostatID"]+"?apikey="+items["Honeywell_APIKey"]+"&locationId="+items["Honeywell_LocationID"];
            var data = new Object();
            data.mode = items["Honeywell_Mode"].toString();
            data.heatSetpoint = parseInt(items["Honeywell_HeatSP"].toString());
            data.coolSetpoint = parseInt(items["Honeywell_CoolSP"].toString());
            data.thermostatSetpointStatus = "TemporaryHold";
            var jsonstr = JSON.stringify(data, null, 2);
            logger.debug("Sending command to thermostat:\n" + jsonstr);
            logger.debug("Results:\n" + makePost(url, jsonstr));
            this.timer = null;
          };

          this.ScriptExecution.createTimer(ZonedDateTime.now().plusSeconds(3), runme);
        }
    type: script.ScriptAction

Kicking it Off

Now that the Items and Rules are in place you can request the first authcode. In a browser go to

https://api.honeywell.com/oauth2/authorize?response_type=code&client_id={apikey}&redirect_uri={redirectUri}

where {apikey} is your Client API key and the {redirectUri} is https://myopenhab.org/static/oauth2.html (or whatever you configured it to be). The redirectUri needs to match exactly with what you configured.

Log in to your Honeywell API account and grant permissions on your device(s). When done it will redirect you to OH and if it works you should see something like:

Success! Received  authcode 123xyz

Watch the logs and you should see the request for the AuthToken using the authcode and if that’s successful the AuthToken, RefreshToken, and Expire Item will be populated with the new values. Then it should kick off the rule to get the locationID followed by the thermostatID.

Conclusions and Future Work

I’m not going to show all the Honeywell API calls and results. Everything is documented on their web site. All the calls will be very similar to the ones above. This is all I’m going to use this API for.

I’ll state it again that this is really not the best way to integrate something with OH. I only post it here as someone may have no choice and/or it might help solve some other related problems. The “correct” way will be to implement this in a binding.

But for me right now, implementing this in rules is faster for me right now given my lack of a build and development environment for OH add-ons right now. With the summer months starting to heat up being able to control the fan based on the upstairs temperature is a real need. Also, it shows some really powerful things you can accomplish with just a few lines of JavaScript, even using UI rules.

With the changes to the HTTP binding some of these could even potentially be implemented in the HTTP binding, making for even less code. But I like having the option to react to a failure in an HTTP call so will probably stick to using the Actions.

Eventually, if time permits, I hope to develop this into an add-on, but like I said above, don’t let that stop you if you want to implement it yourself! I’ll help test it. :slight_smile:

Some things that would be nice to have with the above.

  • Use the Expires Item to set a timer and preemptively request a new AuthToken before it expires.
  • Cover the full Honeywell API