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:
- User navigates to a URL, logs into their Honeywell Home account, and gives access to the devices.
- Once done Honeywell will make a call to the Callback URL with an auth code.
- The auth code is used to request an auth_token and refresh_token.
- 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.
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