├── .gitignore ├── LICENSE ├── README.md ├── images └── red-subscribe-publish.gif ├── nodes ├── api │ ├── api.html │ ├── api.js │ └── icons │ │ ├── particle.png │ │ └── particle@2x.png ├── config │ ├── config.html │ ├── config.js │ └── helpers.js ├── function │ ├── function.html │ ├── function.js │ └── icons │ │ ├── particle.png │ │ └── particle@2x.png ├── publish │ ├── icons │ │ ├── particle.png │ │ └── particle@2x.png │ ├── publish.html │ └── publish.js ├── subscribe │ ├── icons │ │ ├── particle.png │ │ └── particle@2x.png │ ├── subscribe.html │ └── subscribe.js └── variable │ ├── icons │ ├── particle.png │ └── particle@2x.png │ ├── variable.html │ └── variable.js ├── package-lock.json ├── package.json └── src ├── api.js ├── helpers.js └── particle-base-node.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Particle Industries 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node-RED Particle nodes 2 | 3 | ![](images/red-subscribe-publish.gif) 4 | 5 | ## Installation 6 | 7 | ```bash 8 | $ cd ~/.node-red 9 | $ npm install @particle/node-red-contrib-particle-official 10 | ``` 11 | 12 | ## Version History 13 | 14 | #### 0.1.7 (2021-05-13) 15 | 16 | - Upgrade lodash from 4.17.19 to 4.17.21 for security vulnerability fix 17 | 18 | #### 0.1.6 (2020-11-30) 19 | 20 | - Fixed a bug where a subscribe node would keep using the same value it received on the first event, even if the value changed. 21 | "lodash": "^4.17.19", 22 | "lodash": "^4.17.21", -------------------------------------------------------------------------------- /images/red-subscribe-publish.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/particle-iot/node-red-contrib-particle-official/62bd7258e41946e979722db8907deb7ca148dd23/images/red-subscribe-publish.gif -------------------------------------------------------------------------------- /nodes/api/api.html: -------------------------------------------------------------------------------- 1 | 2 | 312 | 313 | 314 | -------------------------------------------------------------------------------- /nodes/api/api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ParticleBaseNode = require('../../src/particle-base-node'); 4 | 5 | module.exports = (RED) => { 6 | function ParticleAPINode(node) { 7 | RED.nodes.createNode(this, node); 8 | 9 | new ParticleBaseNode({ 10 | self: this, 11 | node: node, 12 | RED: RED, 13 | properties: ['name', 'method', 'url', 'payload'], 14 | inputProperties: ['method', 'url', 'payload'], 15 | functionName: 'request', 16 | functionArguments: [ 17 | { key: 'uri', value: 'url', prefix: 'https://api.particle.io/', customValue: ({ url }) => { 18 | if (url[0] === '/') { 19 | url = url.substring(1); 20 | } 21 | 22 | return url; 23 | } }, 24 | { key: 'method', value: 'method' }, 25 | { key: 'data', value: 'payload', format: 'object' } 26 | ], 27 | mandatoryArguments: ['uri', 'method'], 28 | success: { 29 | fields: [ 30 | { key: 'payload', path: 'body' }, 31 | { key: 'statusCode', path: 'statusCode' } 32 | ], 33 | status: 'got response' 34 | }, 35 | info: { 36 | status: 'requesting' 37 | }, 38 | error: { 39 | status: 'failed', 40 | onRuntime: 'Failed to call endpoint', 41 | onConfig: 'Configuration error' 42 | } 43 | }); 44 | } 45 | 46 | RED.nodes.registerType('particle-api', ParticleAPINode); 47 | 48 | }; 49 | -------------------------------------------------------------------------------- /nodes/api/icons/particle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/particle-iot/node-red-contrib-particle-official/62bd7258e41946e979722db8907deb7ca148dd23/nodes/api/icons/particle.png -------------------------------------------------------------------------------- /nodes/api/icons/particle@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/particle-iot/node-red-contrib-particle-official/62bd7258e41946e979722db8907deb7ca148dd23/nodes/api/icons/particle@2x.png -------------------------------------------------------------------------------- /nodes/config/config.html: -------------------------------------------------------------------------------- 1 | 13 | 14 | 32 | -------------------------------------------------------------------------------- /nodes/config/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = (RED) => { 4 | function ParticleConfigNode(n) { 5 | RED.nodes.createNode(this, n); 6 | this.clientId = n.clientId; 7 | this.clientSecret = n.clientSecret; 8 | } 9 | 10 | RED.nodes.registerType('particle-config', ParticleConfigNode); 11 | 12 | }; 13 | -------------------------------------------------------------------------------- /nodes/config/helpers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | if (typeof window !== 'undefined' && !window.helpers) { 4 | 5 | const generateFieldScopeSelector = () => { 6 | return ` 7 |
8 | 9 | 13 | 17 | 18 |
19 | 23 | `; 24 | }; 25 | 26 | const generateFieldSelect = (field) => { 27 | let html = ` 28 |
29 | 30 | 31 | 44 |
45 | `; 46 | 47 | return html; 48 | }; 49 | 50 | const generateField = (field) => { 51 | let html = ''; 52 | 53 | if (field.type === 'scope-selector') { 54 | html = generateFieldScopeSelector(field); 55 | } else if (field.type === 'select') { 56 | html = generateFieldSelect(field); 57 | } else { 58 | html = ` 59 |
60 | 61 | 62 |
63 | `; 64 | } 65 | 66 | return html; 67 | }; 68 | 69 | const generateEditTemplate = ({ name, fields }) => { 70 | let html = ''; 71 | 72 | fields.forEach(field => { 73 | html += generateField(field); 74 | }); 75 | 76 | $(`script[data-template-name="${name}"]`).html(html); 77 | }; 78 | 79 | const generateDocumentation = ({ name, title, inputs, outputs, details, resources }) => { 80 | let html = ` 81 |

