├── data └── README.md ├── views ├── error.ejs ├── index.ejs └── devices.ejs ├── public ├── images │ └── spinner.gif ├── javascript │ ├── .eslintrc.js │ ├── initialize.js │ ├── device.js │ └── view-model.js └── stylesheets │ └── style.css ├── doc ├── apps │ ├── app-oauth.json │ └── app.json └── events │ ├── DELETE.json │ ├── EVENT_ATTRIBUTE_CHANGE.json │ └── headers.json ├── package.json ├── .eslintrc.js ├── .gitignore ├── server.js └── README.md /data/README.md: -------------------------------------------------------------------------------- 1 | This directory is used for storing installed app contexts 2 | -------------------------------------------------------------------------------- /views/error.ejs: -------------------------------------------------------------------------------- 1 |

<%= message %>

2 |

<%= error.status %>

3 |
<%= error.stack %>
4 | -------------------------------------------------------------------------------- /public/images/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmartThingsCommunity/api-app-subscription-example-js/HEAD/public/images/spinner.gif -------------------------------------------------------------------------------- /public/javascript/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'browser': true, 4 | 'node': false, 5 | }, 6 | 'globals': { 7 | '$': true, 8 | 'ko': true, 9 | 'Device': true, 10 | 'ViewModel': true, 11 | }, 12 | 'rules': { 13 | 'no-unused-vars': 'off', 14 | 'no-undef': 'off' 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SmartThings API App Test 5 | 6 | 7 | 8 |

SmartThings API App Test

9 |

10 | Connect to SmartThings 11 |

12 | 13 | 14 | -------------------------------------------------------------------------------- /doc/apps/app-oauth.json: -------------------------------------------------------------------------------- 1 | { 2 | "clientName": "API Access Example", 3 | "scope": [ 4 | "i:deviceprofiles", 5 | "r:devices:*", 6 | "r:locations:*", 7 | "r:scenes:*", 8 | "x:devices:*", 9 | "x:scenes:*" 10 | ], 11 | "redirectUris": [ 12 | "http://localhost:3000/oauth/callback", 13 | "https://smartthings-oauth.ngrok.io/oauth/callback" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /doc/apps/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": "bobs-api-access-example-5", 3 | "appType": "API_ONLY", 4 | "classifications": [ 5 | "AUTOMATION" 6 | ], 7 | "displayName": "API Access Example 5", 8 | "description": "Example of an OAuth-In app", 9 | "singleInstance": false, 10 | "installMetadata": {}, 11 | "apiOnly": { 12 | "targetUrl": "https://smartthings-oauth.ngrok.io" 13 | }, 14 | "oauth": { 15 | "clientName": "API Access Example 2", 16 | "scope": [ 17 | "i:deviceprofiles", 18 | "r:locations:*", 19 | "r:scenes:*", 20 | "x:scenes:*", 21 | "r:devices:*", 22 | "x:devices:*" 23 | ], 24 | "redirectUris": ["https://smartthings-oauth.ngrok.io/oauth/callback"] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /doc/events/DELETE.json: -------------------------------------------------------------------------------- 1 | { 2 | "messageType": "EVENT", 3 | "eventData": { 4 | "installedApp": { 5 | "installedAppId": "f18cf08f-6c89-4194-a35d-b7a2211bb084", 6 | "locationId": "5f278baa-aff0-4cf0-a323-3d9ee1fc58d5" 7 | }, 8 | "events": [ 9 | { 10 | "eventTime": "2019-06-23T22:04:51Z", 11 | "eventType": "INSTALLED_APP_LIFECYCLE_EVENT", 12 | "installedAppLifecycleEvent": { 13 | "eventId": "ec8598fc-9602-11e9-a856-8f2a90a38a5a", 14 | "locationId": "5f278baa-aff0-4cf0-a323-3d9ee1fc58d5", 15 | "installedAppId": "f18cf08f-6c89-4194-a35d-b7a2211bb084", 16 | "appId": "4406788a-c401-4752-a8ce-af7cf75d06d3", 17 | "lifecycle": "DELETE", 18 | "delete": {} 19 | } 20 | } 21 | ] 22 | } 23 | } -------------------------------------------------------------------------------- /public/javascript/initialize.js: -------------------------------------------------------------------------------- 1 | let viewModel; 2 | let eventSource; 3 | $( document ).ready(function() { 4 | viewModel = new ViewModel(); 5 | ko.applyBindings(viewModel); 6 | $.get('/viewData', function(viewData) { 7 | console.log(`viewData=${JSON.stringify(viewData,null,2)}`); 8 | viewModel.initialize(viewData); 9 | 10 | console.log('Opening SSE connection'); 11 | eventSource = new EventSource('/events'); 12 | eventSource.onmessage = function (event) { 13 | const data = JSON.parse(event.data); 14 | console.log(JSON.stringify(data)); 15 | if (data.deviceId) { 16 | viewModel.updateDevice(data.deviceId, data.switchState); 17 | } 18 | }; 19 | eventSource.onerror = function(error) { 20 | console.log('EventSource failed %j', error); 21 | }; 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api-app-subscription-example-js", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "lint": "eslint .", 6 | "start": "node server.js" 7 | }, 8 | "dependencies": { 9 | "@smartthings/file-context-store": "^1.1.2", 10 | "@smartthings/smartapp": "^4.3.4", 11 | "cookie-session": "^2.0.0", 12 | "dotenv": "^16.0.1", 13 | "ejs": "~3.1.8", 14 | "encodeurl": "^1.0.2", 15 | "express": "~4.19.2", 16 | "express-sse": "^0.5.3", 17 | "morgan": "~1.10.0", 18 | "nanoid": "^3.3.7", 19 | "sshpk": "^1.15.2" 20 | }, 21 | "devDependencies": { 22 | "eslint": "^8.55.0", 23 | "eslint-config-standard": "^17.1.0", 24 | "eslint-plugin-dollar-sign": "^1.0.2", 25 | "eslint-plugin-jquery": "^1.5.1", 26 | "eslint-plugin-standard": "^5.0.0" 27 | }, 28 | "engines": { 29 | "node": "^16" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | (module.exports = { 2 | 'root': true, 3 | 'plugins': [ 4 | 'standard', 5 | 'dollar-sign', 6 | 'jquery', 7 | ], 8 | 'env': { 9 | 'browser': true, 10 | 'commonjs': true, 11 | 'es6': true, 12 | 'node': true 13 | }, 14 | 'extends': [ 15 | 'eslint:recommended', 16 | ], 17 | 'globals': { 18 | 'Atomics': 'readonly', 19 | 'SharedArrayBuffer': 'readonly' 20 | }, 21 | 'parserOptions': { 22 | 'ecmaVersion': 2018 23 | }, 24 | 'rules': { 25 | 'indent': ['error', 2], 26 | 'quotes': ['error', 'single'], 27 | 'linebreak-style': ['error', 'unix'], 28 | 'curly': ['error', 'all'], 29 | 'comma-dangle': 'off', 30 | 'no-console': 'off', 31 | 'no-undef': 'off', 32 | 'no-process-exit': 'error', 33 | 'no-template-curly-in-string': 'error', 34 | 'require-await': 'off', 35 | 'semi': ['error', 'always'], 36 | } 37 | }); 38 | -------------------------------------------------------------------------------- /doc/events/EVENT_ATTRIBUTE_CHANGE.json: -------------------------------------------------------------------------------- 1 | { 2 | "messageType": "EVENT", 3 | "eventData": { 4 | "installedApp": { 5 | "installedAppId": "45a3769e-bbad-4bbb-af14-d968b0ee44d8", 6 | "locationId": "5f278baa-aff0-4cf0-a323-3d9ee1fc58d5" 7 | }, 8 | "events": [ 9 | { 10 | "eventTime": "2019-06-23T22:21:37Z", 11 | "eventType": "DEVICE_EVENT", 12 | "deviceEvent": { 13 | "eventId": "444d24bf-9605-11e9-94ef-dbdb1e56af85", 14 | "locationId": "5f278baa-aff0-4cf0-a323-3d9ee1fc58d5", 15 | "deviceId": "66e6a13c-57d6-4802-89ed-45e4bc61aa2f", 16 | "componentId": "main", 17 | "capability": "switch", 18 | "attribute": "switch", 19 | "value": "off", 20 | "valueType": "string", 21 | "stateChange": true, 22 | "subscriptionName": "switchHandler" 23 | } 24 | } 25 | ] 26 | } 27 | } -------------------------------------------------------------------------------- /public/javascript/device.js: -------------------------------------------------------------------------------- 1 | const Device = function (parent, device) { 2 | const self = this; 3 | 4 | this.parent = parent; 5 | this.deviceId = device.deviceId; 6 | this.label = device.label; 7 | this.switchState = ko.observable(device.switchState || '--'); 8 | this.tileActive = ko.observable(false); 9 | 10 | this.tileState = ko.pureComputed(function() { 11 | return `${self.tileActive() ? 'processing ' : ''}${self.switchState()}`; 12 | }); 13 | 14 | this.toggleState = function () { 15 | self.tileActive(true); 16 | const newValue = self.switchState() === 'on' ? 'off' : 'on'; 17 | const params = { 18 | commands: [ 19 | { 20 | componentId: 'main', 21 | capability: 'switch', 22 | command: newValue, 23 | argumemnts: [] 24 | } 25 | ] 26 | }; 27 | 28 | $.ajax({ 29 | type: 'POST', 30 | url: `/command/${self.deviceId}`, 31 | data: JSON.stringify(params), 32 | dataType: 'json', 33 | contentType: 'application/json; charset=utf-8', 34 | }); 35 | }; 36 | 37 | this.switchState.subscribe(function() { 38 | self.tileActive(false); 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Intellij 9 | .idea 10 | *.iml 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (https://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # Typescript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | .env.* 61 | 62 | # next.js build output 63 | .next 64 | 65 | .DS_Store 66 | 67 | data/*.json 68 | -------------------------------------------------------------------------------- /doc/events/headers.json: -------------------------------------------------------------------------------- 1 | { 2 | "content-type": "application/json", 3 | "accept": "application/json", 4 | "authorization": "Signature keyId=\"/pl/useast2/1e-1b-cd-10-5d-32-a2-5e-8c-30-de-d8-3d-b0-19-4e-5b-76-13-1e\",signature=\"AqgdEKIfIGRnhEmuJVcnAjvmvPBCz9n70vTU2lu4glisMwm+4+AA4uTYnsPe/K43MO/3vi1tVf6S0OuS16OqqwS0Sk5VdewVFesWQgbxLMrWFtRlt0vIfOarq2AknocW47ksvoZLxFC1sOe3KL058FwbyNqfhOHV96SX0lmamRwC6vw+aOXxzX/bl7O6XQTQDsWTSENjvEPIj8nZmysSF3tVDlW3Bi12cHQ5oZkhqj+b7dH7hEfnERD3+byEotGKb9qa1Pb6MKvtZkWOnODiub/mEjFpgTSeDdnrKvh1/eE4OxuJSFT0Vo01Ycr8vmf9nXJGXb2BfdvLjnQwyl0JBw==\",headers=\"(request-target) digest date\",algorithm=\"rsa-sha256\"", 5 | "digest": "SHA256=zbxlPkdU/GJdDcW4LJ2ubXZoiC1ztgvbsL2ATNdmoRY=", 6 | "date": "Sun, 23 Jun 2019 22:04:52 UTC", 7 | "x-st-correlation": "CB0BFABB-42A8-4BF8-A6D6-1E5086856794", 8 | "x-b3-traceid": "64913b7171630d84", 9 | "x-b3-spanid": "2bfacbc5239ee3de", 10 | "x-b3-parentspanid": "d63d7f76f5d14bed", 11 | "x-b3-sampled": "0", 12 | "cookie": "connect.sid=s%3AlYm2d3HlZXRbpO6vhu1BNV6Az5M5oO9P.ZkjJdoAwlq07YNajkloBCUh8N3dWOYj%2FphCQr71VfvM", 13 | "content-length": "522", 14 | "host": "smartthings-oauth.ngrok.io", 15 | "user-agent": "AHC/2.1", 16 | "x-forwarded-proto": "https", 17 | "x-forwarded-for": "52.14.10.9" 18 | } -------------------------------------------------------------------------------- /public/javascript/view-model.js: -------------------------------------------------------------------------------- 1 | const ViewModel = function () { 2 | const self = this; 3 | 4 | this.errorMessage = ko.observable(); 5 | this.devices = ko.observableArray(); 6 | this.deviceMap = {}; 7 | this.initialized = ko.observable(); 8 | 9 | this.updateDevice = function(deviceId, switchState) { 10 | if (self.deviceMap[deviceId]) { 11 | self.deviceMap[deviceId].switchState(switchState); 12 | } else { 13 | log.console(`Device ${deviceId} not found`); 14 | } 15 | }; 16 | 17 | this.initialize = function(viewData) { 18 | for (const device of viewData.devices) { 19 | const deviceModel = new Device(this, device); 20 | self.devices.push(deviceModel); 21 | self.deviceMap[device.deviceId] = deviceModel; 22 | } 23 | self.initialized(true); 24 | self.errorMessage(viewData.errorMessage); 25 | }; 26 | 27 | this.allOn = function() { 28 | self.setSwitch('on'); 29 | }; 30 | 31 | this.allOff = function() { 32 | self.setSwitch('off'); 33 | }; 34 | 35 | this.setSwitch = function(value) { 36 | const params = { 37 | commands: [ 38 | { 39 | componentId: 'main', 40 | capability: 'switch', 41 | command: value, 42 | argumemnts: [] 43 | } 44 | ] 45 | }; 46 | 47 | $.ajax({ 48 | type: 'POST', 49 | url: '/commands', 50 | data: JSON.stringify(params), 51 | dataType: 'json', 52 | contentType: 'application/json; charset=utf-8', 53 | }); 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /views/devices.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SmartThings API App Test 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |
19 |

<%= locationName %>

20 |
21 |
22 | All On 23 | All Off 24 |
25 |
26 |
27 |
28 |

Loading switches from location

29 |
30 |
31 |
32 |
33 |

34 |
35 |
36 |
37 |
38 |
39 |
40 | 41 | 42 | 43 | 44 |
45 |
46 |
47 |
48 |
49 |
50 |

Logout

51 |
52 |
53 |
54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font: 16px Roboto, "Lucida Grande", Arial, sans-serif; 3 | } 4 | 5 | a { 6 | color: #00B7FF; 7 | } 8 | 9 | .errorMessage { 10 | color: red; 11 | } 12 | 13 | .deviceTile { 14 | border-width: 1px; 15 | padding: 7px; 16 | margin: 5px 10px 10px 0; 17 | background-color: #85d4ff; 18 | height: 64px; 19 | vertical-align: middle; 20 | border-radius: 10px; 21 | width: 300px; 22 | display: inline-block; 23 | } 24 | 25 | .displayName { 26 | display: flex; 27 | align-items: center; 28 | height: 100%; 29 | margin: 0 88px 0 10px; 30 | cursor: pointer; 31 | } 32 | 33 | .deviceControl { 34 | display: flex; 35 | align-items: center; 36 | height: 100%; 37 | float: right; 38 | width: 80px; 39 | text-transform: uppercase; 40 | border-radius: 10px; 41 | } 42 | 43 | .deviceDelete { 44 | display: flex; 45 | align-items: center; 46 | height: 100%; 47 | float: left; 48 | margin-left: -2px; 49 | } 50 | .deviceStatus { 51 | display: inline-block; 52 | width: 100%; 53 | text-align: center; 54 | cursor: pointer; 55 | font-size: 14px; 56 | } 57 | 58 | .on, .active, .open { 59 | background-color: #3291ff; 60 | color: #ffffff; 61 | } 62 | 63 | .off, .inactive, .closed { 64 | background-color: #32d0ff; 65 | color: #ffffff; 66 | } 67 | 68 | .processing { 69 | background-image: url("/images/spinner.gif"); 70 | background-repeat: no-repeat; 71 | background-position: center; 72 | } 73 | 74 | input.oauth { 75 | width: 49%; 76 | height: 50px; 77 | } 78 | 79 | .left { 80 | float: left; 81 | } 82 | 83 | .right { 84 | float: right; 85 | } 86 | 87 | .footer { 88 | text-align: center; 89 | margin-top: 20px; 90 | } 91 | 92 | h1 { 93 | margin-top: 25px; 94 | } 95 | 96 | h3 { 97 | margin: 20px 0 20px 0px; 98 | } 99 | 100 | #dropdownMenu { 101 | } 102 | 103 | #dropdownMenuButton { 104 | margin-top: 20px; 105 | background-color: #dddddd; 106 | } 107 | 108 | input.number-control, input.custom-control { 109 | width: 100%; 110 | display: inline-block; 111 | height: 2.25em; 112 | padding: 1px 2px 1px 2px; 113 | } 114 | 115 | textarea.custom-control { 116 | width: 100%; 117 | display: inline-block; 118 | } 119 | 120 | form.login input.btn { 121 | width: 175px; 122 | } 123 | 124 | #oauth-buttons { 125 | margin-top: 40px; 126 | } 127 | 128 | .btn-secondary { 129 | width: 100%; 130 | } 131 | .control { 132 | margin-right: 20px; 133 | cursor: pointer; 134 | } 135 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const path = require('path'); 3 | const express = require('express'); 4 | const cookieSession = require('cookie-session'); 5 | const logger = require('morgan'); 6 | const encodeUrl = require('encodeurl'); 7 | const SSE = require('express-sse'); 8 | const FileContextStore = require('@smartthings/file-context-store'); 9 | const SmartApp = require('@smartthings/smartapp'); 10 | const { nanoid } = require('nanoid'); 11 | 12 | const port = process.env.PORT || 3000; 13 | const appId = process.env.APP_ID; 14 | const clientId = process.env.CLIENT_ID; 15 | const clientSecret = process.env.CLIENT_SECRET; 16 | const serverUrl = process.env.SERVER_URL || `https://${process.env.PROJECT_DOMAIN}.glitch.me`; 17 | const redirectUri = `${serverUrl}/oauth/callback`; 18 | const scope = encodeUrl('r:locations:* r:devices:* x:devices:*'); 19 | 20 | /* 21 | * Server-sent events. Used to update the status of devices on the web page from subscribed events 22 | */ 23 | const sse = new SSE(); 24 | 25 | /** 26 | * Stores access tokens and other properties for calling the SmartThings API. This implementation is a simple flat file 27 | * store that is for demo purposes not appropriate for production systems. Other context stores exist, including 28 | * DynamoDB and Firebase. 29 | */ 30 | const contextStore = new FileContextStore('data'); 31 | 32 | /* 33 | * Thew SmartApp. Provides an API for making REST calls to the SmartThings platform and 34 | * handles calls from the platform for subscribed events as well as the initial app registration challenge. 35 | */ 36 | const apiApp = new SmartApp() 37 | .appId(appId) 38 | .clientId(clientId) 39 | .clientSecret(clientSecret) 40 | .contextStore(contextStore) 41 | .redirectUri(redirectUri) 42 | .enableEventLogging(2) 43 | .subscribedEventHandler('switchHandler', async (ctx, event) => { 44 | /* Device event handler. Current implementation only supports main component switches */ 45 | if (event.componentId === 'main') { 46 | try { 47 | sse.send({ 48 | deviceId: event.deviceId, 49 | switchState: event.value 50 | }); 51 | } catch(e) { 52 | console.log(e.message); 53 | } 54 | } 55 | console.log(`EVENT ${event.deviceId} ${event.componentId}.${event.capability}.${event.attribute}: ${event.value}`); 56 | }); 57 | 58 | /* 59 | * Webserver setup 60 | */ 61 | const server = express(); 62 | server.set('views', path.join(__dirname, 'views')); 63 | server.use(cookieSession({ 64 | name: 'session', 65 | keys: ['key1', 'key2'] 66 | })); 67 | server.set('view engine', 'ejs'); 68 | server.use(logger(':date[iso] :method :url :res[location] :status :response-time ms')); 69 | server.use(express.json()); 70 | server.use(express.urlencoded({extended: false})); 71 | server.use(express.static(path.join(__dirname, 'public'))); 72 | 73 | // Needed to avoid flush error with express-sse and newer versions of Node 74 | server.use(function (req, res, next) { 75 | res.flush = function () { /* Do nothing */ }; 76 | next(); 77 | }); 78 | 79 | /* 80 | * Handles calls to the SmartApp from SmartThings, i.e. registration challenges and device events 81 | */ 82 | server.post('/', async (req, res) => { 83 | apiApp.handleHttpCallback(req, res); 84 | }); 85 | 86 | /* 87 | * Main web page. Shows link to SmartThings if not authenticated and list of switch devices afterwards 88 | */ 89 | server.get('/',async (req, res) => { 90 | if (req.session.smartThings) { 91 | // Cookie found, display page with list of devices 92 | const data = req.session.smartThings; 93 | res.render('devices', { 94 | installedAppId: data.installedAppId, 95 | locationName: data.locationName 96 | }); 97 | } 98 | else { 99 | // No context cookie. Display link to authenticate with SmartThings 100 | const state = nanoid(24); 101 | req.session.smartThingsState = state; 102 | res.render('index', { 103 | url: `https://api.smartthings.com/oauth/authorize?client_id=${clientId}&scope=${scope}&response_type=code&redirect_uri=${redirectUri}&state=${state}` 104 | }); 105 | } 106 | }); 107 | 108 | /** 109 | * Returns view model data for the devices page 110 | */ 111 | server.get('/viewData', async (req, res) => { 112 | const data = req.session.smartThings; 113 | 114 | // Read the context from DynamoDB so that API calls can be made 115 | const ctx = await apiApp.withContext(data.installedAppId); 116 | try { 117 | // Get the list of switch devices, which doesn't include the state of the switch 118 | const deviceList = await ctx.api.devices.list({capability: 'switch'}); 119 | 120 | // Query for the state of each one 121 | const ops = deviceList.map(it => { 122 | return ctx.api.devices.getCapabilityStatus(it.deviceId, 'main', 'switch').then(state => { 123 | return { 124 | deviceId: it.deviceId, 125 | label: it.label, 126 | switchState: state.switch.value 127 | }; 128 | }); 129 | }); 130 | 131 | // Wait for all those queries to complete 132 | const devices = await Promise.all(ops); 133 | 134 | // Respond to the request 135 | res.send({ 136 | errorMessage: devices.length > 0 ? '' : 'No switch devices found in location', 137 | devices: devices.sort( (a, b) => { 138 | return a.label === b.label ? 0 : (a.label > b.label) ? 1 : -1; 139 | }) 140 | }); 141 | } catch (error) { 142 | res.send({ 143 | errorMessage: `${error.message || error}`, 144 | devices: [] 145 | }); 146 | } 147 | }); 148 | 149 | /* 150 | * Logout. Uninstalls app and clears context cookie 151 | */ 152 | server.get('/logout', async function(req, res) { 153 | try { 154 | // Read the context from DynamoDB so that API calls can be made 155 | const ctx = await apiApp.withContext(req.session.smartThings.installedAppId); 156 | 157 | // Delete the installed app instance from SmartThings 158 | await ctx.api.installedApps.delete(); 159 | } 160 | catch (error) { 161 | console.error('Error logging out', error.message); 162 | } 163 | // Delete the session data 164 | req.session = null; 165 | res.redirect('/'); 166 | 167 | }); 168 | 169 | /* 170 | * Handles OAuth redirect 171 | */ 172 | server.get('/oauth/callback', async (req, res, next) => { 173 | 174 | try { 175 | // Validate the state 176 | if (req.query.state !== req.session.smartThingsState) { 177 | res.status(400).send('Invalid state'); 178 | return; 179 | } 180 | 181 | // Store the SmartApp context including access and refresh tokens. Returns a context object for use in making 182 | // API calls to SmartThings 183 | const ctx = await apiApp.handleOAuthCallback(req); 184 | 185 | // Get the location name (for display on the web page) 186 | const location = await ctx.api.locations.get(ctx.locationId); 187 | 188 | // Set the cookie with the context, including the location ID and name 189 | req.session.smartThings = { 190 | locationId: ctx.locationId, 191 | locationName: location.name, 192 | installedAppId: ctx.installedAppId 193 | }; 194 | 195 | // Remove any existing subscriptions and unsubscribe to device switch events 196 | try { 197 | await ctx.api.subscriptions.delete(); 198 | try { 199 | await ctx.api.subscriptions.subscribeToCapability('switch', 'switch', 'switchHandler'); 200 | } catch (error) { 201 | console.error('Error subscribing to switch events', error.message); 202 | } 203 | } catch (error) { 204 | console.error('Error deleting subscriptions', error.message); 205 | } 206 | 207 | // Redirect back to the main page 208 | res.redirect('/'); 209 | } catch (error) { 210 | console.log('Error handling OAuth callback', error.message); 211 | next(error); 212 | } 213 | }); 214 | 215 | /** 216 | * Executes a device command from the web page 217 | */ 218 | server.post('/command/:deviceId', async(req, res, next) => { 219 | try { 220 | // Read the context from DynamoDB so that API calls can be made 221 | const ctx = await apiApp.withContext(req.session.smartThings.installedAppId); 222 | 223 | // Execute the device command 224 | await ctx.api.devices.executeCommands(req.params.deviceId, req.body.commands); 225 | res.send({}); 226 | } catch (error) { 227 | next(error); 228 | } 229 | }); 230 | 231 | 232 | /** 233 | * Executes a command for all devices 234 | */ 235 | server.post('/commands', async(req, res) => { 236 | console.log(JSON.stringify(req.body.commands, null, 2)); 237 | // Read the context from DynamoDB so that API calls can be made 238 | const ctx = await apiApp.withContext(req.session.smartThings.installedAppId); 239 | 240 | const devices = await ctx.api.devices.list({capability: 'switch'}); 241 | const ops = []; 242 | for (const device of devices) { 243 | ops.push(ctx.api.devices.executeCommands(device.deviceId, req.body.commands)); 244 | } 245 | await Promise.all(ops); 246 | 247 | res.send({}); 248 | }); 249 | 250 | /** 251 | * Handle SSE connection from the web page 252 | */ 253 | server.get('/events', sse.init); 254 | 255 | /** 256 | * Start the HTTP server and log URLs. Use the "open" URL for starting the OAuth process. Use the "callback" 257 | * URL in the API app definition using the SmartThings Developer Workspace. 258 | */ 259 | server.listen(port); 260 | console.log(`\nTarget URL -- Copy this value into the targetUrl field of you app creation request:\n${serverUrl}\n`); 261 | console.log(`Redirect URI -- Copy this value into redirectUris field of your app creation request:\n${redirectUri}\n`); 262 | console.log(`Website URL -- Visit this URL in your browser to log into SmartThings and connect your account:\n${serverUrl}`); 263 | 264 | 265 | 266 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Event Subscription API App Example 2 | 3 | ## Overview 4 | 5 | This NodeJS Express application illustrates how to create an _API Access_ SmartApp that connects to your SmartThings 6 | account with OAuth2 to control devices and subscribe to device events. The application uses the 7 | [SmartThings SmartApp](https://www.npmjs.com/package/@smartthings/smartapp) SDK NPM module for making the 8 | API calls to control switch devices and subscribe to switch events. The app creates a web page that displays 9 | the state of all switches in a location and allows those switches to be turned on and off. 10 | 11 | API access tokens and web session state are stored local files. This storage mechanism is not suitable for 12 | production use. There are alternative storage mechanisms for DynamoDB and Firebase. 13 | 14 | ## Files and directories 15 | 16 | - public 17 | - images -- image assets used by the web pages 18 | - javascript -- javascript used by the web page for rendering and controlling devices 19 | - stylesheets -- stylesheets used by the web pages 20 | - views 21 | - devices.ejs -- page that displays switch devices and allows them to be controlled 22 | - error.ejs -- error page 23 | - index.ejs -- initial page with link to connect to SmartThings 24 | - server.js -- the Express server and SmartApp 25 | - .env -- file you create with app credentials 26 | - package.json -- The Node.js package file 27 | 28 | ## Getting Started 29 | 30 | ### Prerequisites 31 | - A [SmartThings](https://smartthings.com) account with at least one location and device with the switch capability 32 | - The [SmartThings CLI](https://github.com/SmartThingsCommunity/smartthings-cli#readme) installed 33 | - [Node.js](https://nodejs.org/en/) and [npm](https://www.npmjs.com/) installed 34 | - [ngrok](https://ngrok.com/) or similar tool to create a secure tunnel to a publicly available URL 35 | 36 | Note that as an alternative to running the app locally you can use [Glitch](glitch.com) to host your app. 37 | 38 | ## Instructions 39 | 40 | ### 1. Set up your server 41 | 42 | #### If running locally and tunneling using ngrok or similar tool 43 | Clone [this GitHub repository](https://github.com/SmartThingsCommunity/api-app-subscription-example-js), cd into the 44 | directory, and install the Node modules with NPM: 45 | ``` 46 | git clone https://github.com/SmartThingsCommunity/api-app-subscription-example-js.git 47 | cd api-app-subscription-example-js 48 | npm install 49 | ``` 50 | 51 | Start ngrok or similar tool to create a secure tunnel to your local server. Note that the free version of ngrok will 52 | change the subdomain part of the URL every time you restart it, so you will need to update the server URL in the `.env`. 53 | Alternately, you can use the paid version which supports reserved subdomains: 54 | ``` 55 | ngrok http 3000 56 | ``` 57 | 58 | Create a file named `.env` in the project directory and set the base URL of the server to your ngrok URL, 59 | For example: 60 | ``` 61 | SERVER_URL=https://315e5367357f.ngrok.app 62 | ``` 63 | 64 | Start your server and make note of the information it prints out: 65 | ``` 66 | node server.js 67 | ``` 68 | 69 | #### If using Glitch 70 | 71 | Remix this Glitch project: [midnight-cloudy-sceptre](https://glitch.com/edit/#!/midnight-cloudy-sceptre) and wait for 72 | the server to start. 73 | 74 | ### 2. Register your SmartThings app 75 | 76 | Look at the log output of your local server or Glitch app. You should see something like this: 77 | ``` 78 | Target URL -- Copy this value into the targetUrl field of you app creation request: 79 | https://315e5367357f.ngrok.app 80 | 81 | Redirect URI -- Copy this value into redirectUris field of your app creation request: 82 | https://315e5367357f.ngrok.app/oauth/callback 83 | 84 | Website URL -- Visit this URL in your browser to log into SmartThings and connect your account: 85 | https://315e5367357f.ngrok.app 86 | ``` 87 | 88 | Run the `smartthings apps:create` command to create a new app. You will be prompted for the required 89 | information. The following is an example of the output from the command: 90 | 91 | ```bash 92 | ~ % smartthings apps:create 93 | ? What kind of app do you want to create? (Currently, only OAuth-In apps are supported.) OAuth-In App 94 | 95 | More information on writing SmartApps can be found at 96 | https://developer.smartthings.com/docs/connected-services/smartapp-basics 97 | 98 | ? Display Name My API Subscription App 99 | ? Description Allows control of SmartThings devices 100 | ? Icon Image URL (optional) 101 | ? Target URL (optional) https://315e5367357f.ngrok.app 102 | 103 | More information on OAuth 2 Scopes can be found at: 104 | https://www.oauth.com/oauth2-servers/scope/ 105 | 106 | To determine which scopes you need for the application, see documentation for the individual endpoints you will use in your app: 107 | https://developer.smartthings.com/docs/api/public/ 108 | 109 | ? Select Scopes. r:devices:*, x:devices:*, r:locations:* 110 | ? Add or edit Redirect URIs. Add Redirect URI. 111 | ? Redirect URI (? for help) https://315e5367357f.ngrok.app/oauth/callback 112 | ? Add or edit Redirect URIs. Finish editing Redirect URIs. 113 | ? Choose an action. Finish and create OAuth-In SmartApp. 114 | Basic App Data: 115 | ───────────────────────────────────────────────────────────────────────────── 116 | Display Name My API Subscription App 117 | App Id 3275eef3-xxxx-xxxx-xxxx-xxxxxxxxxxxx 118 | App Name amyapisubscriptionapp-aaea18b1-xxxx-xxxx-xxxx-xxxxxxxxxxxx 119 | Description Allows control of SmartThings devices 120 | Single Instance true 121 | Classifications CONNECTED_SERVICE 122 | App Type API_ONLY 123 | Target URL https://315e5367357f.ngrok.app 124 | Target Status PENDING 125 | ───────────────────────────────────────────────────────────────────────────── 126 | 127 | 128 | OAuth Info (you will not be able to see the OAuth info again so please save it now!): 129 | ─────────────────────────────────────────────────────────── 130 | OAuth Client Id 7a850484-xxxx-xxxx-xxxx-xxxxxxxxxxxx 131 | OAuth Client Secret 3581f317-xxxx-xxxx-xxxx-xxxxxxxxxxxx 132 | ─────────────────────────────────────────────────────────── 133 | ``` 134 | 135 | Save the output of the create command for later use. It contains the client ID and secret of your app. You 136 | won't be able to see those values again. 137 | 138 | After running the create command look at your server logs for a line similar to this one: 139 | ``` 140 | 2024-09-03T14:24:14.967Z warn: Unexpected CONFIRMATION request for app 3275eef3-c18c-4c0c-bbe6-1fbd36739b5c, 141 | received {"messageType":"CONFIRMATION","confirmationData":{"appId":"3275eef3-c18c-4c0c-bbe6-1fbd36739b5c", 142 | "confirmationUrl":"https://api.smartthings.com/apps/3275eef3-xxxx-xxxx-xxxx-xxxxxxxxxxxx/confirm-registration?token=53c980fa-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}} 143 | ``` 144 | 145 | Paste the URL into a browser or request it with a utility like curl to enable callbacks to your app. 146 | The response should contain the 147 | _targetURL_ value from your app creation request, for example: 148 | ``` 149 | { 150 | targetUrl: "https://315e5367357f.ngrok.app" 151 | } 152 | ``` 153 | 154 | ### 3. Update and restart your server 155 | 156 | Add the _APP_ID_, _CLIENT_ID_ and _CLIENT_SECRET_ properties from `apps:create` command 157 | response to your `.env` file: 158 | ``` 159 | APP_ID=3275eef3-xxxx-xxxx-xxxx-xxxxxxxxxxxx 160 | CLIENT_ID=7a850484-xxxx-xxxx-xxxx-xxxxxxxxxxxx 161 | CLIENT_SECRET=3581f317-xxxx-xxxx-xxxx-xxxxxxxxxxxx 162 | ``` 163 | 164 | Restart your server: 165 | ``` 166 | node server.js 167 | ``` 168 | 169 | Note that if you are using Glitch your server will restart automatically when you edit the `.env` file. 170 | 171 | ### 4. Log into SmartThings 172 | 173 | Open the Website URL from the server log (`https://315e5367357f.ngrok.app` in this example) in a browser, 174 | log in with your SmartThings account credentials, and 175 | choose a location. You should see a page listing all devices in that location with the _switch_ 176 | capability. Tapping the device on the page should turn the switch on and off. You should also see 177 | the states of the switches on the page change when you turn them on and off with the SmartThings 178 | mobile app. 179 | 180 | ## Troubleshooting 181 | 182 | Your app should be able to control switches and receive events from them. If the app UI is not being updated 183 | when you change the switch state with the SmartThings mobile app, make sure that the target status is `CONFIRMED` 184 | by running the following command: 185 | ``` 186 | ~ % smartthings apps 3275eef3-xxxx-xxxx-xxxx-xxxxxxxxxxxx 187 | ───────────────────────────────────────────────────────────────────────────── 188 | Display Name My API Subscription App 189 | App Id 3275eef3-xxxx-xxxx-xxxx-xxxxxxxxxxxx 190 | App Name amyapisubscriptionapp-ab9fb550-xxxx-xxxx-xxxx-xxxxxxxxxxxx 191 | Description Allows SmartThings switches to be controlled 192 | Single Instance true 193 | Classifications CONNECTED_SERVICE 194 | App Type API_ONLY 195 | Target URL https://315e5367357f.ngrok.app 196 | Target Status CONFIRMED 197 | ───────────────────────────────────────────────────────────────────────────── 198 | ``` 199 | 200 | If the target status is 'PENDING' you need to confirm the registration by running the following command and 201 | visiting the confirmation URL in a browser or using a utility like curl: 202 | ``` 203 | smatthings apps:register 3275eef3-xxxx-xxxx-xxxx-xxxxxxxxxxxx 204 | ``` 205 | --------------------------------------------------------------------------------