From 85e95129f04858f22bd0d1ef543d3fe5da4081df Mon Sep 17 00:00:00 2001 From: Adrian Cable Date: Tue, 13 Aug 2019 10:25:48 -0700 Subject: [PATCH] Replace previous data model with event/push/commit model. Support for access tokens. Various workarounds for HomeKit/Siri bugs. Improved error handling. --- README.md | 17 ++- index.js | 45 +------ lib/nest-connection.js | 212 ++++++++++++++++++++++--------- lib/nest-device-accessory.js | 6 +- lib/nest-mutex.js | 50 -------- lib/nest-protect-accessory.js | 33 +++-- lib/nest-tempsensor-accessory.js | 10 +- lib/nest-thermostat-accessory.js | 127 +++++------------- package.json | 4 +- 9 files changed, 232 insertions(+), 272 deletions(-) delete mode 100644 lib/nest-mutex.js diff --git a/README.md b/README.md index 4559571..98501ab 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # homebridge-nest -Nest plug-in for [Homebridge](https://github.com/nfarina/homebridge) using the native Nest API. See what's new in [release 3.2.4](https://github.com/chrisjshull/homebridge-nest/releases/tag/v3.2.4). +Nest plug-in for [Homebridge](https://github.com/nfarina/homebridge) using the native Nest API. See what's new in [release 3.3.0](https://github.com/chrisjshull/homebridge-nest/releases/tag/v3.3.0). Integrate your Nest Thermostat (including Nest Temperature Sensors) and Nest Protect devices into your HomeKit system. **homebridge-nest no longer uses the 'Works With Nest' API and will be unaffected by its shutdown in August 2019.** -Currently, homebridge-nest supports Nest Thermostat and Nest Protect devices. Camera and Nest Secure/Detect support may come later. (I don't currently own those devices.) +Currently, homebridge-nest supports all Nest Thermostat and Nest Protect models, except the UK model of the Thermostat E with Heat Link. Camera and Nest Secure/Detect support may come later. (I don't currently own those devices.) # Installation 1. Install homebridge using: `npm install -g homebridge` 2. Install this plug-in using: `npm install -g homebridge-nest` -3. Update your configuration file. See `sample-config.json` snippet below. +3. Update your configuration file. See example `config.json` snippet below. You will need your Nest account email address and password - the same credentials you use with the Nest app. A 'Works With Nest' developer account and tokens are not required. @@ -33,6 +33,7 @@ Fields: * `"platform"`: Must always be `"Nest"` (required) * `"email"`: Your Nest account email address (required) * `"password"`: Your Nest account password (required) +* `"access_token"`: Nest access token (optional, see below - you almost certainly don't need this) * `"pin"`: `"number"` // PIN code sent to your mobile device for 2-factor authentication - see below (optional) * `"structureId"`: `"your structure's ID"` // optional structureId to filter to (see logs on first run for each device's structureId) - Nest "structures" are equivalent to HomeKit "homes" * `"options"`: `[ "feature1", "feature2", ... ]` // optional list of features to enable/disable (see below) @@ -44,7 +45,13 @@ Note: the syntax for setting features to enable/disable has changed since 3.0.0. Two-factor authentication is supported if enabled in your Nest account. On starting Homebridge, you will be prompted to enter a PIN code which will be sent to the mobile device number registered to your Nest account. -If you are running Homebridge as a service, you cannot manually enter the PIN in the console. In this case, when you start Homebridge and receive the PIN code, edit config.json and add the PIN received under "pin" (see 'Configuration' above). Then, restart Homebridge. Using 2FA is not recommended if Homebridge is run as a service, because if the connection to the Nest service is interrupted for any reason, homebridge-nest will not be able to automatically reconnect. +If you are running Homebridge as a service, you cannot manually enter the PIN in the console. In this case, when you start Homebridge and receive the PIN code, edit `config.json` and add the PIN received under `"pin"` (see 'Configuration' above). Then, restart Homebridge. Using 2FA is not recommended if Homebridge is run as a service, because if the connection to the Nest service is interrupted for any reason, homebridge-nest will not be able to automatically reconnect. + +# Access Token Mode + +As an alternative to specifying `"email"` and `"password"` in `config.json`, you may provide an `"access_token"` instead (which can be obtained, for example, by logging into `home.nest.com` from your browser and extracting the token from the response of the `session` API call). This may be useful, for example, if your primary account has 2FA enabled and you are running Homebridge in a Docker container or similar where you cannot enter a PIN when Homebridge starts. + +However, we don't recommend this usage - if the token expires, homebridge-nest will not be able to automatically reconnect. Instead, we recommend you use Nest's Family Sharing feature to create an alternative login to the service without 2FA, and use those credentials for homebridge-nest. # HomeKit Accessory Types @@ -81,7 +88,7 @@ Set `"options"` in `config.json` to an array of strings chosen from the followin * `"HomeAway.AsOccupancySensorAndSwitch"` - create Home/Away indicator as an *OccupancySensor* and a *Switch* * `"Protect.Disable"` - exclude Nest Protects from HomeKit -By default, options set apply to all devices. To set an option for a specific device only, add `.device_id` to the corresponding `option`, where `device_id` is shown in the Homebridge logs, or in HomeKit itself as *Serial Number* in the Settings page for your device. For example, to disable one specific thermostat with serial number 09AC01AC31180349, add `Thermostat.Disable.09AC01AC31180349` to `"options"`. +By default, options set apply to all devices. To set an option for a specific device only, add `.device_id` to the corresponding `option`, where `device_id` is shown in the Homebridge logs, or in HomeKit itself as *Serial Number* in the Settings page for your device. For example, to disable one specific thermostat with serial number 09AC01AC31180349, add `"Thermostat.Disable.09AC01AC31180349"` to the `"options"` array. # Things to try with Siri diff --git a/index.js b/index.js index 4d49d71..299ff83 100644 --- a/index.js +++ b/index.js @@ -1,11 +1,10 @@ const NestConnection = require('./lib/nest-connection.js'); -const NestMutex = require('./lib/nest-mutex.js'); const Promise = require('bluebird'); let Service, Characteristic, Accessory, uuid; let ThermostatAccessory, HomeAwayAccessory, TempSensorAccessory, ProtectAccessory; //, CamAccessory; -module.exports = function (homebridge) { +module.exports = function(homebridge) { Service = homebridge.hap.Service; Characteristic = homebridge.hap.Characteristic; Accessory = homebridge.hap.Accessory; @@ -38,45 +37,15 @@ function NestPlatform(log, config) { const setupConnection = function(config, log, verbose) { return new Promise(function (resolve, reject) { - const email = config.email; - const password = config.password; - const pin = config.pin; - const token = ''; - - let err; - if (!email || !password) { - err = 'You did not specify your Nest app {\'email\',\'password\'} in config.json'; - } - if (err) { - reject(new Error(err)); + if (!config.access_token && (!config.email || !config.password)) { + log.error('You did not specify your Nest app credentials {\'email\',\'password\'}, or an access_token, in config.json'); + reject({ code: 'no_credentials' }); return; } - const conn = new NestConnection(token, log, verbose); + const conn = new NestConnection(log, verbose); conn.config = config; - conn.mutex = new NestMutex(log); - if (token) { - resolve(conn); - } else { - conn.auth(email, password, pin) - .then(() => { - resolve(conn); - }) - .catch(function(authError){ - if (log) { - if (authError.code == 400) { - log.warn('Auth failed: email/password is not valid. Check you are using the correct email/password for your Nest account'); - } else if (authError.code == 429) { - log.warn('Auth failed: rate limit exceeded. Please try again in 60 minutes'); - } else if (authError.code == '2fa_error') { - log.warn('Auth failed: 2FA PIN was rejected'); - } else { - log.warn('Auth failed: could not connect to Nest service. Check your Internet connection'); - } - } - reject(authError); - }); - } + conn.auth(config.email, config.password, config.pin, config.access_token).then(() => resolve(conn)); }); }; @@ -136,7 +105,7 @@ NestPlatform.prototype = { }); }; - const handleUpdates = function(data){ + const handleUpdates = function(data) { updateAccessories(data, that.accessoryLookup); }; setupConnection(this.config, this.log, this.optionSet('Debug.Verbose')) diff --git a/lib/nest-connection.js b/lib/nest-connection.js index c1f207e..f0e3057 100644 --- a/lib/nest-connection.js +++ b/lib/nest-connection.js @@ -3,6 +3,7 @@ */ const Promise = require('bluebird'); +const debounce = require('lodash.debounce'); const rp = require('request-promise'); const Prompt = require('promise-prompt'); @@ -14,13 +15,16 @@ module.exports = Connection; const DEFAULT_FAN_DURATION_MINUTES = 15; // Delay after authentication fail before retrying -const API_AUTH_FAIL_RETRY_DELAY_SECONDS = 10; +const API_AUTH_FAIL_RETRY_DELAY_SECONDS = 15; // Interval between Nest subscribe requests const API_SUBSCRIBE_DELAY_SECONDS = 0.1; +// Nest property updates are combined together if less than this time apart, to reduce network traffic +const API_PUSH_DEBOUNCE_SECONDS = 2; + // Timeout API calls after this number of seconds -const API_TIMEOUT_SECONDS = 60; +const API_TIMEOUT_SECONDS = 120; // We want to look like a browser const USER_AGENT_STRING = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36'; @@ -28,9 +32,14 @@ const USER_AGENT_STRING = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) Apple // Endpoint URLs const URL_NEST_AUTH = 'https://home.nest.com/session'; const URL_NEST_VERIFY_PIN = 'https://home.nest.com/api/0.1/2fa/verify_pin'; +const ENDPOINT_PUT = '/v5/put'; +const ENDPOINT_SUBSCRIBE = '/v5/subscribe'; + +function Connection(log, verbose) { + this.token = ''; + this.objectList = { objects: [] }; + this.currentState = {}; -function Connection(token, log, verbose) { - this.token = token; this.log = function(...info) { log.info(...info); }; @@ -47,33 +56,57 @@ function Connection(token, log, verbose) { }; } -Connection.prototype.auth = function(email, password, forcePIN) { - this.mutex.startApiUpdate(); - return new Promise((resolve, reject) => { - rp({ - method: 'POST', - timeout: API_TIMEOUT_SECONDS * 1000, - uri: URL_NEST_AUTH, - headers: { - 'Authorization': 'Basic', - 'User-Agent': USER_AGENT_STRING - }, - body: { - email: email, - password: password - }, - json: true - }).then(body => { +Connection.prototype.pendingUpdates = []; +Connection.prototype.currentData = {}; +Connection.prototype.forceUpdateDataFn = function() { }; +Connection.prototype.connected = false; + +Connection.prototype.auth = function(email, password, forcePIN, access_token) { + return new Promise(resolve => { + let req; + + this.connected = false; + if (access_token) { + req = { + method: 'GET', + followAllRedirects: true, + timeout: API_TIMEOUT_SECONDS * 1000, + uri: URL_NEST_AUTH, + headers: { + 'Authorization': 'Basic ' + access_token, + 'User-Agent': USER_AGENT_STRING + }, + json: true + }; + } else { + req = { + method: 'POST', + followAllRedirects: true, + timeout: API_TIMEOUT_SECONDS * 1000, + uri: URL_NEST_AUTH, + headers: { + 'Authorization': 'Basic', + 'User-Agent': USER_AGENT_STRING + }, + body: { + email: email, + password: password + }, + json: true + }; + } + + rp(req).then(body => { + this.connected = true; this.token = body.access_token; this.transport_url = body.urls.transport_url; this.userid = body.userid; this.loggedin_email = email; this.loggedin_password = password; - this.objectList = { objects: [] }; - this.currentState = {}; resolve(this.token); }).catch(error => { - if (error.statusCode == 401) { + this.connected = false; + if (error.statusCode == 401 && error.response && error.response.body && error.response.body.truncated_phone_number) { // 2FA required let getPIN; @@ -89,6 +122,7 @@ Connection.prototype.auth = function(email, password, forcePIN) { getPIN.then(pin => { return rp({ method: 'POST', + followAllRedirects: true, timeout: API_TIMEOUT_SECONDS * 1000, uri: URL_NEST_VERIFY_PIN, body: { @@ -100,6 +134,7 @@ Connection.prototype.auth = function(email, password, forcePIN) { }).then(result => { return rp({ method: 'GET', + followAllRedirects: true, timeout: API_TIMEOUT_SECONDS * 1000, uri: URL_NEST_AUTH, headers: { @@ -109,39 +144,63 @@ Connection.prototype.auth = function(email, password, forcePIN) { json: true }); }).then(body => { + this.connected = true; this.token = body.access_token; this.transport_url = body.urls.transport_url; this.userid = body.userid; this.loggedin_email = email; this.loggedin_password = password; - this.objectList = { objects: [] }; - this.currentState = {}; resolve(this.token); }).catch(() => { - reject({ code: '2fa_error' }); + this.error('Auth failed: 2FA PIN was rejected'); + resolve(null); }); + } else if (error.statusCode == 400) { + if (access_token) { + this.error('Auth failed: access token rejected'); + } else { + this.error('Auth failed: Nest rejected the account email/password specified in your Homebridge configuration file. Please check.'); + } + resolve(null); + } else if (error.statusCode == 429) { + this.error('Auth failed: rate limit exceeded. Please try again in 60 minutes'); + resolve(null); } else { - this.log('Could not connect to Nest authentication service. Retrying in ' + API_AUTH_FAIL_RETRY_DELAY_SECONDS + ' second(s).'); - resolve(Promise.delay(API_AUTH_FAIL_RETRY_DELAY_SECONDS * 1000).then(() => this.auth(email, password, forcePIN))); - // reject({ code: error.statusCode }); + this.error('Could not authenticate with Nest (code ' + (error.statusCode || (error.cause && error.cause.code)) + '). Retrying in ' + API_AUTH_FAIL_RETRY_DELAY_SECONDS + ' second(s).'); + resolve(Promise.delay(API_AUTH_FAIL_RETRY_DELAY_SECONDS * 1000).then(() => this.auth(this.config.email, this.config.password, this.config.pin, this.config.access_token))); + } + }); + }); +}; + +Connection.prototype.mergePendingUpdates = function(body) { + this.pendingUpdates.forEach(obj => { + let deviceType = obj.object_key.split('.')[0]; + let deviceId = obj.object_key.split('.')[1]; + Object.keys(obj.value).forEach(key => { + if (body[deviceType] && body[deviceType][deviceId]) { + this.verbose(deviceType + '.' + deviceId + '.' + key + ': overriding ' + body[deviceType][deviceId][key] + ' -> ' + obj.value[key]); + body[deviceType][deviceId][key] = obj.value[key]; } - }).finally(() => this.mutex.endApiUpdate()); + }); }); + + return body; }; Connection.prototype.updateData = function() { - if (this.mutex.isApiUpdatePending() || this.mutex.isTemperatureUpdatePending()) { - // Don't get a data update if we are in the middle of pushing updated settings to the device - this.verbose('API: get status update deferred [before call issue] as property change active', this.mutex.getPropertySetPending()); + let data = {}; + let uri = this.objectList.objects.length ? this.transport_url + ENDPOINT_SUBSCRIBE : 'https://home.nest.com/api/0.1/user/' + this.userid + '/app_launch'; + let body = this.objectList.objects.length ? this.objectList : {'known_bucket_types':['device','kryptonite','shared','structure','topaz','user','where'],'known_bucket_versions':[]}; + + if (!this.token || !this.connected) { return Promise.resolve(null); } - let data = {}; - let uri = this.objectList.objects.length ? this.transport_url + '/v5/subscribe' : 'https://home.nest.com/api/0.1/user/' + this.userid + '/app_launch'; - let body = this.objectList.objects.length ? this.objectList : {'known_bucket_types':['buckets','delayed_topaz','demand_response','device','device_alert_dialog','geofence_info','kryptonite','link','message','message_center','metadata','occupancy','quartz','safety','rcs_settings','safety_summary','schedule','shared','structure','structure_history','structure_metadata','topaz','topaz_resource','track','trip','tuneups','user','user_alert_dialog','user_settings','where','widget_track'],'known_bucket_versions':[]}; - + this.verbose('API subscribe POST: subscribing'); return rp({ method: 'POST', + followAllRedirects: true, timeout: API_TIMEOUT_SECONDS * 1000, uri: uri, headers: { @@ -154,13 +213,13 @@ Connection.prototype.updateData = function() { json: true, gzip: true }).then(rawBody => { - let body = createNestBody(this.currentState, rawBody.updated_buckets || rawBody.objects, this.objectList); + let body = this.mergePendingUpdates(createNestBody(this.currentState, rawBody.updated_buckets || rawBody.objects, this.objectList)); data.devices = {}; data.devices['thermostats'] = {}; - data.devices['smoke_co_alarms'] = {}; - data.devices['temp_sensors'] = {}; data.devices['home_away_sensors'] = {}; + data.devices['temp_sensors'] = {}; + data.devices['smoke_co_alarms'] = {}; let structures = body.structure || {}; let shared = body.shared || {}; @@ -251,7 +310,7 @@ Connection.prototype.updateData = function() { data.devices['home_away_sensors'][structureId] = {}; data.devices['home_away_sensors'][structureId].structure_id = structureId; data.devices['home_away_sensors'][structureId].device_id = structureId; - data.devices['home_away_sensors'][structureId].software_version = thisDevice.current_version; + data.devices['home_away_sensors'][structureId].software_version = thisDevice.software_version; data.devices['home_away_sensors'][structureId].serial_number = thisDevice.serial_number; data.devices['home_away_sensors'][structureId].name = 'Home/Away'; data.devices['home_away_sensors'][structureId].away = thisDevice.topaz_away; @@ -261,6 +320,7 @@ Connection.prototype.updateData = function() { }); data.structures = structures; + this.currentData = data; return data; }); }; @@ -269,6 +329,8 @@ Connection.prototype.dataTimerLoop = function(resolve, handler) { var notify = resolve || handler; var apiLoopTimer; + this.forceUpdateDataFn = handler; + this.updateData().then(data => { if (data) { this.verbose('API subscribe POST: got updated data'); @@ -276,10 +338,11 @@ Connection.prototype.dataTimerLoop = function(resolve, handler) { } }).catch(error => { if (!error.cause || (error.cause && error.cause.code != 'ESOCKETTIMEDOUT')) { - this.debug('Nest_API_error', error); + this.debug('Nest_API_error', error.statusCode || (error.cause && error.cause.code) || error); if (error.statusCode == 401 || error.statusCode == 403 || (error.cause && error.cause.code == 'ECONNREFUSED')) { // Token has probably expired, or transport endpoint has changed - re-authenticate this.log('Reauthenticating on Nest service ...'); + this.token = null; this.auth(this.loggedin_email, this.loggedin_password); } } @@ -288,12 +351,15 @@ Connection.prototype.dataTimerLoop = function(resolve, handler) { if (apiLoopTimer) { clearInterval(apiLoopTimer); } - this.verbose('API subscribe POST: subscribing'); this.dataTimerLoop(null, handler); }, API_SUBSCRIBE_DELAY_SECONDS * 1000); }); }; +Connection.prototype.forceUpdateData = function() { + this.forceUpdateDataFn(this.currentData); +}; + Connection.prototype.subscribe = function(handler) { return new Promise(resolve => { this.dataTimerLoop(resolve, handler); @@ -303,7 +369,7 @@ Connection.prototype.subscribe = function(handler) { Connection.prototype.update = function(device, property, value) { this.debug(device, property, value); - let body = {}, additionalPushObject; + let body = {}; body[property] = value; let deviceType = device.split('.')[0]; @@ -319,45 +385,73 @@ Connection.prototype.update = function(device, property, value) { deviceType = 'device'; body = { eco: { mode: value == 'eco' ? 'manual-eco' : 'schedule' } }; } else { - additionalPushObject = createApiObject('device' + '.' + deviceId, { eco: { mode: 'schedule' } }); + this.commitUpdate('device.' + deviceId, { eco: { mode: 'schedule' } }); body = { target_temperature_type: value }; } } } else if (deviceType == 'device') { - if (property == 'away_temperature_high' ) { + if (property == 'away_temperature_high') { body.away_temperature_high_enabled = true; - } else if (property == 'away_temperature_low' ) { + } else if (property == 'away_temperature_low') { body.away_temperature_low_enabled = true; - } else if (property == 'fan_timer_active' ) { + } else if (property == 'fan_timer_active') { body = { fan_timer_timeout: value ? getUnixTime() + ((this.config.fanDurationMinutes || DEFAULT_FAN_DURATION_MINUTES) * 60) : 0 }; } } let nodeId = deviceType + '.' + deviceId; - let url = this.transport_url + '/v5/put'; - let objects = [ createApiObject(nodeId, body) ]; - if (additionalPushObject) { - objects.push(additionalPushObject); + this.commitUpdate(nodeId, body); + return Promise.resolve(); +}; + +Connection.prototype.commitUpdate = function(nodeId, body) { + this.verbose('Committing update', nodeId, body); + let updatingExistingKey = false; + this.pendingUpdates.forEach(obj => { + if (obj.object_key == nodeId) { + updatingExistingKey = true; + Object.keys(body).forEach(key => { + obj.value[key] = body[key]; + }); + } + }); + if (!updatingExistingKey) { + this.pendingUpdates.push(createApiObject(nodeId, body)); } - this.mutex.startApiUpdate(); + this.pushUpdates(); +}; + +Connection.prototype.pushUpdates = debounce(function() { + this.verbose('Pushing updates', Object.keys(this.pendingUpdates).length); + + let updatesToSend = this.pendingUpdates; + this.pendingUpdates = []; + return Promise.resolve(rp({ method: 'POST', + followAllRedirects: true, timeout: API_TIMEOUT_SECONDS * 1000, - uri: url, + uri: this.transport_url + ENDPOINT_PUT, headers: { 'User-Agent': USER_AGENT_STRING, 'Authorization': 'Basic ' + this.token, 'X-nl-protocol-version': 1 }, body: { - objects: objects + objects: updatesToSend }, json: true })).catch(error => { - this.log('Nest API call to change device settings returned an error: ' + error.statusCode); - }).finally(() => this.mutex.endApiUpdate()); -}; + this.log('Nest API call to change device settings returned an error: ' + (error.statusCode || (error.cause && error.cause.code))); + if (error.statusCode == 401 || error.statusCode == 403 || (error.cause && error.cause.code == 'ECONNREFUSED')) { + // Token has probably expired, or transport endpoint has changed - re-authenticate + this.pendingUpdates = updatesToSend; + this.log('Reauthenticating on Nest service ...'); + this.auth(this.loggedin_email, this.loggedin_password).then(() => this.pushUpdates()); + } + }); +}, API_PUSH_DEBOUNCE_SECONDS * 1000); function getUnixTime() { return Math.floor(Date.now() / 1000); diff --git a/lib/nest-device-accessory.js b/lib/nest-device-accessory.js index 0defabd..c0f553d 100644 --- a/lib/nest-device-accessory.js +++ b/lib/nest-device-accessory.js @@ -91,11 +91,13 @@ NestDeviceAccessory.prototype.updateData = function (device, structure) { }); }; -NestDeviceAccessory.prototype.setPropertyAsync = function(type, property, value, propertyDescription, valueDescription) { +NestDeviceAccessory.prototype.setPropertyAsync = function(type, property, value, propertyDescription, valueDescription, doNotUpdateLocalProperty) { propertyDescription = propertyDescription || property; valueDescription = valueDescription || value; this.log.debug('Setting ' + propertyDescription + ' for ' + this.name + ' to: ' + valueDescription); - // this.device[property] = value; + if (!doNotUpdateLocalProperty) { + this.device[property] = value; + } switch (type) { case 'structure': // Structure diff --git a/lib/nest-mutex.js b/lib/nest-mutex.js deleted file mode 100644 index 989f31b..0000000 --- a/lib/nest-mutex.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Created by Adrian Cable on 7/21/19. - */ - -'use strict'; - -module.exports = NestMutex; - -function NestMutex(log) { - this.log = log; - this.apiUpdateCallsPending = 0; - this.apiPropertySetPending = [false, false, false, false, false, false]; -} - -NestMutex.prototype.updateStates = { - targetTemperature: 0, - coolingThresholdTemperature: 1, - heatingThresholdTemperature: 2, - targetHeatingCooling: 3, - ecoMode: 4, - homeAwayMode: 5 -}; - -NestMutex.prototype.isApiUpdatePending = function() { - return (this.apiUpdateCallsPending > 0); -}; - -NestMutex.prototype.isTemperatureUpdatePending = function() { - return (this.apiPropertySetPending.some(el => el)); -}; - -NestMutex.prototype.getPropertySetPending = function() { - return this.apiPropertySetPending; -}; - -NestMutex.prototype.startApiUpdate = function() { - this.apiUpdateCallsPending++; -}; - -NestMutex.prototype.endApiUpdate = function() { - this.apiUpdateCallsPending--; -}; - -NestMutex.prototype.startPropertyUpdate = function(tuType) { - this.apiPropertySetPending[tuType] = true; -}; - -NestMutex.prototype.endPropertyUpdate = function(tuType) { - this.apiPropertySetPending[tuType] = false; -}; diff --git a/lib/nest-protect-accessory.js b/lib/nest-protect-accessory.js index 7257c1c..13c2246 100644 --- a/lib/nest-protect-accessory.js +++ b/lib/nest-protect-accessory.js @@ -57,22 +57,22 @@ function NestProtectAccessory(conn, log, device, structure, platform) { NestDeviceAccessory.call(this, conn, log, device, structure, platform); const smokeSvc = this.addService(Service.SmokeSensor) - .setCharacteristic(Characteristic.Name, this.device.name + ' ' + 'Smoke'); + .setCharacteristic(Characteristic.Name, this.device.name + ' Smoke'); this.bindCharacteristic(smokeSvc, Characteristic.SmokeDetected, 'Smoke', - getSmokeAlarmState.bind(this), null, formatSmokeAlarmState.bind(this)); + this.getSmokeAlarmState, null, this.formatSmokeAlarmState); this.bindCharacteristic(smokeSvc, Characteristic.StatusLowBattery, 'Battery status (Smoke)', - getBatteryHealth.bind(this), null, formatStatusLowBattery.bind(this)); + this.getBatteryHealth, null, this.formatStatusLowBattery); this.bindCharacteristic(smokeSvc, Characteristic.StatusActive, 'Online status (Smoke)', - getOnlineStatus.bind(this), null, formatOnlineStatus.bind(this)); + this.getOnlineStatus, null, this.formatOnlineStatus); const coSvc = this.addService(Service.CarbonMonoxideSensor) - .setCharacteristic(Characteristic.Name, this.device.name + ' ' + 'Carbon Monoxide'); + .setCharacteristic(Characteristic.Name, this.device.name + ' Carbon Monoxide'); this.bindCharacteristic(coSvc, Characteristic.CarbonMonoxideDetected, 'Carbon Monoxide', - getCarbonMonoxideAlarmState.bind(this), null, formatCarbonMonoxideAlarmState.bind(this)); + this.getCarbonMonoxideAlarmState, null, this.formatCarbonMonoxideAlarmState); this.bindCharacteristic(coSvc, Characteristic.StatusLowBattery, 'Battery status (CO)', - getBatteryHealth.bind(this), null, formatStatusLowBattery.bind(this)); + this.getBatteryHealth, null, this.formatStatusLowBattery); this.bindCharacteristic(coSvc, Characteristic.StatusActive, 'Online status (CO)', - getOnlineStatus.bind(this), null, formatOnlineStatus.bind(this)); + this.getOnlineStatus, null, this.formatOnlineStatus); // Add custom characteristics @@ -85,7 +85,7 @@ function NestProtectAccessory(conn, log, device, structure, platform) { // --- SmokeAlarmState --- -const getSmokeAlarmState = function() { +NestProtectAccessory.prototype.getSmokeAlarmState = function() { switch (AlarmState[this.device.smoke_alarm_state]) { case AlarmState.ok: return Characteristic.SmokeDetected.SMOKE_NOT_DETECTED; @@ -94,7 +94,7 @@ const getSmokeAlarmState = function() { } }; -const formatSmokeAlarmState = function(val) { +NestProtectAccessory.prototype.formatSmokeAlarmState = function(val) { switch (val) { case Characteristic.SmokeDetected.SMOKE_NOT_DETECTED: return 'not detected'; @@ -108,7 +108,7 @@ const formatSmokeAlarmState = function(val) { // --- CarbonMonoxideAlarmState --- -const getCarbonMonoxideAlarmState = function() { +NestProtectAccessory.prototype.getCarbonMonoxideAlarmState = function() { switch (AlarmState[this.device.co_alarm_state]) { case AlarmState.ok: return Characteristic.CarbonMonoxideDetected.CO_LEVELS_NORMAL; @@ -117,7 +117,7 @@ const getCarbonMonoxideAlarmState = function() { } }; -const formatCarbonMonoxideAlarmState = function(val) { +NestProtectAccessory.prototype.formatCarbonMonoxideAlarmState = function(val) { switch (val) { case Characteristic.CarbonMonoxideDetected.CO_LEVELS_NORMAL: return 'normal'; @@ -128,10 +128,9 @@ const formatCarbonMonoxideAlarmState = function(val) { } }; - // --- BatteryHealth --- -const getBatteryHealth = function () { +NestProtectAccessory.prototype.getBatteryHealth = function () { switch (this.device.battery_health) { case 'ok': return Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL; @@ -140,7 +139,7 @@ const getBatteryHealth = function () { } }; -const formatStatusLowBattery = function (val) { +NestProtectAccessory.prototype.formatStatusLowBattery = function (val) { switch (val) { case Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL: return 'normal'; @@ -154,11 +153,11 @@ const formatStatusLowBattery = function (val) { // --- OnlineStatus --- -const getOnlineStatus = function () { +NestProtectAccessory.prototype.getOnlineStatus = function () { return this.device.is_online; }; -const formatOnlineStatus = function (val) { +NestProtectAccessory.prototype.formatOnlineStatus = function (val) { switch (val) { case true: return 'online'; diff --git a/lib/nest-tempsensor-accessory.js b/lib/nest-tempsensor-accessory.js index ff83f6f..92db443 100644 --- a/lib/nest-tempsensor-accessory.js +++ b/lib/nest-tempsensor-accessory.js @@ -34,7 +34,7 @@ function NestTempSensorAccessory(conn, log, device, structure, platform) { NestDeviceAccessory.call(this, conn, log, device, structure, platform); const tempService = this.addService(Service.TemperatureSensor, device.name, device.device_id); - this.bindCharacteristic(tempService, Characteristic.CurrentTemperature, 'Temperature', this.getSensorTemperature); + this.bindCharacteristic(tempService, Characteristic.CurrentTemperature, 'Temperature', this.getSensorTemperature, null, this.formatAsDisplayTemperature); this.updateData(); } @@ -42,3 +42,11 @@ function NestTempSensorAccessory(conn, log, device, structure, platform) { NestTempSensorAccessory.prototype.getSensorTemperature = function () { return this.device.current_temperature; }; + +NestTempSensorAccessory.prototype.formatAsDisplayTemperature = function(t) { + return t + ' °C / ' + Math.round(celsiusToFahrenheit(t)) + ' °F'; +}; + +function celsiusToFahrenheit(temperature) { + return (temperature * 1.8) + 32; +} diff --git a/lib/nest-thermostat-accessory.js b/lib/nest-thermostat-accessory.js index bbf749f..9a3beae 100644 --- a/lib/nest-thermostat-accessory.js +++ b/lib/nest-thermostat-accessory.js @@ -3,7 +3,6 @@ */ const Promise = require('bluebird'); -const debounce = require('lodash.debounce'); const inherits = require('util').inherits; let Accessory, Service, Characteristic; let FanTimerActive, FanTimerDuration, HasLeaf, SunlightCorrectionEnabled, SunlightCorrectionActive, UsingEmergencyHeat; @@ -11,9 +10,6 @@ const NestDeviceAccessory = require('./nest-device-accessory')(); 'use strict'; -// Temperature set debounce interval - stops multiple API calls if there are rapid temperature set changes -const API_TEMP_DEBOUNCE_SECONDS = 1; - module.exports = function(exportedTypes) { if (exportedTypes && !Accessory) { Accessory = exportedTypes.Accessory; @@ -317,7 +313,7 @@ NestThermostatAccessory.prototype.getCurrentRelativeHumidity = function () { // Siri will use this even when in AUTO mode NestThermostatAccessory.prototype.getTargetTemperature = function () { - this.verbose('getTargetTemperature', this.device.hvac_mode, this.device.hvac_state); + this.verbose('getTargetTemperature', this.device.hvac_mode, this.device.hvac_state, this.device.target_temperature); switch (this.device.hvac_mode) { case 'eco': if (this.device.away_temperature_low_enabled && !this.device.away_temperature_high_enabled) { @@ -339,6 +335,9 @@ NestThermostatAccessory.prototype.getCoolingThresholdTemperature = function () { case 'eco': // away_temperature deprecated in v5. in v6 use eco_temperature but if undefined, fallback to away_temperature return this.unroundTemperature('eco_temperature_high') || this.unroundTemperature('away_temperature_high'); + case 'heat': + case 'cool': + return null; // Work-around for iOS bug - Siri queries cooling threshold temperature even when not in auto mode default: return this.unroundTemperature('target_temperature_high') || this.unroundTemperature('target_temperature'); } @@ -349,6 +348,9 @@ NestThermostatAccessory.prototype.getHeatingThresholdTemperature = function () { case 'eco': // away_temperature deprecated in v5. in v6 use eco_temperature but if undefined, fallback to away_temperature return this.unroundTemperature('eco_temperature_low') || this.unroundTemperature('away_temperature_low'); + case 'heat': + case 'cool': + return null; // Work-around for iOS bug - Siri queries heating threshold temperature even when not in auto mode default: return this.unroundTemperature('target_temperature_low') || this.unroundTemperature('target_temperature'); } @@ -404,83 +406,15 @@ NestThermostatAccessory.prototype.setTargetHeatingCooling = function (targetHeat return void callback(); } - this.conn.mutex.startPropertyUpdate(this.conn.mutex.updateStates.targetHeatingCooling); this.verbose('setTargetHeatingCooling', val); - this.device['hvac_mode'] = val; - return this.setPropertyAsync('shared', 'hvac_mode', val, 'target heating cooling').asCallback(callback).finally(() => this.conn.mutex.endPropertyUpdate(this.conn.mutex.updateStates.targetHeatingCooling)); + this.device.hvac_mode = val; + this.conn.forceUpdateData(); + return this.setPropertyAsync('shared', 'hvac_mode', val, 'target heating cooling').asCallback(callback); }; -// Note: HomeKit is not smart enough to avoid sending every temp change while waiting for callback to invoke NestThermostatAccessory.prototype.setTargetTemperature = function(targetTemperature, callback) { this.verbose('setTargetTemperature', targetTemperature); - let setting = 'target_temperature'; - if (this.device.hvac_mode === 'eco') { - if (this.device.away_temperature_low_enabled && !this.device.away_temperature_high_enabled) { - setting = 'away_temperature_low'; - } else if (this.device.away_temperature_high_enabled && !this.device.away_temperature_low_enabled) { - setting = 'away_temperature_high'; - } else { - return void callback(); - } - } else if (this.device.hvac_mode === 'range') { - return void callback(); - } - this.device[setting] = targetTemperature; - - this.conn.mutex.startPropertyUpdate(this.conn.mutex.updateStates.targetTemperature); - this.log('Queuing to set temperature ' + this.formatAsDisplayTemperature(targetTemperature)); - this.setTargetTemperatureDebounced(targetTemperature, function(error) { - this.conn.mutex.endPropertyUpdate(this.conn.mutex.updateStates.targetTemperature); - this.log('Temperature set to ' + this.formatAsDisplayTemperature(targetTemperature), error); - }.bind(this)); - callback(); -}; - -NestThermostatAccessory.prototype.setCoolingThresholdTemperature = function(targetTemperature, callback) { - this.verbose('setCoolingThresholdTemperature', targetTemperature); - - let setting = 'target_temperature_high'; - if (this.device.hvac_mode == 'eco') { - setting = 'away_temperature_high'; - } else if (this.device.hvac_mode == 'cool') { - // Work around for iOS bug - should not be adjusting heating threshold unless in Auto mode - setting = 'target_temperature'; - } - this.device[setting] = targetTemperature; - - this.conn.mutex.startPropertyUpdate(this.conn.mutex.updateStates.coolingThresholdTemperature); - this.log('Queuing to set cooling threshold temperature ' + this.formatAsDisplayTemperature(targetTemperature)); - this.setCoolingThresholdTemperatureDebounced(targetTemperature, function(error) { - this.conn.mutex.endPropertyUpdate(this.conn.mutex.updateStates.coolingThresholdTemperature); - this.log('Cooling threshold temperature set to ' + this.formatAsDisplayTemperature(targetTemperature), error); - }.bind(this)); - callback(); -}; - -NestThermostatAccessory.prototype.setHeatingThresholdTemperature = function(targetTemperature, callback) { - this.verbose('setHeatingThresholdTemperature', targetTemperature); - - let setting = 'target_temperature_low'; - if (this.device.hvac_mode == 'eco') { - setting = 'away_temperature_low'; - } else if (this.device.hvac_mode == 'heat') { - // Work around for iOS bug - should not be adjusting heating threshold unless in Auto mode - setting = 'target_temperature'; - } - this.device[setting] = targetTemperature; - - this.conn.mutex.startPropertyUpdate(this.conn.mutex.updateStates.heatingThresholdTemperature); - this.log('Queuing to set heating threshold temperature ' + this.formatAsDisplayTemperature(targetTemperature)); - this.setHeatingThresholdTemperatureDebounced(targetTemperature, function(error) { - this.conn.mutex.endPropertyUpdate(this.conn.mutex.updateStates.heatingThresholdTemperature); - this.log('Heating threshold temperature set to ' + this.formatAsDisplayTemperature(targetTemperature), error); - }.bind(this)); - callback(); -}; - -// todo: why does this sometimes reset while dragging? (change event coming in while new change queued?) -NestThermostatAccessory.prototype.setTargetTemperatureDebounced = debounce(function (targetTemperature, callback) { let deviceType = 'shared'; let setting = 'target_temperature'; let mode = this.device.hvac_mode; @@ -499,23 +433,28 @@ NestThermostatAccessory.prototype.setTargetTemperatureDebounced = debounce(funct return void callback(); } } else if (mode === 'eco') { + deviceType = 'device'; if (this.device.away_temperature_low_enabled && !this.device.away_temperature_high_enabled) { - deviceType = 'device'; setting = 'away_temperature_low'; } else if (this.device.away_temperature_high_enabled && !this.device.away_temperature_low_enabled) { - deviceType = 'device'; setting = 'away_temperature_high'; + } else { + return void callback(); } + } else if (mode === 'range') { + return void callback(); } + this.log('Setting temperature to ' + this.formatAsDisplayTemperature(targetTemperature)); return promise.then(() => this.setPropertyAsync(deviceType, setting, targetTemperature, 'target temperature')).asCallback(callback); -}, API_TEMP_DEBOUNCE_SECONDS * 1000); +}; + +NestThermostatAccessory.prototype.setCoolingThresholdTemperature = function(targetTemperature, callback) { + this.verbose('setCoolingThresholdTemperature', targetTemperature); -NestThermostatAccessory.prototype.setCoolingThresholdTemperatureDebounced = debounce(function (targetTemperature, callback) { let mode = this.device.hvac_mode; let deviceType = 'shared'; let setting = 'target_temperature_high'; - if (mode === 'eco') { if (this.device.can_heat && this.device.can_cool) { deviceType = 'device'; @@ -523,19 +462,18 @@ NestThermostatAccessory.prototype.setCoolingThresholdTemperatureDebounced = debo } else { return void callback(); } - } else if (mode == 'cool') { - // Work around for iOS bug - should not be adjusting heating threshold unless in Auto mode - setting = 'target_temperature'; } + this.log('Setting cooling threshold temperature to ' + this.formatAsDisplayTemperature(targetTemperature)); return this.setPropertyAsync(deviceType, setting, targetTemperature, 'cooling threshold temperature').asCallback(callback); -}, API_TEMP_DEBOUNCE_SECONDS * 1000); +}; + +NestThermostatAccessory.prototype.setHeatingThresholdTemperature = function(targetTemperature, callback) { + this.verbose('setHeatingThresholdTemperature', targetTemperature); -NestThermostatAccessory.prototype.setHeatingThresholdTemperatureDebounced = debounce(function (targetTemperature, callback) { let mode = this.device.hvac_mode; let deviceType = 'shared'; let setting = 'target_temperature_low'; - if (mode === 'eco') { if (this.device.can_heat && this.device.can_cool) { deviceType = 'device'; @@ -543,13 +481,11 @@ NestThermostatAccessory.prototype.setHeatingThresholdTemperatureDebounced = debo } else { return void callback(); } - } else if (mode == 'heat') { - // Work around for iOS bug - should not be adjusting heating threshold unless in Auto mode - setting = 'target_temperature'; } + this.log('Setting heating threshold temperature to ' + this.formatAsDisplayTemperature(targetTemperature)); return this.setPropertyAsync(deviceType, setting, targetTemperature, 'heating threshold temperature').asCallback(callback); -}, API_TEMP_DEBOUNCE_SECONDS * 1000); +}; NestThermostatAccessory.prototype.getFanState = function () { return this.device.fan_timer_active; @@ -557,11 +493,7 @@ NestThermostatAccessory.prototype.getFanState = function () { NestThermostatAccessory.prototype.setFanState = function (targetFanState, callback) { this.log('Setting target fan state for ' + this.name + ' to: ' + targetFanState); - - return this.setPropertyAsync('device', 'fan_timer_active', Boolean(targetFanState), 'fan enable/disable') - .asCallback(function () { - setTimeout(callback, 3000, ...arguments); // fan seems to "flicker" when you first enable it - }); + return this.setPropertyAsync('device', 'fan_timer_active', Boolean(targetFanState), 'fan enable/disable').asCallback(callback); }; NestThermostatAccessory.prototype.getEcoMode = function () { @@ -572,8 +504,7 @@ NestThermostatAccessory.prototype.setEcoMode = function (eco, callback) { const val = eco ? 'eco' : this.device.previous_hvac_mode; this.log.info('Setting Eco Mode for ' + this.name + ' to: ' + val); this.device.hvac_mode = val; - this.conn.mutex.startPropertyUpdate(this.conn.mutex.updateStates.ecoMode); - return this.setPropertyAsync('shared', 'hvac_mode', eco ? 'eco' : 'eco-off', 'target heating cooling').asCallback(callback).finally(() => this.conn.mutex.endPropertyUpdate(this.conn.mutex.updateStates.ecoMode)); + return this.setPropertyAsync('shared', 'hvac_mode', eco ? 'eco' : 'eco-off', 'target heating cooling', null, true).asCallback(callback); }; NestThermostatAccessory.prototype.formatAsDisplayTemperature = function(t) { diff --git a/package.json b/package.json index 4228bf4..1b72d15 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "prepublishOnly": "npm run lint", "preversion": "npm run lint" }, - "version": "3.2.4", + "version": "3.3.0", "warnings": [ { "code": "ENOTSUP", @@ -39,7 +39,7 @@ "node": ">=7.0.0", "homebridge": ">=0.2.5" }, - "pkgid": "homebridge-nest@3.2.4" + "pkgid": "homebridge-nest@3.3.0" } ] }