${title}

82 | `; 83 | 84 | if (inputs) { 85 | html += ` 86 |

Inputs

87 |
88 | `; 89 | 90 | inputs.forEach(input => { 91 | html += ` 92 |
${input.name} ${input.type}
93 |
${input.description}
94 | `; 95 | }); 96 | } 97 | 98 | html += ` 99 |
100 | `; 101 | 102 | if (outputs) { 103 | html += ` 104 |

Outputs

105 |
106 | `; 107 | 108 | outputs.forEach(output => { 109 | html += ` 110 |
${output.name} ${output.type}
111 |
${output.description}
112 | `; 113 | }); 114 | 115 | 116 | html += ` 117 |
118 | `; 119 | } 120 | 121 | html += ` 122 | 123 |

Details

124 |

${details}

125 |

Resources

126 | '; 140 | 141 | $(`script[data-help-name="${name}"]`).html(html); 142 | }; 143 | 144 | const generateClientConfig = (config) => { 145 | let nodeClient = Object.assign({}, config, { 146 | category: 'particle', 147 | color: '#C0DEED' 148 | }); 149 | 150 | if (config._scopeSelector === true) { 151 | nodeClient.onScopeChange = function onscopechange() { 152 | let stream = $('input[name=node-input-scope-options]:checked').val() || 'user'; 153 | 154 | if (stream === 'product') { 155 | $('#particle-scope-product').removeClass('hidden'); 156 | } else { 157 | $('#particle-scope-product').addClass('hidden'); 158 | $('#node-input-product').val(''); 159 | } 160 | 161 | $('#node-input-stream').val(stream); 162 | }; 163 | 164 | nodeClient.oneditprepare = function oneditprepare() { 165 | $('input[name=node-input-scope-options]') 166 | .prop('checked', null) 167 | .change(nodeClient.onScopeChange.bind(this)); 168 | 169 | const isProduct = $('#node-input-product').val(); 170 | 171 | if (isProduct) { 172 | $('#node-input-scope-product').prop('checked', true); 173 | } else { 174 | $('#node-input-scope-user').prop('checked', true); 175 | } 176 | 177 | nodeClient.onScopeChange(); 178 | }; 179 | 180 | nodeClient.oneditsave = function oneditsave() { 181 | nodeClient.onScopeChange(); 182 | }; 183 | } 184 | 185 | return nodeClient; 186 | }; 187 | 188 | const addCSS = () => { 189 | $('body').append(` 190 | 191 | 204 | `); 205 | }; 206 | 207 | const generateNodeFromDefinition = (name, definition) => { 208 | generateEditTemplate(definition.editTemplate); 209 | generateDocumentation(definition.documentation); 210 | addCSS(); 211 | 212 | RED.nodes.registerType(name, generateClientConfig(definition.client)); 213 | }; 214 | 215 | /*global window*/ 216 | window.helpers = { 217 | generateEditTemplate, 218 | generateDocumentation, 219 | generateClientConfig, 220 | generateNodeFromDefinition 221 | }; 222 | 223 | } -------------------------------------------------------------------------------- /nodes/function/function.html: -------------------------------------------------------------------------------- 1 | 2 | 308 | 309 | 310 | -------------------------------------------------------------------------------- /nodes/function/function.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ParticleBaseNode = require('../../src/particle-base-node'); 4 | 5 | module.exports = (RED) => { 6 | function ParticleFunctionNode(node) { 7 | RED.nodes.createNode(this, node); 8 | 9 | new ParticleBaseNode({ 10 | self: this, 11 | node: node, 12 | RED: RED, 13 | properties: ['name', 'scope', 'device', 'product', 'function', 'argument'], 14 | inputProperties: ['device', 'function', 'argument'], 15 | functionName: 'callFunction', 16 | functionArguments: [ 17 | { key: 'deviceId', value: 'device' }, 18 | { key: 'name', value: 'function' }, 19 | { key: 'argument', value: 'argument' }, 20 | { key: 'product', value: 'product' } 21 | ], 22 | mandatoryArguments: ['deviceId', 'name'], 23 | success: { 24 | fields: [ 25 | { key: 'payload' }, 26 | { key: 'function', value: 'function' }, 27 | { key: 'device', value: 'device' } 28 | ], 29 | status: 'called' 30 | }, 31 | info: { 32 | status: 'calling' 33 | }, 34 | error: { 35 | status: 'failed', 36 | onRuntime: 'Failed to call function' 37 | } 38 | }); 39 | } 40 | 41 | RED.nodes.registerType('particle-function', ParticleFunctionNode); 42 | 43 | }; 44 | -------------------------------------------------------------------------------- /nodes/function/icons/particle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/particle-iot/node-red-contrib-particle-official/62bd7258e41946e979722db8907deb7ca148dd23/nodes/function/icons/particle.png -------------------------------------------------------------------------------- /nodes/function/icons/particle@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/particle-iot/node-red-contrib-particle-official/62bd7258e41946e979722db8907deb7ca148dd23/nodes/function/icons/particle@2x.png -------------------------------------------------------------------------------- /nodes/publish/icons/particle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/particle-iot/node-red-contrib-particle-official/62bd7258e41946e979722db8907deb7ca148dd23/nodes/publish/icons/particle.png -------------------------------------------------------------------------------- /nodes/publish/icons/particle@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/particle-iot/node-red-contrib-particle-official/62bd7258e41946e979722db8907deb7ca148dd23/nodes/publish/icons/particle@2x.png -------------------------------------------------------------------------------- /nodes/publish/publish.html: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 312 | 313 | 314 | 315 | -------------------------------------------------------------------------------- /nodes/publish/publish.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ParticleBaseNode = require('../../src/particle-base-node'); 4 | 5 | module.exports = (RED) => { 6 | function ParticlePublishNode(node) { 7 | RED.nodes.createNode(this, node); 8 | 9 | new ParticleBaseNode({ 10 | self: this, 11 | node: node, 12 | RED: RED, 13 | properties: ['name', 'scope', 'product', 'event', 'payload'], 14 | inputProperties: ['event', 'payload'], 15 | functionName: 'publishEvent', 16 | functionArguments: [ 17 | { key: 'name', value: 'event' }, 18 | { key: 'product', value: 'product' }, 19 | { key: 'data', value: 'payload' }, 20 | { key: 'isPrivate', defaultValue: true } 21 | ], 22 | mandatoryArguments: ['name'], 23 | success: { 24 | status: 'published' 25 | }, 26 | info: { 27 | status: 'publishing' 28 | }, 29 | error: { 30 | status: 'failed', 31 | onRuntime: 'Failed to publish event' 32 | } 33 | }); 34 | } 35 | 36 | RED.nodes.registerType('particle-publish', ParticlePublishNode); 37 | 38 | }; 39 | -------------------------------------------------------------------------------- /nodes/subscribe/icons/particle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/particle-iot/node-red-contrib-particle-official/62bd7258e41946e979722db8907deb7ca148dd23/nodes/subscribe/icons/particle.png -------------------------------------------------------------------------------- /nodes/subscribe/icons/particle@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/particle-iot/node-red-contrib-particle-official/62bd7258e41946e979722db8907deb7ca148dd23/nodes/subscribe/icons/particle@2x.png -------------------------------------------------------------------------------- /nodes/subscribe/subscribe.html: -------------------------------------------------------------------------------- 1 | 2 | 311 | 312 | 313 | 314 | -------------------------------------------------------------------------------- /nodes/subscribe/subscribe.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const helpers = require('../../src/helpers'); 4 | const ParticleBaseNode = require('../../src/particle-base-node'); 5 | 6 | module.exports = (RED) => { 7 | function ParticleSubscribeNode(node) { 8 | RED.nodes.createNode(this, node); 9 | 10 | new ParticleBaseNode({ 11 | self: this, 12 | node: node, 13 | RED: RED, 14 | properties: ['name', 'scope', 'device', 'product', 'event'], 15 | functionName: 'listenToEventStream', 16 | functionArguments: [ 17 | { key: 'deviceId', customValue: ({ device, product }) => { 18 | if (product) { 19 | return device === '' ? undefined : device; 20 | } else { 21 | return device === '' ? 'mine' : device; 22 | } 23 | } }, 24 | { key: 'name', value: 'event' }, 25 | { key: 'product', value: 'product' }, 26 | { key: 'onEvent', defaultValue: (data) => { 27 | helpers.onSuccess({ status: this.status.bind(this), message: 'new event' }); 28 | 29 | this.send({ 30 | event: data.name, 31 | payload: data.data, 32 | published_at: data.published_at, 33 | device: data.coreid 34 | }); 35 | } } 36 | ], 37 | mandatoryArguments: [], 38 | runOnLoad: true, 39 | info: { 40 | status: 'setting up stream' 41 | }, 42 | error: { 43 | status: 'failed to set up stream', 44 | onRuntime: 'Failed to set up stream' 45 | } 46 | }); 47 | } 48 | 49 | RED.nodes.registerType('particle-subscribe', ParticleSubscribeNode); 50 | 51 | }; 52 | -------------------------------------------------------------------------------- /nodes/variable/icons/particle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/particle-iot/node-red-contrib-particle-official/62bd7258e41946e979722db8907deb7ca148dd23/nodes/variable/icons/particle.png -------------------------------------------------------------------------------- /nodes/variable/icons/particle@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/particle-iot/node-red-contrib-particle-official/62bd7258e41946e979722db8907deb7ca148dd23/nodes/variable/icons/particle@2x.png -------------------------------------------------------------------------------- /nodes/variable/variable.html: -------------------------------------------------------------------------------- 1 | 2 | 305 | 306 | 307 | 308 | -------------------------------------------------------------------------------- /nodes/variable/variable.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const ParticleBaseNode = require('../../src/particle-base-node'); 4 | 5 | module.exports = (RED) => { 6 | function ParticleVariableNode(node) { 7 | RED.nodes.createNode(this, node); 8 | 9 | new ParticleBaseNode({ 10 | self: this, 11 | node: node, 12 | RED: RED, 13 | properties: ['name', 'scope', 'device', 'product', 'variable'], 14 | inputProperties: ['device', 'variable'], 15 | functionName: 'getVariable', 16 | functionArguments: [ 17 | { key: 'deviceId', value: 'device' }, 18 | { key: 'name', value: 'variable' }, 19 | { key: 'product', value: 'product' } 20 | ], 21 | mandatoryArguments: ['deviceId', 'name'], 22 | success: { 23 | fields: [ 24 | { key: 'payload' }, 25 | { key: 'variable', value: 'variable' }, 26 | { key: 'device', value: 'device' } 27 | ], 28 | status: 'fetched' 29 | }, 30 | info: { 31 | status: 'fetching' 32 | }, 33 | error: { 34 | status: 'failed', 35 | onRuntime: 'Failed to fetch varaible' 36 | } 37 | }); 38 | } 39 | 40 | RED.nodes.registerType('particle-variable', ParticleVariableNode); 41 | 42 | 43 | }; 44 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@particle/node-red-contrib-particle-official", 3 | "version": "0.1.6", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "asynckit": { 8 | "version": "0.4.0", 9 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 10 | "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" 11 | }, 12 | "babel-runtime": { 13 | "version": "6.26.0", 14 | "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", 15 | "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", 16 | "requires": { 17 | "core-js": "^2.4.0", 18 | "regenerator-runtime": "^0.11.0" 19 | } 20 | }, 21 | "builtin-status-codes": { 22 | "version": "2.0.0", 23 | "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-2.0.0.tgz", 24 | "integrity": "sha1-byIAO6rPADzNKHr+aHIVH93FhXk=" 25 | }, 26 | "combined-stream": { 27 | "version": "1.0.8", 28 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 29 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 30 | "requires": { 31 | "delayed-stream": "~1.0.0" 32 | } 33 | }, 34 | "component-emitter": { 35 | "version": "1.3.0", 36 | "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", 37 | "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" 38 | }, 39 | "cookiejar": { 40 | "version": "2.1.2", 41 | "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", 42 | "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==" 43 | }, 44 | "core-js": { 45 | "version": "2.6.11", 46 | "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", 47 | "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==" 48 | }, 49 | "debug": { 50 | "version": "4.1.1", 51 | "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", 52 | "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", 53 | "requires": { 54 | "ms": "^2.1.1" 55 | } 56 | }, 57 | "delayed-stream": { 58 | "version": "1.0.0", 59 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 60 | "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" 61 | }, 62 | "fast-safe-stringify": { 63 | "version": "2.0.7", 64 | "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz", 65 | "integrity": "sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==" 66 | }, 67 | "form-data": { 68 | "version": "3.0.0", 69 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz", 70 | "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==", 71 | "requires": { 72 | "asynckit": "^0.4.0", 73 | "combined-stream": "^1.0.8", 74 | "mime-types": "^2.1.12" 75 | } 76 | }, 77 | "formidable": { 78 | "version": "1.2.2", 79 | "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", 80 | "integrity": "sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==" 81 | }, 82 | "inherits": { 83 | "version": "2.0.4", 84 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 85 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 86 | }, 87 | "lodash": { 88 | "version": "4.17.21", 89 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", 90 | "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 91 | }, 92 | "methods": { 93 | "version": "1.1.2", 94 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", 95 | "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" 96 | }, 97 | "mime": { 98 | "version": "2.4.6", 99 | "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.6.tgz", 100 | "integrity": "sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA==" 101 | }, 102 | "mime-db": { 103 | "version": "1.44.0", 104 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.44.0.tgz", 105 | "integrity": "sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==" 106 | }, 107 | "mime-types": { 108 | "version": "2.1.27", 109 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.27.tgz", 110 | "integrity": "sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w==", 111 | "requires": { 112 | "mime-db": "1.44.0" 113 | } 114 | }, 115 | "ms": { 116 | "version": "2.1.2", 117 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", 118 | "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" 119 | }, 120 | "particle-api-js": { 121 | "version": "9.0.2", 122 | "resolved": "https://registry.npmjs.org/particle-api-js/-/particle-api-js-9.0.2.tgz", 123 | "integrity": "sha512-w+KG6ybdwFKJCiTZ6sQMAFX5FJS16cA5JyG4o8th80ULneTI9WYfj9Twa0WHdC598inF+rYJRouqHS47/VWqOA==", 124 | "requires": { 125 | "babel-runtime": "^6.9.2", 126 | "form-data": ">2.2.0", 127 | "stream-http": "https://github.com/particle-iot/stream-http/archive/v2.2.1.tar.gz", 128 | "superagent": "^5.1.2", 129 | "superagent-prefix": "0.0.2" 130 | } 131 | }, 132 | "qs": { 133 | "version": "6.9.4", 134 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.4.tgz", 135 | "integrity": "sha512-A1kFqHekCTM7cz0udomYUoYNWjBebHm/5wzU/XqrBRBNWectVH0QIiN+NEcZ0Dte5hvzHwbr8+XQmguPhJ6WdQ==" 136 | }, 137 | "readable-stream": { 138 | "version": "3.6.0", 139 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", 140 | "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", 141 | "requires": { 142 | "inherits": "^2.0.3", 143 | "string_decoder": "^1.1.1", 144 | "util-deprecate": "^1.0.1" 145 | } 146 | }, 147 | "regenerator-runtime": { 148 | "version": "0.11.1", 149 | "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", 150 | "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" 151 | }, 152 | "safe-buffer": { 153 | "version": "5.2.1", 154 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 155 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" 156 | }, 157 | "semver": { 158 | "version": "7.3.2", 159 | "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", 160 | "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==" 161 | }, 162 | "stream-http": { 163 | "version": "https://github.com/particle-iot/stream-http/archive/v2.2.1.tar.gz", 164 | "integrity": "sha512-eiMmV7ssQY13N4JoAHkr9nWvq5TgbQp6VyeCA6/UGZjBSepFMhtfICRDNzkzPNY/NPJ/os0an1xExHISjfBkmg==", 165 | "requires": { 166 | "builtin-status-codes": "^2.0.0", 167 | "inherits": "^2.0.1", 168 | "to-arraybuffer": "^1.0.0", 169 | "xtend": "^4.0.0" 170 | } 171 | }, 172 | "string_decoder": { 173 | "version": "1.3.0", 174 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", 175 | "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", 176 | "requires": { 177 | "safe-buffer": "~5.2.0" 178 | } 179 | }, 180 | "superagent": { 181 | "version": "5.3.1", 182 | "resolved": "https://registry.npmjs.org/superagent/-/superagent-5.3.1.tgz", 183 | "integrity": "sha512-wjJ/MoTid2/RuGCOFtlacyGNxN9QLMgcpYLDQlWFIhhdJ93kNscFonGvrpAHSCVjRVj++DGCglocF7Aej1KHvQ==", 184 | "requires": { 185 | "component-emitter": "^1.3.0", 186 | "cookiejar": "^2.1.2", 187 | "debug": "^4.1.1", 188 | "fast-safe-stringify": "^2.0.7", 189 | "form-data": "^3.0.0", 190 | "formidable": "^1.2.2", 191 | "methods": "^1.1.2", 192 | "mime": "^2.4.6", 193 | "qs": "^6.9.4", 194 | "readable-stream": "^3.6.0", 195 | "semver": "^7.3.2" 196 | } 197 | }, 198 | "superagent-prefix": { 199 | "version": "0.0.2", 200 | "resolved": "https://registry.npmjs.org/superagent-prefix/-/superagent-prefix-0.0.2.tgz", 201 | "integrity": "sha1-sVu7E1P4ibANJa8QtPEbNQ0gOwY=" 202 | }, 203 | "to-arraybuffer": { 204 | "version": "1.0.1", 205 | "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", 206 | "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=" 207 | }, 208 | "util-deprecate": { 209 | "version": "1.0.2", 210 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 211 | "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" 212 | }, 213 | "xtend": { 214 | "version": "4.0.2", 215 | "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", 216 | "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@particle/node-red-contrib-particle-official", 3 | "version": "0.1.7", 4 | "description": "Official Node-RED Particle nodes", 5 | "scripts": {}, 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/particle-iot/node-red-contrib-particle-official.git" 9 | }, 10 | "keywords": [ 11 | "node-red" 12 | ], 13 | "node-red": { 14 | "nodes": { 15 | "config": "nodes/config/config.js", 16 | "publish": "nodes/publish/publish.js", 17 | "subscribe": "nodes/subscribe/subscribe.js", 18 | "variable": "nodes/variable/variable.js", 19 | "function": "nodes/function/function.js", 20 | "api": "nodes/api/api.js" 21 | } 22 | }, 23 | "author": "Wojtek 'suda' Siudzinski", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/particle-iot/node-red-contrib-particle-official/issues" 27 | }, 28 | "homepage": "https://github.com/particle-iot/node-red-contrib-particle-official#readme", 29 | "dependencies": { 30 | "lodash": "^4.17.21", 31 | "particle-api-js": "^9.0.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Particle = require('particle-api-js'); 4 | 5 | module.exports = class Api { 6 | /** 7 | * API wrapper constructor 8 | * 9 | * @param {ParticleConfigNode} auth 10 | * @param {Console} logger 11 | */ 12 | constructor(auth, logger=console) { 13 | this._auth = auth; 14 | this._logger = logger; 15 | this._defaultExpiresIn = 2147483647; 16 | this._init(); 17 | } 18 | 19 | /** 20 | * Login using the client credentials, store the access token 21 | * and set up token refresh before it expires 22 | * 23 | * @returns {Promise} Login promise 24 | */ 25 | login() { 26 | return this._particle.loginAsClientOwner({}).then((res) => { 27 | this._logger.log('Authenticated with Particle'); 28 | this._accessToken = res.body.access_token; 29 | // Reauthenticate before the token expires 30 | // There is res.body.expires_in property but 31 | // as setTimeout is using 32 bit int the maximum 32 | // we can set is 2147483647 (~24 days) 33 | const expiresIn = Math.min(this._defaultExpiresIn, res.body.expires_in); 34 | this._expirationTimer = setTimeout( 35 | this._reauthenticate.bind(this), 36 | expiresIn 37 | ); 38 | 39 | return this._accessToken; 40 | }); 41 | } 42 | 43 | /** 44 | * Get the EventStream and subscribe to the 'event' event 45 | * with onEvent callback 46 | * 47 | * @param {Function} onEvent 48 | * @param {String} options.deviceId 49 | * @param {String} options.name 50 | * @param {String} options.product 51 | * @returns {Promise} Resolves when adding listener succeeded 52 | */ 53 | listenToEventStream({ onEvent, deviceId, name, product }) { 54 | this._lastListenArguments = arguments; 55 | return this._particle.getEventStream({ 56 | deviceId, name, product, auth: this._accessToken 57 | }).then((stream) => { 58 | this._stream = stream; 59 | this._stream.on('event', onEvent); 60 | this._stream.on('error', this._reauthenticate.bind(this)); 61 | this._stream.on('end', this._reauthenticate.bind(this)); 62 | }); 63 | } 64 | 65 | /** 66 | * Publish a Particle event 67 | * 68 | * @param {Object} params Event params 69 | * @param {String} params.name Event name 70 | * @param {String} params.data Event data 71 | * @param {String} params.product Event for this product ID or slug 72 | * @param {Boolean} params.isPrivate Should the event be publicly available? 73 | * @returns {Promise} Resolves when event has been published 74 | */ 75 | publishEvent(params) { 76 | return this._particle.publishEvent(Object.assign({ 77 | auth: this._accessToken 78 | }, params)); 79 | } 80 | 81 | /** 82 | * Get the value of a device variable 83 | * @param {Object} params Options for this API call 84 | * @param {String} params.deviceId Device ID or Name 85 | * @param {String} params.name Variable name 86 | * @param {String} [params.product] Device in this product ID or slug 87 | * @param {String} params.auth Access Token 88 | * @return {Promise} Resolves when the variable was fetched 89 | */ 90 | getVariable(params) { 91 | return this._particle.getVariable(Object.assign({ 92 | auth: this._accessToken 93 | }, params)) 94 | .then(response => { 95 | return response.body.result; 96 | }, (error) => { 97 | throw error; 98 | }); 99 | } 100 | 101 | /** 102 | * Call a device function 103 | * @param {Object} options Options for this API call 104 | * @param {String} options.deviceId Device ID or Name 105 | * @param {String} options.name Function name 106 | * @param {String} options.argument Function argument 107 | * @param {String} [options.product] Device in this product ID or slug 108 | * @param {String} options.auth Access Token 109 | * @return {Promise} Resolves when the function was called 110 | */ 111 | callFunction(options) { 112 | return this._particle.callFunction(Object.assign({ 113 | auth: this._accessToken 114 | }, options)) 115 | .then(response => { 116 | return response.body.return_value; 117 | }, (error) => { 118 | throw error; 119 | }); 120 | } 121 | 122 | request(params) { 123 | return this._particle.request(Object.assign({ 124 | auth: this._accessToken 125 | }, params)); 126 | } 127 | 128 | /** 129 | * Close the stream, remove all timers, listeners etc. 130 | */ 131 | cleanup() { 132 | clearTimeout(this._expirationTimer); 133 | 134 | if (this._stream) { 135 | this._stream.abort(); 136 | } 137 | 138 | this._particle.deleteCurrentAccessToken({ 139 | auth: this._accessToken 140 | }); 141 | } 142 | 143 | /** 144 | * Instantiate Particle and set the config 145 | * 146 | * @private 147 | */ 148 | _init() { 149 | this._particle = new Particle({ 150 | clientId: this._auth.clientId, 151 | clientSecret: this._auth.clientSecret 152 | }); 153 | this._particle.setContext('tool', { 154 | name: 'node-red-contrib-particle' 155 | }); 156 | } 157 | 158 | /** 159 | * Callback that fetches a new token and recreates all 160 | * event callbacks to prevent data loss. 161 | * 162 | * @private 163 | */ 164 | _reauthenticate() { 165 | this._logger.log('Reauthenticating...'); 166 | this.cleanup(); 167 | 168 | this.login().then(() => { 169 | this.listenToEventStream.apply(this, this._lastListenArguments); 170 | }, () => { 171 | const retryIn = 5; 172 | this._logger.error(`Failed to reauthenticate. Trying again in ${retryIn} seconds`); 173 | setTimeout(this._reauthenticate.bind(this), retryIn * 1000); 174 | }); 175 | } 176 | }; 177 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const resetStatus = ({ status, timeout = 0 }) => { 4 | setTimeout(() => { 5 | status({}); 6 | }, timeout); 7 | }; 8 | 9 | const onSuccess = ({ status, message }) => { 10 | status({ fill: 'green', shape: 'dot', text: message }); 11 | 12 | resetStatus({ 13 | status, 14 | timeout: 1000 15 | }); 16 | }; 17 | 18 | const onInfo = ({ status, message }) => { 19 | status({ fill: 'blue', shape: 'dot', text: message }); 20 | }; 21 | 22 | const onError = ({ status, message }) => { 23 | status({ fill: 'red', shape: 'ring', text: message }); 24 | }; 25 | 26 | module.exports = { 27 | onSuccess, 28 | onInfo, 29 | onError, 30 | resetStatus 31 | }; 32 | -------------------------------------------------------------------------------- /src/particle-base-node.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Api = require('./api'); 4 | const _ = require('lodash'); 5 | const helpers = require('./helpers'); 6 | 7 | class ParticleBaseNode { 8 | constructor({ self, node, RED, properties, functionName, functionArguments, mandatoryArguments, onAPIResponse, onAPIError, inputProperties, runOnLoad, success, error, info }) { 9 | this.self = self; 10 | this.node = node; 11 | this.RED = RED; 12 | this.message = null; 13 | this.variables = {}; 14 | this.functionName = functionName; 15 | this.functionArguments = functionArguments; 16 | this.mandatoryArguments = mandatoryArguments; 17 | this.inputProperties = inputProperties; 18 | this.onAPIResponse = onAPIResponse; 19 | this.onAPIError = onAPIError; 20 | this.runOnLoad = runOnLoad || node.runOnLoad; 21 | this.success = success || node.success; 22 | this.error = error; 23 | this.info = info || {}; 24 | 25 | properties.forEach(field => { 26 | this.variables[field] = node[field]; 27 | }); 28 | 29 | this.auth = this.RED.nodes.getNode(node.auth); 30 | 31 | if (this.auth) { 32 | this.api = new Api(this.auth); 33 | } else if (node.testAuth) { 34 | this.api = new Api(node.testAuth); 35 | } 36 | 37 | this.run(); 38 | } 39 | 40 | hasMandatoryArguments(args = [], params = {}) { 41 | let ok = true; 42 | 43 | args.forEach(arg => { 44 | if (!params[arg]) { 45 | ok = false; 46 | 47 | this.self.error(`Missing the ${arg} parameter`); 48 | } 49 | }); 50 | 51 | return ok; 52 | } 53 | 54 | parametersFromVariables(variables, functionArguments) { 55 | let params = {}; 56 | let ok = true; 57 | 58 | functionArguments.forEach(argument => { 59 | let value = null; 60 | 61 | if (argument.defaultValue) { 62 | value = argument.defaultValue; 63 | } 64 | 65 | if (argument.value && variables[argument.value]) { 66 | value = variables[argument.value]; 67 | } 68 | 69 | if (argument.customValue) { 70 | value = argument.customValue(variables); 71 | } 72 | 73 | if (argument.format === 'object' && typeof value !== 'object') { 74 | try { 75 | value = JSON.parse(value); 76 | 77 | if (typeof value !== 'object') { 78 | throw 'Argument not a valid JSON object'; 79 | } 80 | } catch (e) { 81 | if (this.error.onConfig) { 82 | this.self.error(this.error.onConfig); 83 | } 84 | 85 | ok = false; 86 | } 87 | } 88 | 89 | if (argument.prefix && value) { 90 | value = `${argument.prefix}${value}`; 91 | } 92 | 93 | params[argument.key] = value; 94 | }); 95 | 96 | return ok ? params : null; 97 | } 98 | 99 | successStatus(message = 'OK') { 100 | return helpers.onSuccess({ status: this.self.status.bind(this.self), message }); 101 | } 102 | 103 | errorStatus(message = 'NOT OK') { 104 | return helpers.onError({ status: this.self.status.bind(this.self), message }); 105 | } 106 | 107 | infoStatus(message = 'processing') { 108 | return helpers.onInfo({ status: this.self.status.bind(this.self), message }); 109 | } 110 | 111 | hideStatus() { 112 | return helpers.resetStatus({ status: this.self.status.bind(this.self), timeout: 0 }); 113 | } 114 | 115 | makeAPIRequest(variables) { 116 | const parameters = this.parametersFromVariables(variables, this.functionArguments); 117 | 118 | if (!parameters) { 119 | return null; 120 | } 121 | 122 | const hasMandatoryArguments = this.hasMandatoryArguments(this.mandatoryArguments, parameters); 123 | 124 | if (!hasMandatoryArguments) { 125 | return null; 126 | } 127 | 128 | this.infoStatus(this.info.status); 129 | 130 | this.api[this.functionName](parameters) 131 | .then(result => { 132 | this.hideStatus(); 133 | 134 | if (this.success && this.success.status) { 135 | this.successStatus(this.success.status); 136 | } 137 | 138 | if (this.onAPIResponse) { 139 | return this.onAPIResponse(variables, this.message, result); 140 | } else if (this.success && this.success.fields) { 141 | const fields = this.success.fields; 142 | let message = this.message || {}; 143 | 144 | fields.forEach(field => { 145 | if (field.path) { 146 | message[field.key] = _.get(result, field.path); 147 | } else if (field.value) { 148 | message[field.key] = variables[field.value]; 149 | } else if (field.key === 'payload') { 150 | message.payload = result; 151 | } 152 | }); 153 | 154 | return this.self.send(message); 155 | } else { 156 | return true; 157 | } 158 | }, (error) => { 159 | this.hideStatus(); 160 | 161 | if (this.error && this.error.status) { 162 | this.errorStatus(this.error.status); 163 | } 164 | 165 | if (this.onAPIError) { 166 | return this.onAPIError(error); 167 | } 168 | 169 | if (this.error && this.error.onRuntime) { 170 | this.self.error(`${this.error.onRuntime} (${error})`, this.message || {}); 171 | } 172 | 173 | return false; 174 | }); 175 | } 176 | 177 | onClose() { 178 | this.api.cleanup(); 179 | } 180 | 181 | mergePropertiesFromMsg(variables, inputProperties, msg) { 182 | let _var = Object.assign({}, variables); 183 | const data = msg; 184 | 185 | if (data) { 186 | inputProperties.forEach(field => { 187 | if (data[field] && !_var[field]) { 188 | _var[field] = data[field]; 189 | } 190 | }); 191 | } 192 | 193 | return _var; 194 | } 195 | 196 | onInput(msg) { 197 | if (msg) { 198 | this.message = msg; 199 | } 200 | 201 | let variables = this.mergePropertiesFromMsg(this.variables, this.inputProperties, msg); 202 | 203 | this.makeAPIRequest(variables); 204 | } 205 | 206 | run() { 207 | if (!this.api) { 208 | return null; 209 | } 210 | 211 | this.api.login() 212 | .then(() => { 213 | this.self.on('input', this.onInput.bind(this)); 214 | this.self.on('close', this.onClose.bind(this)); 215 | 216 | if (this.runOnLoad) { 217 | this.makeAPIRequest(this.variables); 218 | } 219 | }); 220 | } 221 | } 222 | 223 | module.exports = ParticleBaseNode; 224 | --------------------------------------------------------------------------------