diff --git a/.gitignore b/.gitignore index 52019cc..a72b52e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,3 @@ results npm-debug.log node_modules - -test2.js diff --git a/ApiError.js b/ApiError.js new file mode 100644 index 0000000..a007d92 --- /dev/null +++ b/ApiError.js @@ -0,0 +1,36 @@ +function ApiError(code, body) { + this.code = code; + this.errors = []; + + if (body.errors && body.errors.length > 0) { + this.errors = body.errors; + } + else this.errors.push(body); + return (this); +} + +ApiError.prototype = Object.create(Error.prototype); +ApiError.prototype.constructor = ApiError; + +ApiError.prototype.toString = function() { + var str = "CODE: " + this.code; + + for (var error of this.errors) { + str += "\n"; + + if (error.error && error.error_description) { + str += error.error + ": " + error.error_description; + } else if (error.error_code && error.error_message) { + str += error.error_code + ": " + error.error_message; + } else if (typeof error == 'string') { + str += error; + } else { + var key = Object.keys(error)[0]; + str += key + ": " + error[key]; + } + + } + return (str); +} + +module.exports = ApiError; diff --git a/FlowerPowerCloud.js b/FlowerPowerCloud.js new file mode 100644 index 0000000..77840c1 --- /dev/null +++ b/FlowerPowerCloud.js @@ -0,0 +1,228 @@ +var ApiError = require('./ApiError'); +var async = require('async'); +var request = require('request'); +var qs = require('querystring'); +var schedule = require('node-schedule'); + +const DEBUG = false; + +function FlowerPowerCloud() { + this._token = {}; + this._isLogged = false; + this.credentials = {}; + this.autoRefresh = false; + + var self = this; + var api = { + // Profile + 'getProfile': {method: 'GET/json', path: '/user/v4/profile', auth: true}, + 'getUserVersions': {method: 'GET/json', path: '/user/v1/versions', auth: true}, + 'verify': {method: 'GET/json', path: '/user/v1/verify', auth: true}, + + // Garden + 'getSyncGarden': {method: 'GET/json', path: '/sensor_data/v4/garden_locations_status', auth: true}, + 'getConfiguration': {method: 'GET/json', path: '/garden/v2/configuration', auth: true}, + 'getGarden': {method: 'GET/json', path: '/garden/v1/status', auth: true}, + 'sendSamples': {method: 'PUT/json', path: '/sensor_data/v8/sample', auth: true}, + 'getSyncData': {method: 'GET/json', path: '/sensor_data/v3/sync', auth: true}, + 'getFirmwareUpdate': {method: 'GET/json', path: '/sensor_data/v1/firmware_update', auth: true}, + 'getLocationSamples': {method: 'GET/json', path: '/sensor_data/v2/sample/location/:location_identifier', auth: true}, + 'getStatistics': {method: 'GET/json', path: '/sensor_data/v1/statistics/:location_identifier', auth: true}, + + // Images + 'getImageLocation': {method: 'GET/json', path: '/image/v3/location/user_images/:location_identifier', auth: true}, + }; + + for (var item in api) { + self.makeReqFunction(item, api[item]); + } + return this; +}; + +FlowerPowerCloud.url = 'https://api-flower-power-pot.parrot.com'; + +FlowerPowerCloud.prototype.makeReqFunction = function(name, req) { + var self = this; + + FlowerPowerCloud.prototype[name] = function(data, callback) { + self.invoke(req, data, callback); + }; +}; + +FlowerPowerCloud.prototype.makeHeader = function(req, data) { + var options = {headers: {}}; + var verb = req.method.split('/')[0]; + var type = req.method.split('/')[1]; + + switch (type) { + case 'urlencoded': + options.body = qs.stringify(data); + options.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + break; + case 'json': + options.body = JSON.stringify(data); + options.headers['Content-Type'] = 'application/json'; + break; + default: + options.body = data; + options.headers['Content-Type'] = 'text/plain'; + break; + } + + options.url = FlowerPowerCloud.url + req.path; + options.method = verb; + options.headers['Authorization'] = (req.auth) ? "Bearer " + this._token.access_token : ""; + + return options; +}; + +FlowerPowerCloud.prototype.makeUrl = function(req, data) { + var self = this; + + if (data) { + for (var item in data.url) { + req.path = req.path.replace(':' + item, data.url[item]); + } + delete data.url; + } + if (DEBUG) self.loggerReq(req, data); + return req; +}; + +FlowerPowerCloud.prototype.loggerReq = function(req, data) { + console.log(req.method, req.path); + for (var key in data) { + console.log(key + ":", data[key]); + } +}; + +FlowerPowerCloud.prototype.invoke = function(req, data, callback) { + var options = {}; + var self = this; + + if (typeof data == 'function') { + callback = data; + data = null; + } + if (data && typeof data !== 'object') { + return callback(new Error('Data is not a json')); + } + req = self.makeUrl(req, data); + options = self.makeHeader(req, data); + + if (DEBUG) console.log(options); + request(options, function(err, res, body) { + if (typeof body == 'string') { + try { + body = JSON.parse(body); + } catch (e) {}; + } + if (err) callback(err, null); + else if (res.statusCode != 200 || (body.errors && body.errors.length > 0)) { + return callback(new ApiError(res.statusCode, body), null); + } + else if (callback) { + var results = body; + + return callback(null, results); + } + else throw "Give me a callback"; + }); +}; + +FlowerPowerCloud.prototype.login = function(data, callback) { + var req = {method: 'POST/urlencoded', path: '/user/v2/authenticate'}; + var self = this; + + if (typeof data['auto-refresh'] != 'undefined') { + self.autoRefresh = data['auto-refresh']; + delete data['auto-refresh']; + } + self.credentials = data; + data['grant_type'] = 'password'; + self.invoke(req, data, function(err, res) { + if (err) callback(err); + else self.setToken(res, callback); + }); +}; + +FlowerPowerCloud.prototype.setToken = function(token, callback) { + var self = this; + + self._token = token; + self._isLogged = true; + if (self.autoRefresh) { + var job = new schedule.Job(function() { + self.refresh(token); + }); + job.schedule(new Date(Date.now() + (token['expires_in'] - 1440) * 1000)); + } + if (typeof callback == 'function') callback(null, token); +} + +FlowerPowerCloud.prototype.refresh = function(token) { + var req = {method: 'POST/urlencoded', path: '/user/v2/authenticate'}; + var self = this; + + var data = { + 'client_id': self.credentials['client_id'], + 'client_secret': self.credentials['client_secret'], + 'refresh_token': token.refresh_token, + 'grant_type': 'refresh_token' + }; + + self.invoke(req, data, function(err, res) { + if (err) callback(err); + else self.setToken(res); + }); +}; + +// FlowerPowerCloud.prototype.getGarden = function(callback) { +// var self = this; +// +// async.parallel({ +// syncGarden: self.getSyncGarden, +// syncData: self.getSyncData, +// }, function(err, res) { +// if (err) callback(err); +// else callback(null, self.concatJson(res.syncData, res.syncGarden)); +// }); +// }; + +FlowerPowerCloud.prototype.concatJson = function(json1, json2) { + var dest = json1; + var self = this; + + for (var key in json2) { + if (typeof json1[key] == 'object' && typeof json2[key] == 'object') { + dest[key] = self.concatJson(json1[key], json2[key]); + } + else { + dest[key] = json2[key]; + } + } + return dest; +} + +// 'url1': { +// 'getProfile', +// 'getUserVersions', +// 'getSyncGarden', +// 'sendSamples', +// 'getSyncData', +// 'getFirmwareUpdate', +// 'getLocationSamples', +// 'getStatistics', +// }, +// 'url2': { +// 'searchName', +// 'suggest', +// 'suggestLocation' +// }, +// 'url3': { +// 'infoPlant', +// 'plantdataVersion', +// 'listPopularityPlant', +// } + +module.exports = FlowerPowerCloud; diff --git a/README.md b/README.md index 7955546..a102187 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,109 @@ -node-flower-power-cloud -======================= +# flower-power-api + +[![NPM](https://nodei.co/npm/flower-power-api.png)](https://nodei.co/npm/flower-power-api/) A node.js module to interface with the [cloud service](https://github.com/parrot-flower-power/parrot-flower-power-api-example) for the Parrot [Flower Power](http://www.parrot.com/flowerpower/). -Before Starting ---------------- -You will need OAuth tokens and a Flower Power account: - -- To get the OAuth tokens, use this [form](https://apiflowerpower.parrot.com/api_access/signup) - -- To get a Flower power account, -launch the [iOS](https://itunes.apple.com/us/app/apple-store/id712479884), and follow the directions to create an account. -(Apparently there isn't an Android app yet). - +## Get your access API +* `username` `password` + * Make sure you have an account created by your smartphone. You should be see your garden: [myflowerpower.parrot.com](https://myflowerpower.parrot.com). +* `client_id` `client_secret` + * [Sign up to API here](https://apiflowerpower.parrot.com/api_access/signup), and got by **email** your *Access ID* (`client_id`) and your *Access secret* (`client_secret`). -Install -------- +## API - npm install flower-power-cloud - -API ---- +### Install +```bash +$ npm install flower-power-api +``` ### Load - - var CloudAPI = require('flower-power-cloud'); +```js +var FlowerPowerApi = require('flower-power-api'); +var api = new FlowerPowerApi(); +``` ### Login to cloud - - var clientID = '...' - , clientSecret = '...' - , userName = '...' - , passPhrase = '...' - , api - ; - - api = new CloudAPI.CloudAPI({ clientID : clientID - , clientSecret : clientSecret }).login(userName, passPhrase, function(err) { - if (!!err) return console.log('login error: ' + err.message); - - // otherwise, good to go! - }).on('error', function(err) { - console.log('background error: ' + err.message); - }); - -### Get garden information - - flower-power-cloud.getGarden(function(err, plants, sensors) { - if (!!err) return console.log('getGarden: ' + err.message); - - // inspect plants{} and sensors{} - } - -Finally -------- - +```js +var credential = { + 'username' : "...", + 'password' : "...", + 'client_id' : "...", + 'client_secret' : "...", + 'auto-refresh' : false +}; + +api.login(credential, function(err, res) { + if (err) console.log(err); + else { + // Head in the clouds :) + } +}); +``` + +### Get garden configuration (and specials methods) +```js +api.getGarden(function(error, garden)); +``` + +### Communicate with Cloud +Every method have the sema pattern: +```js +// For example: +'methodName': {method: 'GET/json', path: '/im/a/flower/', auth: true} +// Call like this: +api.methodName([data,] callback); + +// 'data' is optional, 'callback' is required +data = { + url: {} + param1, + param2, + ... +} +callback = function(error, results); +``` +```js +// Find all methods in ./FlowerPowerCloud.js +var api = { + // Profile + 'login': {method: 'POST/urlencoded', path: '/user/v2/authenticate', auth: false}, + 'refresh': {method: 'POST/urlencoded', path: '/user/v2/authenticate', auth: false}, + 'getProfile': {method: 'GET/json', path: '/user/v4/profile', auth: true}, + 'getUserVersions': {method: 'GET/json', path: '/user/v1/versions', auth: true}, + + // Garden + 'getSyncGarden': {method: 'GET/json', path: '/sensor_data/v4/garden_locations_status', auth: true}, + 'sendSamples': {method: 'PUT/json', path: '/sensor_data/v5/sample', auth: true}, + 'getSyncData': {method: 'GET/json', path: '/sensor_data/v3/sync', auth: true}, + 'getFirmwareUpdate': {method: 'GET/json', path: '/sensor_data/v1/firmware_update', auth: true}, + 'getLocationSamples': {method: 'GET/json', path: '/sensor_data/v2/sample/location/:location_identifier', auth: true}, + 'getStatistics': {method: 'GET/json', path: '/sensor_data/v1/statistics/:location_identifier', auth: true}, + + // Images + 'getImageLocation': {method: 'GET/json', path: '/image/v3/location/user_images/:location_identifier', auth: true}, +}; + +``` +#### Param in url +```js +// Api which need parameters into url +'anExample': {method: 'GET/json', path: '/:this/is/an/:example'} + +api.anExample({ + url: { + this: 'flower', + example: 'organ' + }, + param1: '...', + param2: '...' +}, callback); + +// Become +'/flower/is/an/organ' +``` + +## Finally Enjoy! diff --git a/flower-power-cloud.js b/flower-power-cloud.js deleted file mode 100644 index 381011c..0000000 --- a/flower-power-cloud.js +++ /dev/null @@ -1,252 +0,0 @@ -// flower-power-cloud.js -// cf., https://github.com/parrot-flower-power/parrot-flower-power-api-example - - -var https = require('https') - , events = require('events') - , querystring = require('querystring') - , url = require('url') - , util = require('util') - ; - - -var DEFAULT_LOGGER = { error : function(msg, props) { console.log(msg); if (!!props) console.log(props); } - , warning : function(msg, props) { console.log(msg); if (!!props) console.log(props); } - , notice : function(msg, props) { console.log(msg); if (!!props) console.log(props); } - , info : function(msg, props) { console.log(msg); if (!!props) console.log(props); } - , debug : function(msg, props) { console.log(msg); if (!!props) console.log(props); } - }; - - -var CloudAPI = function(options) { - var k; - - var self = this; - - if (!(self instanceof CloudAPI)) return new CloudAPI(options); - - self.options = options; - - self.logger = self.options.logger || {}; - for (k in DEFAULT_LOGGER) { - if ((DEFAULT_LOGGER.hasOwnProperty(k)) && (typeof self.logger[k] === 'undefined')) self.logger[k] = DEFAULT_LOGGER[k]; - } - - self.oauth = {}; -}; -util.inherits(CloudAPI, events.EventEmitter); - - -CloudAPI.prototype.login = function(username, passphrase, callback) { - var json; - - var self = this; - - if (typeof callback !== 'function') throw new Error('callback is mandatory for login'); - - json = { username : username - , client_secret : self.options.clientSecret - , password : passphrase - , client_id : self.options.clientID - , grant_type : 'password' - }; - self.invoke('POST', '/user/v1/authenticate', json, function(err, code, results) { - if (!!err) callback(err); - - if (code !== 200) return callback(new Error('invalid credentials: code=' + code + ' results=' + JSON.stringify(results))); - - self.oauth = results; - -/* - if (!!self.timer) { - clearTimeout(self.timer); - delete(self.timer); - } - if (!!results.expires_in) self.timer = setTimeout(function() { self._refresh(self); }, (results.expires_in - 120) * 1000); - */ - - callback(null); - }); - - return self; -}; - -CloudAPI.prototype._refresh = function(self, callback) { - var json; - - delete(self.timer); - - if (!callback) { - callback = function(err) { - if (!!err) return self.logger.error('refresh', { exception: err }); - - self.logger.info('refresh', { status: 'success' }); - }; - } - - json = { client_id : self.options.clientID - , client_secret : self.options.clientSecret - , refresh_token : self.oauth.refresh_token - , grant_type : 'refresh_token' - }; - self.invoke('POST', '/user/v1/authenticate', json, function(err, code, results) { - if (!!err) callback(err); - - if (code !== 200) return callback(new Error('invalid credentials: code=' + code + 'results=' + JSON.stringify(results))); - - self.oauth = results; - - if (!!results.expires_in) self.timer = setTimeout(function() { self._refresh(self); }, (results.expires_in - 120) * 1000); - - callback(null); - }); - - return self; -}; - - -CloudAPI.prototype.getGarden = function(callback) { - var self = this; - - return self.invoke('GET', '/sensor_data/v2/sync?include_s3_urls=1', function(err, code, results) { - var count, i, location, locations, sensor, sensors; - - if (!!err) return callback(err); - - var f = function(id) { - return function(err, results) { - if (!!err) self.logger.error('invoke', { event: 'sync', diagnostic: err.message }); - else locations[id].samples = results.samples; - - if (--count === 0) callback(null, locations, sensors); - }; - }; - - count = 0; - - - sensors = {}; - for (i = 0; i < results.sensors.length; i++) { - sensor = results.sensors[i]; - sensors[sensor.sensor_serial] = sensor; - } - - locations = {}; - for (i = 0; i < results.locations.length; i++) { - location = results.locations[i]; - locations[location.location_identifier] = location; - - count++; - self.roundtrip('GET', '/sensor_data/v2/sample/location/' + location.location_identifier - + '?from_datetime_utc=' + location.last_sample_utc, f(location.location_identifier)); - } - - count++; - self.roundtrip('GET', '/sensor_data/v1/garden_locations_status', function(err, results) { - var i, id; - - if (!!err) self.logger.error('invoke', { event: 'garden_locations_status', diagnostic: err.message }); - else { - for (i = 0; i < results.locations.length; i++) { - location = results.locations[i]; - id = location.location_identifier; - delete(location.location_identifier); - - locations[id].status = location; - } - } - - if (--count === 0) callback(null, locations, sensors); - }); - }); -}; - - -CloudAPI.prototype.roundtrip = function(method, path, json, callback) { - var self = this; - - if ((!callback) && (typeof json === 'function')) { - callback = json; - json = null; - } - - return self.invoke(method, path, json, function(err, code, results) { - var errors; - - if (!!err) return callback(err); - - errors = (!!results.errors) && util.isArray(results.errors) && (results.errors.length > 0) && results.errors; - if (!!errors) { - return callback(new Error('invalid response: ' + JSON.stringify(!!errors ? errors : results))); - } - - callback(null, results); - }); -}; - -CloudAPI.prototype.invoke = function(method, path, json, callback) { - var options; - - var self = this; - - if ((!callback) && (typeof json === 'function')) { - callback = json; - json = null; - } - if (!callback) { - callback = function(err, results) { - if (!!err) self.logger.error('invoke', { exception: err }); else self.logger.info(path, { results: results }); - }; - } - - options = url.parse('https://apiflowerpower.parrot.com' + path); - options.agent = false; - options.method = method; - options.rejectUnauthorized = false; // self-signed certificate? - options.headers = {}; - if ((!!self.oauth.access_token) && ((!json) || (!json.grant_type))) { - options.headers.Authorization = 'Bearer ' + self.oauth.access_token; - } - if (!!json) { - options.headers['Content-Type'] = 'application/x-www-form-urlencoded'; - json = querystring.stringify(json); - options.headers['Content-Length'] = Buffer.byteLength(json); - } - - https.request(options, function(response) { - var body = ''; - - response.on('data', function(chunk) { - body += chunk.toString(); - }).on('end', function() { - var expected = { GET : [ 200 ] - , PUT : [ 200 ] - , POST : [ 200, 201, 202 ] - , DELETE : [ 200 ] - }[method]; - - var results = {}; - - try { results = JSON.parse(body); } catch(ex) { - self.logger.error(path, { event: 'json', diagnostic: ex.message, body: body }); - return callback(ex, response.statusCode); - } - - if (expected.indexOf(response.statusCode) === -1) { - self.logger.error(path, { event: 'https', code: response.statusCode, body: body }); - return callback(new Error('HTTP response ' + response.statusCode), response.statusCode, results); - } - - callback(null, response.statusCode, results); - }).on('close', function() { - callback(new Error('premature end-of-file')); - }).setEncoding('utf8'); - }).on('error', function(err) { - callback(err); - }).end(json); - - return self; -}; - - -exports.CloudAPI = CloudAPI; diff --git a/package.json b/package.json index 2e118ad..4c8b561 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,31 @@ { - "name" : "flower-power-cloud" -, "version" : "0.2.1" -, "description" : "A node.js module to interface with the cloud service for the Parrot Flower Power" -, "author" : { - "name" : "Marshall Rose" - , "email" : "mrose17@gmail.com" - } -, "repository" : { - "type" : "git" - , "url" : "https://github.com/TheThingSystem/node-flower-power-cloud.git" - } -, "bugs" : { - "url" : "https://github.com/TheThingSystem/node-flower-power-cloud/issues" - } -, "keywords" : [ "Parrot", "Flower Power" ] -, "main" : "./flower-power-cloud.js" -, "engines" : { - "node" : ">=0.8" - } -, "readmeFilename" : "README.md" + "name": "flower-power-api", + "version": "2.0.1", + "description": "A node.js module which use the Parrot Flower Power API", + "author": "Bruno Sautron ", + "repository": { + "type": "git", + "url": "git+https://github.com/Parrot-Developers/node-flower-power-cloud.git" + }, + "bugs": { + "url": "https://github.com/Parrot-Developers/node-flower-power-cloud/issues" + }, + "keywords": [ + "Parrot", + "Flower Power" + ], + "main": "./FlowerPowerCloud.js", + "engines": { + "node": ">=0.8" + }, + "dependencies": { + "async": "^1.5.0", + "node-schedule": "^0.6.0", + "request": "^2.67.0" + }, + "homepage": "https://github.com/Parrot-Developers/node-flower-power-cloud#readme", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "license": "ISC" } diff --git a/test.js b/test.js index 3c9a4a2..f71178f 100644 --- a/test.js +++ b/test.js @@ -1,21 +1,21 @@ -var CloudAPI = require('./flower-power-cloud'); +var FlowerPowerCloud = require('./FlowerPowerCloud'); +var async = require('async'); -var clientID = '...' - , clientSecret = '...' - , userName = '...' - , passPhrase = '...' - , api - ; +var api = new FlowerPowerCloud(); -api = new CloudAPI.CloudAPI({ clientID: clientID, clientSecret: clientSecret }).login(userName, passPhrase, function(err) { - if (!!err) return console.log('login error: ' + err.message); +var credentials = { + 'username' : "...", + 'password' : "...", + 'client_id' : "...", + 'client_secret' : "...", + 'auto-refresh' : true +}; - api.getGarden(function(err, plants, sensors) { - if (!!err) return console.log('getGarden: ' + err.message); - - console.log('plants:'); console.log(plants); - console.log('sensors:'); console.log(sensors); - }); -}).on('error', function(err) { - console.log('background error: ' + err.message); +api.login(credentials, function(err, res) { + if (err) console.log(err.toString()); + else { + api.getGarden(function(err, res) { + console.log(res); + }); + } });