├── package.json ├── .gitignore ├── README.md └── index.js /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-people", 3 | "version": "0.6.2", 4 | "description": "Homebridge plugin that provides details of who is in a Home", 5 | "keywords": [ 6 | "homebridge-plugin" 7 | ], 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/PeteLawrence/homebridge-people.git" 11 | }, 12 | "engines": { 13 | "node": ">=0.12.0", 14 | "homebridge": ">=0.2.0" 15 | }, 16 | "dependencies": { 17 | "moment": "^2.11.2", 18 | "node-persist": "0.0.8", 19 | "ping": "^0.1.10", 20 | "request": "^2.79.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # homebridge-people specifics 36 | seen.db.json 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **NOTE: Since version 0.5 the configuration changed to platform. You must fix your configuration to match the new configuration format.** 2 | *** 3 | # homebridge-people 4 | This is a plugin for [homebridge](https://github.com/nfarina/homebridge). It monitors who is at home, based on their smartphone being seen on the network recently. 5 | 6 | It can also receive webhooks sent by location-aware mobile apps (such as [Locative](https://my.locative.io), which can use iBeacons and geofencing to provide faster and more accurate location information. 7 | 8 | # Installation 9 | 10 | 1. Install homebridge (if not already installed) using: `npm install -g homebridge` 11 | 2. Install this plugin using: `npm install -g homebridge-people` 12 | 3. Update your configuration file. See below for a sample. 13 | 14 | # Configuration 15 | 16 | ``` 17 | "platforms": [ 18 | { 19 | "platform": "People", 20 | "threshold" : 15, 21 | "anyoneSensor" : true, 22 | "nooneSensor" : false, 23 | "webhookPort": 51828, 24 | "cacheDirectory": "./.node-persist/storage", 25 | "pingInterval": 10000, 26 | "ignoreReEnterExitSeconds": 0, 27 | "people" : [ 28 | { 29 | "name" : "Pete", 30 | "target" : "PetesiPhone", 31 | "threshold" : 15, 32 | "pingInterval": 10000, 33 | "ignoreReEnterExitSeconds": 0 34 | }, 35 | { 36 | "name" : "Someone Else", 37 | "target" : "192.168.1.68", 38 | "threshold" : 15, 39 | "pingInterval": 10000, 40 | "ignoreReEnterExitSeconds": 0 41 | } 42 | ] 43 | } 44 | ] 45 | ``` 46 | 47 | | Parameter | Note | 48 | |----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 49 | | `threshold` | optional, in minutes, default: 15 | 50 | | `anyoneSensor` | optional, default: true | 51 | | `nooneSensor` | optional, default: false | 52 | | `webhookPort` | optional, default: 51828 | 53 | | `cacheDirectory` | optional, default: "./.node-persist/storage" | 54 | | `pingInterval` | optional, in milliseconds, default: 10000, if set to -1 than the ping mechanism will not be used | 55 | | `ignoreReEnterExitSeconds` | optional, in minutes, default: 0, if set to 0 than every enter/exit will trigger state change otherwise the state will only change if no re-enter/exit occurs in specified number of seconds | 56 | | `target` | may be either a hostname or IP address | 57 | | `name` | a human-readable name for your sensor | 58 | 59 | # How it works 60 | * When started homebridge-people will continually ping the IP address associated with each person defined in config.json if `pingInterval` is not set to `-1`. 61 | * With an iBeacon or geofencing smartphone app, you can configure a HTTP push to trigger when you enter and exit your 'home' region. This data will be combined with the ping functionality if used to give this plugin more precise presence data. 62 | * When a ping is successful the current timestamp is logged to a file (seen.db.json) 63 | * When a Homekit enabled app looks up the state of a person, the last seen time for that persons device is compared to the current time minus ```threshold``` minutes, and if it is greater assumes that the person is active. 64 | 65 | # 'Anyone' and 'No One' sensors 66 | Some HomeKit automations need to happen when "anyone" is home or when "no one" is around, but the default Home app makes this difficult. homebridge-people can automatically create additional sensors called "Anyone" and "No One" to make these automations very easy. 67 | 68 | For example, you might want to run your "Arrive Home" scene when _Anyone_ gets home. Or run "Leave Home" when _No One_ is home. 69 | 70 | These sensors can be enabled by adding `"anyoneSensor" : true` and `"nooneSensor" : true` to your homebridge `config.json` file. 71 | 72 | # Accuracy 73 | This plugin requires that the devices being monitored are connected to the network. iPhones (and I expect others) deliberately disconnect from the network once the screen is turned off to save power, meaning just because the device isn't connected, it doesn't mean that the devices owner isn't at home. Fortunately, iPhones (and I expect others) periodically reconnect to the network to check for updates, emails, etc. This plugin works by keeping track of the last time a device was seen, and comparing that to a threshold value (in minutes). 74 | 75 | From a _very_ limited amount of testing, I've found that a threshold of 15 minutes seems to work well for the phones that I have around, but for different phones this may or may not work. The threshold can be configured in the ```.homebridge/config.json``` file. 76 | 77 | Additionally, if you're using a location-aware mobile app to range for iBeacons and geofences, this plugin can receive a HTTP push from the app to immediately see you as present or not present when you physically enter or exit your desired region. This is particularly useful for "Arrive Home" and "Depart Home" HomeKit automations which ideally happen faster than every 15 minutes. 78 | 79 | # Pairing with a location-aware mobile app 80 | Apps like [Locative](https://my.locative.io) range for iBeacons and geofences by using core location APIs available on your smartphone. With bluetooth and location services turned on, these apps can provide an instantaneous update when you enter and exit a desired region. 81 | 82 | To use this plugin with one of these apps, configure your region and set the HTTP push to `http://youripaddress:51828/?sensor=[name]&state=true` for arrival, and `http://youripaddress:51828/?sensor=[name]&state=false` for departure, where `[name]` is the name of the person the device belongs to as specified in your config under `people`. *Note:* you may need to enable port forwarding on your router to accomplish this. 83 | 84 | By default homebridge-people listens on port 51828 for updates. This can be changed by setting `webhookPort` in your homebridge `config.json`. 85 | 86 | # Notes 87 | ## Running on a raspberry pi as non 'pi' user 88 | On some versions of raspbian, users are not able to use the ping program by default. If none of your devices show online try running ```sudo chmod u+s /bin/ping```. Thanks to oberstmueller for the tip. 89 | 90 | # Thanks 91 | Thanks to everyone who's helped contribute code, feedback and support. In particular: 92 | * [wr](https://github.com/wr) - for adding in webhook support. 93 | * [benzman81](https://github.com/benzman81) - for porting the plugin over to be a Platform and improving how ping and webhooks work together, and numerous other fixes. 94 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var ping = require('ping'); 2 | var moment = require('moment'); 3 | var request = require("request"); 4 | var http = require('http'); 5 | var url = require('url'); 6 | var DEFAULT_REQUEST_TIMEOUT = 10000; 7 | var SENSOR_ANYONE = 'Anyone'; 8 | var SENSOR_NOONE = 'No One'; 9 | 10 | var Service, Characteristic, HomebridgeAPI; 11 | module.exports = function(homebridge) { 12 | Service = homebridge.hap.Service; 13 | Characteristic = homebridge.hap.Characteristic; 14 | HomebridgeAPI = homebridge; 15 | 16 | homebridge.registerPlatform("homebridge-people", "People", PeoplePlatform); 17 | homebridge.registerAccessory("homebridge-people", "PeopleAccessory", PeopleAccessory); 18 | homebridge.registerAccessory("homebridge-people", "PeopleAllAccessory", PeopleAllAccessory); 19 | } 20 | 21 | // ####################### 22 | // PeoplePlatform 23 | // ####################### 24 | 25 | function PeoplePlatform(log, config){ 26 | this.log = log; 27 | this.threshold = config['threshold'] || 15; 28 | this.anyoneSensor = config['anyoneSensor'] || true; 29 | this.nooneSensor = config['nooneSensor'] || false; 30 | this.webhookPort = config["webhookPort"] || 51828; 31 | this.cacheDirectory = config["cacheDirectory"] || HomebridgeAPI.user.persistPath(); 32 | this.pingInterval = config["pingInterval"] || 10000; 33 | this.ignoreReEnterExitSeconds = config["ignoreReEnterExitSeconds"] || 0; 34 | this.people = config['people']; 35 | this.storage = require('node-persist'); 36 | this.storage.initSync({dir:this.cacheDirectory}); 37 | this.webhookQueue = []; 38 | } 39 | 40 | PeoplePlatform.prototype = { 41 | 42 | accessories: function(callback) { 43 | this.accessories = []; 44 | this.peopleAccessories = []; 45 | for(var i = 0; i < this.people.length; i++){ 46 | var peopleAccessory = new PeopleAccessory(this.log, this.people[i], this); 47 | this.accessories.push(peopleAccessory); 48 | this.peopleAccessories.push(peopleAccessory); 49 | } 50 | if(this.anyoneSensor) { 51 | this.peopleAnyOneAccessory = new PeopleAllAccessory(this.log, SENSOR_ANYONE, this); 52 | this.accessories.push(this.peopleAnyOneAccessory); 53 | } 54 | if(this.nooneSensor) { 55 | this.peopleNoOneAccessory = new PeopleAllAccessory(this.log, SENSOR_NOONE, this); 56 | this.accessories.push(this.peopleNoOneAccessory); 57 | } 58 | callback(this.accessories); 59 | 60 | this.startServer(); 61 | }, 62 | 63 | startServer: function() { 64 | // 65 | // HTTP webserver code influenced by benzman81's great 66 | // homebridge-http-webhooks homebridge plugin. 67 | // https://github.com/benzman81/homebridge-http-webhooks 68 | // 69 | 70 | // Start the HTTP webserver 71 | http.createServer((function(request, response) { 72 | var theUrl = request.url; 73 | var theUrlParts = url.parse(theUrl, true); 74 | var theUrlParams = theUrlParts.query; 75 | var body = []; 76 | request.on('error', (function(err) { 77 | this.log("WebHook error: %s.", err); 78 | }).bind(this)).on('data', function(chunk) { 79 | body.push(chunk); 80 | }).on('end', (function() { 81 | body = Buffer.concat(body).toString(); 82 | 83 | response.on('error', function(err) { 84 | this.log("WebHook error: %s.", err); 85 | }); 86 | 87 | response.statusCode = 200; 88 | response.setHeader('Content-Type', 'application/json'); 89 | 90 | if(!theUrlParams.sensor || !theUrlParams.state) { 91 | response.statusCode = 404; 92 | response.setHeader("Content-Type", "text/plain"); 93 | var errorText = "WebHook error: No sensor or state specified in request."; 94 | this.log(errorText); 95 | response.write(errorText); 96 | response.end(); 97 | } 98 | else { 99 | var sensor = theUrlParams.sensor.toLowerCase(); 100 | var newState = (theUrlParams.state == "true"); 101 | this.log('Received hook for ' + sensor + ' -> ' + newState); 102 | var responseBody = { 103 | success: true 104 | }; 105 | for(var i = 0; i < this.peopleAccessories.length; i++){ 106 | var peopleAccessory = this.peopleAccessories[i]; 107 | var target = peopleAccessory.target 108 | if(peopleAccessory.name.toLowerCase() === sensor) { 109 | this.clearWebhookQueueForTarget(target); 110 | this.webhookQueue.push({"target": target, "newState": newState, "timeoutvar": setTimeout((function(){ 111 | this.runWebhookFromQueueForTarget(target); 112 | }).bind(this), peopleAccessory.ignoreReEnterExitSeconds * 1000)}); 113 | break; 114 | } 115 | } 116 | response.write(JSON.stringify(responseBody)); 117 | response.end(); 118 | } 119 | }).bind(this)); 120 | }).bind(this)).listen(this.webhookPort); 121 | this.log("WebHook: Started server on port '%s'.", this.webhookPort); 122 | }, 123 | 124 | clearWebhookQueueForTarget: function(target) { 125 | for (var i = 0; i < this.webhookQueue.length; i++) { 126 | var webhookQueueEntry = this.webhookQueue[i]; 127 | if(webhookQueueEntry.target == target) { 128 | clearTimeout(webhookQueueEntry.timeoutvar); 129 | this.webhookQueue.splice(i, 1); 130 | break; 131 | } 132 | } 133 | }, 134 | 135 | runWebhookFromQueueForTarget: function(target) { 136 | for (var i = 0; i < this.webhookQueue.length; i++) { 137 | var webhookQueueEntry = this.webhookQueue[i]; 138 | if(webhookQueueEntry.target == target) { 139 | this.log('Running hook for ' + target + ' -> ' + webhookQueueEntry.newState); 140 | this.webhookQueue.splice(i, 1); 141 | this.storage.setItemSync('lastWebhook_' + target, Date.now()); 142 | this.getPeopleAccessoryForTarget(target).setNewState(webhookQueueEntry.newState); 143 | break; 144 | } 145 | } 146 | }, 147 | 148 | getPeopleAccessoryForTarget: function(target) { 149 | for(var i = 0; i < this.peopleAccessories.length; i++){ 150 | var peopleAccessory = this.peopleAccessories[i]; 151 | if(peopleAccessory.target === target) { 152 | return peopleAccessory; 153 | } 154 | } 155 | return null; 156 | } 157 | } 158 | 159 | // ####################### 160 | // PeopleAccessory 161 | // ####################### 162 | 163 | function PeopleAccessory(log, config, platform) { 164 | this.log = log; 165 | this.name = config['name']; 166 | this.target = config['target']; 167 | this.platform = platform; 168 | this.threshold = config['threshold'] || this.platform.threshold; 169 | this.pingInterval = config['pingInterval'] || this.platform.pingInterval; 170 | this.ignoreReEnterExitSeconds = config['ignoreReEnterExitSeconds'] || this.platform.ignoreReEnterExitSeconds; 171 | this.stateCache = false; 172 | 173 | this.service = new Service.OccupancySensor(this.name); 174 | this.service 175 | .getCharacteristic(Characteristic.OccupancyDetected) 176 | .on('get', this.getState.bind(this)); 177 | 178 | this.initStateCache(); 179 | 180 | if(this.pingInterval > -1) { 181 | this.ping(); 182 | } 183 | } 184 | 185 | PeopleAccessory.encodeState = function(state) { 186 | if (state) 187 | return Characteristic.OccupancyDetected.OCCUPANCY_DETECTED; 188 | else 189 | return Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED; 190 | } 191 | 192 | PeopleAccessory.prototype.getState = function(callback) { 193 | callback(null, PeopleAccessory.encodeState(this.stateCache)); 194 | } 195 | 196 | PeopleAccessory.prototype.initStateCache = function() { 197 | var isActive = this.isActive(); 198 | this.stateCache = isActive; 199 | } 200 | 201 | PeopleAccessory.prototype.isActive = function() { 202 | var lastSeenUnix = this.platform.storage.getItemSync('lastSuccessfulPing_' + this.target); 203 | if (lastSeenUnix) { 204 | var lastSeenMoment = moment(lastSeenUnix); 205 | var activeThreshold = moment().subtract(this.threshold, 'm'); 206 | return lastSeenMoment.isAfter(activeThreshold); 207 | } 208 | return false; 209 | } 210 | 211 | PeopleAccessory.prototype.ping = function() { 212 | if(this.webhookIsOutdated()) { 213 | ping.sys.probe(this.target, function(state){ 214 | if(this.webhookIsOutdated()) { 215 | if (state) { 216 | this.platform.storage.setItemSync('lastSuccessfulPing_' + this.target, Date.now()); 217 | } 218 | if(this.successfulPingOccurredAfterWebhook()) { 219 | var newState = this.isActive(); 220 | this.setNewState(newState); 221 | } 222 | } 223 | setTimeout(PeopleAccessory.prototype.ping.bind(this), this.pingInterval); 224 | }.bind(this)); 225 | } 226 | else { 227 | setTimeout(PeopleAccessory.prototype.ping.bind(this), this.pingInterval); 228 | } 229 | } 230 | 231 | PeopleAccessory.prototype.webhookIsOutdated = function() { 232 | var lastWebhookUnix = this.platform.storage.getItemSync('lastWebhook_' + this.target); 233 | if (lastWebhookUnix) { 234 | var lastWebhookMoment = moment(lastWebhookUnix); 235 | var activeThreshold = moment().subtract(this.threshold, 'm'); 236 | return lastWebhookMoment.isBefore(activeThreshold); 237 | } 238 | return true; 239 | } 240 | 241 | PeopleAccessory.prototype.successfulPingOccurredAfterWebhook = function() { 242 | var lastSuccessfulPing = this.platform.storage.getItemSync('lastSuccessfulPing_' + this.target); 243 | if(!lastSuccessfulPing) { 244 | return false; 245 | } 246 | var lastWebhook = this.platform.storage.getItemSync('lastWebhook_' + this.target); 247 | if(!lastWebhook) { 248 | return true; 249 | } 250 | var lastSuccessfulPingMoment = moment(lastSuccessfulPing); 251 | var lastWebhookMoment = moment(lastWebhook); 252 | return lastSuccessfulPingMoment.isAfter(lastWebhookMoment); 253 | } 254 | 255 | PeopleAccessory.prototype.setNewState = function(newState) { 256 | var oldState = this.stateCache; 257 | if (oldState != newState) { 258 | this.stateCache = newState; 259 | this.service.getCharacteristic(Characteristic.OccupancyDetected).updateValue(PeopleAccessory.encodeState(newState)); 260 | 261 | if(this.platform.peopleAnyOneAccessory) { 262 | this.platform.peopleAnyOneAccessory.refreshState(); 263 | } 264 | 265 | if(this.platform.peopleNoOneAccessory) { 266 | this.platform.peopleNoOneAccessory.refreshState(); 267 | } 268 | 269 | var lastSuccessfulPingMoment = "none"; 270 | var lastWebhookMoment = "none"; 271 | var lastSuccessfulPing = this.platform.storage.getItemSync('lastSuccessfulPing_' + this.target); 272 | if(lastSuccessfulPing) { 273 | lastSuccessfulPingMoment = moment(lastSuccessfulPing).format(); 274 | } 275 | var lastWebhook = this.platform.storage.getItemSync('lastWebhook_' + this.target); 276 | if(lastWebhook) { 277 | lastWebhookMoment = moment(lastWebhook).format(); 278 | } 279 | this.log('Changed occupancy state for %s to %s. Last successful ping %s , last webhook %s .', this.target, newState, lastSuccessfulPingMoment, lastWebhookMoment); 280 | } 281 | } 282 | 283 | PeopleAccessory.prototype.getServices = function() { 284 | return [this.service]; 285 | } 286 | 287 | // ####################### 288 | // PeopleAllAccessory 289 | // ####################### 290 | 291 | function PeopleAllAccessory(log, name, platform) { 292 | this.log = log; 293 | this.name = name; 294 | this.platform = platform; 295 | 296 | this.service = new Service.OccupancySensor(this.name); 297 | this.service 298 | .getCharacteristic(Characteristic.OccupancyDetected) 299 | .on('get', this.getState.bind(this)); 300 | } 301 | 302 | PeopleAllAccessory.prototype.getState = function(callback) { 303 | callback(null, PeopleAccessory.encodeState(this.getStateFromCache())); 304 | } 305 | 306 | PeopleAllAccessory.prototype.getStateFromCache = function() { 307 | var isAnyoneActive = this.getAnyoneStateFromCache(); 308 | if(this.name === SENSOR_NOONE) { 309 | return !isAnyoneActive; 310 | } 311 | else { 312 | return isAnyoneActive; 313 | } 314 | } 315 | 316 | PeopleAllAccessory.prototype.getAnyoneStateFromCache = function() { 317 | for(var i = 0; i < this.platform.peopleAccessories.length; i++){ 318 | var peopleAccessory = this.platform.peopleAccessories[i]; 319 | var isActive = peopleAccessory.stateCache; 320 | if(isActive) { 321 | return true; 322 | } 323 | } 324 | return false; 325 | } 326 | 327 | PeopleAllAccessory.prototype.refreshState = function() { 328 | this.service.getCharacteristic(Characteristic.OccupancyDetected).updateValue(PeopleAccessory.encodeState(this.getStateFromCache())); 329 | } 330 | 331 | PeopleAllAccessory.prototype.getServices = function() { 332 | return [this.service]; 333 | } 334 | --------------------------------------------------------------------------------