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 |
--------------------------------------------------------------------------------