diff --git a/.gitignore b/.gitignore index dbe46b1..450a904 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ CVS/ .com.apple.timemachine.supported tests/out/ .nyc_output +.idea +package-lock.json \ No newline at end of file diff --git a/doorbot.js b/doorbot.js index 418a6b5..bfd96e6 100644 --- a/doorbot.js +++ b/doorbot.js @@ -3,11 +3,16 @@ const parse = require('url').parse; const format = require('url').format; const stringify = require('querystring').stringify; const crypto = require("crypto"); - +const io = require('socket.io-client'); const logger = require('debug')('doorbot'); +const fs = require('fs'); + +const API_VERSION = 11; +let hardware_id = crypto.randomBytes(16).toString("hex"); -const API_VERSION = 9; -const hardware_id = crypto.randomBytes(16).toString("hex"); +const homeDir = require('os').homedir(); +const path = require('path'); +const cacheFile = ".ringAlarmCache"; const formatDates = (key, value) => { if (value && value.indexOf && value.indexOf('.000Z') > -1) { @@ -51,11 +56,16 @@ class Doorbot { options = options || {}; this.username = options.username || options.email; this.password = options.password; - this.retries = options.retries || 0; + this.retries = options.retries || 10; this.counter = 0; - this.userAgent = options.userAgent || 'android:com.ringapp:2.0.67(423)'; + this.userAgent = options.userAgent || 'Dalvik/2.1.0 (Linux; U; Android 8.1.0; LG-RS988 Build/OPM6.171019.030.H1)'; this.token = options.token || null; + this.oauthToken = options.oauthToken || null; + this.alarmSockets = {}; + this.alarmCallbacks = {}; this.api_version = options.api_version || API_VERSION; + this.cacheDir = options.cacheDir || homeDir; + if (!this.username) { throw(new Error('username is required')); @@ -63,13 +73,21 @@ class Doorbot { if (!this.password) { throw(new Error('password is required')); } + + this.loadingCache = false; + this.cacheQueue = []; + this.loadCache(this.cacheDir); + this.authenticating = false; this.authQueue = []; } fetch(method, url, query, body, callback) { logger('fetch:', this.counter, method, url); - var d = parse('https://api.ring.com/clients_api' + url, true); + var d = parse(url, true); + if(url.indexOf('http') === -1) + d = parse('https://api.ring.com/clients_api' + url, true); + logger('query', query); delete d.path; delete d.href; @@ -86,6 +104,7 @@ class Doorbot { logger('fetch-data', d); d.method = method; d.headers = d.headers || {}; + d.headers['Authorization'] = "Bearer " + this.oauthToken; if (body) { body = stringify(body); d.headers['content-type'] = 'application/x-www-form-urlencoded'; @@ -133,66 +152,63 @@ class Doorbot { req.end(); } - simpleRequest(url, method, data, callback) { - if (typeof data === 'function') { - callback = data; - data = null; - } - /*istanbul ignore next*/ - if (data && !data.api_version) { - data.api_version = this.api_version; - } - this.authenticate((e) => { - if (e && !this.retries) { - return callback(e); + loadCache(cacheDir){ + this.loadingCache = true; + fs.readFile(path.join(cacheDir,cacheFile), 'utf8', (err, data) => { + this.loadingCache = false; + if(!err) { + let jsonData = JSON.parse(data); + hardware_id = jsonData.hardware_id; + this.oauthToken = jsonData.oauthToken; + this.refreshToken = jsonData.refreshToken; + logger("found cached data: " + stringify(jsonData)); + } + else + logger('error loading cached data' + err); + if (this.cacheQueue.length) { + logger(`Clearing ${this.cacheQueue.length} callbacks from the cache queue`); + this.cacheQueue.forEach(_cb => { + return _cb(); + }); + this.cacheQueue = []; } - this.fetch(method, url, { - api_version: this.api_version, - auth_token: this.token - }, data, (e, res, json) => { - logger('code', json.statusCode); - logger('headers', json.headers); - logger(e); - if (e && e.code === 401 && this.counter < this.retries) { - logger('auth failed, retrying', e); - this.counter += 1; - setTimeout(() => { - this.token = null; - this.authenticate((e) => { - /*istanbul ignore next*/ - if (e) { - return callback(e); - } - this.simpleRequest(url, method, callback); - }); - }, 500); - return; - } - this.counter = 0; - callback(e, res, json); - }); }); } - authenticate(callback) { - if (this.token) { - logger('auth skipped, we have a token'); - return callback(); - } - if (this.authenticating) { - logger('authenticate in progress, queuing callback'); - this.authQueue.push(callback); - return; - } - this.authenticating = true; - logger('authenticating with oAuth..'); - const body = JSON.stringify({ - client_id: "ring_official_android", - grant_type: "password", - username: this.username, - password: this.password, - scope: "client" + writeCache(){ + let outObj = { + oauthToken: this.oauthToken, + refreshToken: this.refreshToken, + hardware_id: hardware_id + }; + let outStr = JSON.stringify(outObj); + fs.writeFile(path.join(this.cacheDir, cacheFile), outStr, 'utf8', (err) => { + if(err) logger('failed to persist token data' + err); + else logger('successfully saved token data'); }); + } + + + + loginOauth(callback, type){ + logger('authenticating with oAuth...'); + let body; + if(type === "login") + body = JSON.stringify({ + client_id: "ring_official_android", + grant_type: "password", + username: this.username, + password: this.password, + scope: "client" + }); + else if(type === "refresh") + body = JSON.stringify({ + client_id: "ring_official_android", + grant_type: "refresh_token", + refresh_token: this.refreshToken, + scope: "client" + }); + const url = parse('https://oauth.ring.com/oauth/token'); url.method = 'POST'; url.headers = { @@ -212,80 +228,133 @@ class Doorbot { json = JSON.parse(data); } catch (je) { logger('JSON parse error', data); - logger(je); + logger(je); e = new Error('JSON parse error from ring, check logging..'); } let token = null; - if (json && json.access_token) { + if (json && json.access_token && json.refresh_token) { token = json.access_token; + this.oauthToken = token; + this.refreshToken = json.refresh_token; logger('authentication_token', token); + this.writeCache(); } if (!token || e) { logger('access_token request failed, bailing..'); - e = e || new Error('Api failed to return an authentication_token'); + e = e || new Error('API failed to return an authentication_token'); return callback(e); } - const body = JSON.stringify({ - device: { - hardware_id: hardware_id, - metadata: { - api_version: this.api_version, - }, - os: "android" - } - }); - logger('session json', body); - const sessionURL = `https://api.ring.com/clients_api/session?api_version=${this.api_version}`; - logger('sessionURL', sessionURL); - const u = parse(sessionURL, true); - u.method = 'POST'; - u.headers = { - Authorization: 'Bearer ' + token, - 'content-type': 'application/json', - 'content-length': body.length - }; - logger('fetching token with oAuth access_token'); - const a = https.request(u, (res) => { - logger('token fetch statusCode', res.statusCode); - logger('token fetch headers', res.headers); - let data = ''; - let e = null; - res.on('data', d => {return data += d;}); - res.on('end', () => { - let json = null; - try { - json = JSON.parse(data); - } catch (je) { - logger('JSON parse error', data); - logger(je); - e = 'JSON parse error from ring, check logging..'; - } - logger('token fetch response', json); - const token = json && json.profile && json.profile.authentication_token; - if (!token || e) { - /*istanbul ignore next*/ - const msg = e || json && json.error || 'Authentication failed'; - return callback(new Error(msg)); - } - //Timeout after authentication to let the token take effect - //performance issue.. - setTimeout(() => { - this.token = token; - this.authenticating = false; - if (this.authQueue.length) { - logger(`Clearing ${this.authQueue.length} callbacks from the queue`); - this.authQueue.forEach(_cb => {return _cb(e, token);}); - } - callback(e, token); - }, 1500); - }); - }); - a.write(body); - a.end(); + return callback(null, token); }); }); + req.on('error', callback); req.write(body); req.end(); + + } + + + + getOauthToken(callback){ + if(this.refreshToken){ + logger('found refresh token, attempting to refresh'); + this.loginOauth((e, token) => { + if(e) { + logger("oAuth refresh failed, attempting login"); + return this.loginOauth(callback, "login"); + } + logger("successfully refreshed oAuth token"); + return callback(e, token); + }, "refresh"); + } + else{ + return this.loginOauth(callback, "login"); + } + } + + simpleRequest(url, method, data, callback) { + if (typeof data === 'function') { + callback = data; + data = null; + } + /*istanbul ignore next*/ + if (data && !data.api_version) { + data.api_version = this.api_version; + } + this.authenticate((e) => { + if (e && !this.retries) { + return callback(e); + } + this.fetch(method, url, { + api_version: this.api_version + }, data, (e, res, json) => { + /*istanbul ignore else - It's only for logging..*/ + if (json) { + logger('code', json.statusCode); + logger('headers', json.headers); + } + logger('error', e); + if (e && e.code === 401 && this.counter < this.retries) { + logger('auth failed, retrying', e); + this.counter += 1; + let self = this; + setTimeout(() => { + logger('auth failed, retry', { counter: self.counter }); + self.token = self.oauthToken = null; + self.authenticate(true, (e) => { + /*istanbul ignore next*/ + if (e) { + return callback(e); + } + self.simpleRequest(url, method, callback); + }); + }, 500); + return; + } + this.counter = 0; + callback(e, res, json); + }); + }); + } + + authenticate(retryP, callback) { + if (typeof retryP === 'function') { + callback = retryP; + retryP = false; + } + if(this.loadingCache){ + logger("Cache read in progress. Queuing auth"); + this.cacheQueue.push(() => { + this.authenticate(retryP, callback); + }); + return; + } + if (!retryP) { + if (this.oauthToken) { + logger('auth skipped, we have a token'); + return callback(); + } + if (this.authenticating) { + logger('authenticate in progress, queuing callback'); + this.authQueue.push(callback); + return; + } + this.authenticating = true; + } + let self = this; + this.getOauthToken((err, token) => { + if(err) return callback(err); + self.authenticating = false; + if (self.authQueue.length) { + logger(`Clearing ${self.authQueue.length} callbacks from the queue`); + self.authQueue.forEach(_cb => { + return _cb(err, token); + }); + self.authQueue = []; + } + return callback(null, token); + }) + } devices(callback) { @@ -293,14 +362,18 @@ class Doorbot { this.simpleRequest('/ring_devices', 'GET', callback); } - history(limit, callback) { + history(limit, older_than, callback) { + if (typeof older_than === 'function') { + callback = older_than; + older_than = null; + } if (typeof limit === 'function') { callback = limit; limit = 20; } validate_number(limit); validate_callback(callback); - const url = `/doorbots/history?limit=${limit}`; + const url = `/doorbots/history?limit=${limit}` + ((older_than) ? `&older_than=${older_than}` : ''); this.simpleRequest(url, 'GET', callback); } @@ -333,6 +406,21 @@ class Doorbot { this.simpleRequest(url, 'PUT', callback); } + subscribe(device, callback) { + validate_device(device); + validate_callback(callback); + var url = `/doorbots/${device.id}/subscribe`; + this.simpleRequest(url, 'POST',{}, callback); + } + + subscribe_motion(device, callback){ + validate_device(device); + validate_callback(callback); + var url = `/doorbots/${device.id}/motions_subscribe`; + this.simpleRequest(url, 'POST', {}, callback); + } + + recording(id, callback) { validate_callback(callback); this.simpleRequest(`/dings/${id}/recording`, 'GET', (e, json, res) => { @@ -375,6 +463,88 @@ class Doorbot { validate_callback(callback); this.simpleRequest(`/doorbots/${device.id}/health`, 'GET' , callback); } + + initAlarmConnection(device, callback){ + validate_device(device); + validate_callback(callback); + if(this.alarmSockets[device.location_id] === undefined) { + this.simpleRequest("https://app.ring.com/api/v1/rs/connections", "POST", { accountId: device.location_id }, (e, connection) => { + logger('Connecting to Websocket'); + this.alarmSockets[device.location_id] = io.connect("wss://" + connection.server + "/?authcode=" + connection.authCode, {} ); + this.alarmSockets[device.location_id].on('connect', callback); + this.alarmSockets[device.location_id].on('connect', () => { + this.registerAlarmCallback(device, 'message', (message) => { + logger("Generic Message Received"); + if(this.alarmCallbacks[message.msg] !== undefined) + this.alarmCallbacks[message.msg](message); + }); + }); + }); + } + } + + registerAlarmCallback(device, messageType, callback){ + validate_device(device); + validate_callback(callback); + this.alarmCallbacks[messageType] = callback; + if(this.alarmSockets[device.location_id] !== undefined) + return this.alarmSockets[device.location_id].on(messageType, callback); + else + this.initAlarmConnection(device, () => { + logger('Connected to websocket'); + this.registerAlarmCallback(device, messageType, callback); + }); + } + + sendAlarmMessage(device, messageType, messageBody){ + validate_device(device); + if(this.alarmSockets[device.location_id] !== undefined) + this.alarmSockets[device.location_id].emit(messageType, messageBody); + else + this.initAlarmConnection(device, () => { + logger('Connected to websocket'); + this.sendAlarmMessage(device, messageType, messageBody); + }); + } + + getAlarmDevices(alarmDevice, callback){ + validate_device(alarmDevice); + validate_callback(callback); + this.alarmCallbacks.DeviceInfoDocGetList = callback; + this.sendAlarmMessage(alarmDevice, 'message', { msg: "DeviceInfoDocGetList", seq: 1 }); + } + + setAlarmMode(alarmDevice, alarmPanelId, alarmMode, bypassedSensors, callback){ + this.alarmCallbacks.DeviceInfoSet = callback; + this.sendAlarmMessage(alarmDevice, 'message', { + msg: "DeviceInfoSet", + seq: 2, + datatype: "DeviceInfoSetType", + body: [ + { + zid: alarmPanelId, + command: { + v1: [ + { + commandType: 'security-panel.switch-mode', + data: { + mode: alarmMode, + bypass: bypassedSensors + } + } + ] + } + } + ] + }); + } + + closeAlarmConnection(device){ + this.alarmSockets[device.location_id].emit('terminate', {}); + this.alarmSockets[device.location_id].disconnect(true); + this.alarmSockets[device.location_id].close(); + } + } module.exports = function(options) { diff --git a/examples/download-all.js b/examples/download-all.js new file mode 100644 index 0000000..0e46da5 --- /dev/null +++ b/examples/download-all.js @@ -0,0 +1,119 @@ +#!/usr/bin/env node + +/* + * + * To use this: npm install async mkdirp request dateformat doorbot + * + * To run this: node download-all.js + * To run this: node download-all.js + * + */ + +//Includes +const dateFormat = require('dateformat'); +const RingAPI = require('doorbot.js'); +const async = require('async'); +const mkdirp = require('mkdirp'); +const fs = require('fs'); +const path = require('path'); +const url = require('url'); +const request = require('request'); + +const ring = RingAPI({ + email: 'EMAILADDRESS', + password: 'PASSWORD' +}); + +/* + * Script Settings + * + * loopForOlder - If true, once the 100 max items are returned from the API, we get the next 100, repeating until the API returns no more items + * + * skipExistingFiles - If true, we don't download files that we already have a local copy of (based on Device ID, Video ID and CreatedAt date) - if false, we re-download and overwrite any existing files on disk. + * + */ + + +var loopForOlder = true; +var skipExistingFiles = true; + + +//Parse 1st command line argument to take in the ID of what we want this to be older than, otherwise start with most recent +var olderthan = process.argv[2]; + +//Variables for tracking what the oldest file in a run is, as well as the previous oldest-file we started at, to determine when we are no longer receiving additional older files anymore +var oldestFile = '999999999999999999999999'; //Expected max file ID +var lastOldest = olderthan; + +const base = path.join(__dirname, 'downloads'); + +fs.mkdir(base, () => { //ignoring if it exists.. + const doAgain = (goBack) => { + //Implements the get-next-100-oldest feature + if (goBack !== null) { + olderthan = goBack.replace("_stamp", ""); + console.log('Getting more, older than: ' + olderthan); + } + + //First value is HistoryLimit, max return is 100 so I hardcoded 1000 to make sure this number is bigger than what the API returns + ring.history(1000, olderthan, (e, history) => { + const fetch = (info, callback) => { + ring.recording(info.id, (e, recording) => { + if(recording == undefined) + return callback(); + //Calculate the filename we want this to be saved as + const datea = dateFormat(info['created_at'],"yyyymmdd_HHMMssZ"); + const partFilePath = url.parse(recording).pathname.substring(0,url.parse(recording).pathname.length - 4); + const parts = partFilePath.split('/'); + const filePath = '/' + parts[1] + '/' + datea + '_' + parts[2] + '.mp4'; + const file = path.join(base, '.', filePath); + + //Is the file we just processed an older File ID than the previous file ID? + if (parts[2] < oldestFile) { + oldestFile = parts[2]; + } + + //Make sure the directory exists + const dirname = path.dirname(file); + mkdirp(dirname, () => { + //Tracking variable + var writeFile = true; + + //Test if the file we are about to write already exists + try { + fs.accessSync(file); + console.log('File Exists, Skipping: ', file); + writeFile = false; + } catch (err) { + writeFile = true; + } + + //If we aren't skipping existing files, we write them regardless of the write-file value + if (skipExistingFiles && !writeFile) { + return callback(); + } + + console.log('Fetching file', file); + const writer = fs.createWriteStream(file); + writer.on('close', () => { + console.log('Done writing', file); + callback(); + }); + request(recording).pipe(writer); + }); + }); + }; + + async.eachLimit(history, 10, fetch, () => { + console.log('Done, Oldest File: ' + oldestFile); + + //If we started at the most recent video and don't have an existing oldest, or if we found a new, older Video ID, we start the look again from there - assuming loopForOlder is true + if ((lastOldest === null || lastOldest !== oldestFile) && loopForOlder) { + lastOldest = oldestFile; + doAgain(lastOldest); //If we could a new oldest file, start again from there + } + }); + }); + }; + doAgain(null); //Initially start it +}); diff --git a/package.json b/package.json index c03b812..90cefa7 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "nyc": "^10.0.0" }, "dependencies": { - "debug": "^2.6.8" + "debug": "^2.6.8", + "socket.io": "^2.1.1" } }