├── lambda ├── functions │ ├── cognito-verifyAuthToken │ │ ├── test-bad-token.json │ │ ├── package.json │ │ ├── test-good-token.json │ │ ├── package-lock.json │ │ └── index.js │ ├── ddb-getUserThings │ │ ├── event.json │ │ ├── package.json │ │ └── index.js │ ├── iot-createThing │ │ ├── package.json │ │ ├── event.json │ │ └── index.js │ ├── cfProvider-iotEndpoint │ │ ├── package.json │ │ └── index.js │ ├── ddb-createThingType │ │ ├── package.json │ │ ├── event.json │ │ └── index.js │ ├── ddb-associateThingToUser │ │ ├── package.json │ │ ├── event.json │ │ └── index.js │ ├── cfProvider-cognitoClientSecret │ │ ├── package.json │ │ └── index.js │ ├── cfProvider-cognitoUserPoolDomain │ │ ├── package.json │ │ └── index.js │ ├── cfProvider-cognitoClientConfiguration │ │ ├── package.json │ │ └── index.js │ ├── alexa-skill │ │ ├── package.json │ │ ├── discover-event.json │ │ ├── discoveryConfig.js │ │ ├── AlexaResponse.js │ │ └── index.js │ └── cfProvider-uuid │ │ ├── package.json │ │ ├── package-lock.json │ │ └── index.js ├── layers │ ├── cfn-response │ │ └── nodejs │ │ │ ├── package.json │ │ │ ├── package-lock.json │ │ │ └── node_modules │ │ │ ├── cfn-response-sync │ │ │ └── index.js │ │ │ └── cfn-response-async │ │ │ └── index.js │ └── smart-home-helpers │ │ └── nodejs │ │ └── node_modules │ │ └── smart-home-helpers │ │ └── index.js └── README.md ├── images ├── app-1.png ├── app-2.png ├── app-3.png ├── app-4.png ├── app-5.png ├── app-6.png ├── cert-01.png ├── cert-02.png ├── cert-03.png ├── cognito.png ├── pinout.jpg ├── circuit-1.jpg ├── circuit-2.jpg ├── cool-mode.jpg ├── dynamodb.png ├── esp32_dev.png ├── heat-mode.jpg ├── off-mode.jpg ├── shadow-01.png ├── vendor_id.png ├── esp32_prod.png ├── iot-settings.png ├── link_scope.png ├── mos-build-01.png ├── mos-build-02.png ├── mos-flash-01.png ├── mos-flash-02.png ├── register-1.PNG ├── register-2.PNG ├── register-3.PNG ├── register-4.PNG ├── register-5.PNG ├── register-6.PNG ├── register-7.PNG ├── register-8.PNG ├── register-9.PNG ├── wifi-error.png ├── create_secret_1.png ├── create_secret_2.png ├── esp32-directory.png ├── esp32-telemetry.png ├── name_alexa_skill.png ├── board_and_schematic.jpg ├── thing-name-registry.png └── architecture-overview.JPG ├── esp32 ├── .gitignore ├── mos.yml └── fs │ └── init.js ├── deploy.sh ├── docs ├── 04-esp32-first-time-setup.md ├── 06-test-skill-with-esp32.md ├── 03-test-skill-without-device.md ├── 05a-esp32-parts-list.md ├── 02-sign-up-for-your-skill.md ├── 01-create-alexa-skill-and-aws-backend.md ├── appendix-user-stories.md └── 05-build-esp32-thermostat.md ├── .gitignore ├── README.md └── template.yaml /lambda/functions/cognito-verifyAuthToken/test-bad-token.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "sdfsdf" 3 | } -------------------------------------------------------------------------------- /images/app-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/app-1.png -------------------------------------------------------------------------------- /images/app-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/app-2.png -------------------------------------------------------------------------------- /images/app-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/app-3.png -------------------------------------------------------------------------------- /images/app-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/app-4.png -------------------------------------------------------------------------------- /images/app-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/app-5.png -------------------------------------------------------------------------------- /images/app-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/app-6.png -------------------------------------------------------------------------------- /images/cert-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/cert-01.png -------------------------------------------------------------------------------- /images/cert-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/cert-02.png -------------------------------------------------------------------------------- /images/cert-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/cert-03.png -------------------------------------------------------------------------------- /images/cognito.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/cognito.png -------------------------------------------------------------------------------- /images/pinout.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/pinout.jpg -------------------------------------------------------------------------------- /images/circuit-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/circuit-1.jpg -------------------------------------------------------------------------------- /images/circuit-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/circuit-2.jpg -------------------------------------------------------------------------------- /images/cool-mode.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/cool-mode.jpg -------------------------------------------------------------------------------- /images/dynamodb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/dynamodb.png -------------------------------------------------------------------------------- /images/esp32_dev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/esp32_dev.png -------------------------------------------------------------------------------- /images/heat-mode.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/heat-mode.jpg -------------------------------------------------------------------------------- /images/off-mode.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/off-mode.jpg -------------------------------------------------------------------------------- /images/shadow-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/shadow-01.png -------------------------------------------------------------------------------- /images/vendor_id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/vendor_id.png -------------------------------------------------------------------------------- /lambda/functions/ddb-getUserThings/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "userId": "b7a46a82-8177-4043-9fc9-8609de512928" 3 | } -------------------------------------------------------------------------------- /lambda/functions/ddb-getUserThings/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfprovider-uuid", 3 | "version": "1.0.0" 4 | } -------------------------------------------------------------------------------- /lambda/functions/iot-createThing/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfprovider-uuid", 3 | "version": "1.0.0" 4 | } -------------------------------------------------------------------------------- /esp32/.gitignore: -------------------------------------------------------------------------------- 1 | travis.yml 2 | LICENSE 3 | README.md 4 | setup_iot.sh 5 | tmp/ 6 | certs/ 7 | build/ 8 | fs/*.pem* -------------------------------------------------------------------------------- /images/esp32_prod.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/esp32_prod.png -------------------------------------------------------------------------------- /images/iot-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/iot-settings.png -------------------------------------------------------------------------------- /images/link_scope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/link_scope.png -------------------------------------------------------------------------------- /images/mos-build-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/mos-build-01.png -------------------------------------------------------------------------------- /images/mos-build-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/mos-build-02.png -------------------------------------------------------------------------------- /images/mos-flash-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/mos-flash-01.png -------------------------------------------------------------------------------- /images/mos-flash-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/mos-flash-02.png -------------------------------------------------------------------------------- /images/register-1.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/register-1.PNG -------------------------------------------------------------------------------- /images/register-2.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/register-2.PNG -------------------------------------------------------------------------------- /images/register-3.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/register-3.PNG -------------------------------------------------------------------------------- /images/register-4.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/register-4.PNG -------------------------------------------------------------------------------- /images/register-5.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/register-5.PNG -------------------------------------------------------------------------------- /images/register-6.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/register-6.PNG -------------------------------------------------------------------------------- /images/register-7.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/register-7.PNG -------------------------------------------------------------------------------- /images/register-8.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/register-8.PNG -------------------------------------------------------------------------------- /images/register-9.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/register-9.PNG -------------------------------------------------------------------------------- /images/wifi-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/wifi-error.png -------------------------------------------------------------------------------- /lambda/functions/cfProvider-iotEndpoint/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfprovider-uuid", 3 | "version": "1.0.0" 4 | } -------------------------------------------------------------------------------- /lambda/functions/ddb-createThingType/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfprovider-uuid", 3 | "version": "1.0.0" 4 | } -------------------------------------------------------------------------------- /lambda/functions/iot-createThing/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "thing": { 3 | "serialNumber": "0000001" 4 | } 5 | } -------------------------------------------------------------------------------- /lambda/functions/ddb-associateThingToUser/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfprovider-uuid", 3 | "version": "1.0.0" 4 | } -------------------------------------------------------------------------------- /images/create_secret_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/create_secret_1.png -------------------------------------------------------------------------------- /images/create_secret_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/create_secret_2.png -------------------------------------------------------------------------------- /images/esp32-directory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/esp32-directory.png -------------------------------------------------------------------------------- /images/esp32-telemetry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/esp32-telemetry.png -------------------------------------------------------------------------------- /images/name_alexa_skill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/name_alexa_skill.png -------------------------------------------------------------------------------- /lambda/functions/cfProvider-cognitoClientSecret/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfprovider-uuid", 3 | "version": "1.0.0" 4 | } -------------------------------------------------------------------------------- /lambda/functions/cfProvider-cognitoUserPoolDomain/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfprovider-uuid", 3 | "version": "1.0.0" 4 | } -------------------------------------------------------------------------------- /images/board_and_schematic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/board_and_schematic.jpg -------------------------------------------------------------------------------- /images/thing-name-registry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/thing-name-registry.png -------------------------------------------------------------------------------- /lambda/functions/cfProvider-cognitoClientConfiguration/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfprovider-uuid", 3 | "version": "1.0.0" 4 | } -------------------------------------------------------------------------------- /images/architecture-overview.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matwerber1/aws-alexa-smart-home-demo/HEAD/images/architecture-overview.JPG -------------------------------------------------------------------------------- /lambda/functions/alexa-skill/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfprovider-uuid", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "uuid": "^3.3.3" 6 | } 7 | } -------------------------------------------------------------------------------- /lambda/functions/cfProvider-uuid/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfprovider-uuid", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "uuid": "^3.3.3" 6 | } 7 | } -------------------------------------------------------------------------------- /lambda/functions/ddb-associateThingToUser/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "userId": "6ad485c2-cadf-4114-ac8d-d5095180269c", 3 | "thingName": "aws-alexa-smart-home-demo-SmartHomeThing-T2XSF7MU0XYW" 4 | } -------------------------------------------------------------------------------- /lambda/functions/cognito-verifyAuthToken/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfprovider-uuid", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "https": "^1.0.0", 6 | "node-jose": "^1.1.3" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lambda/layers/cfn-response/nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejs", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "dependencies": { 7 | "uuid": "^3.3.2" 8 | }, 9 | "devDependencies": {}, 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "author": "", 14 | "license": "MIT" 15 | } 16 | -------------------------------------------------------------------------------- /lambda/layers/cfn-response/nodejs/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejs", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "uuid": { 8 | "version": "3.3.2", 9 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", 10 | "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /lambda/functions/cfProvider-uuid/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfprovider-uuid", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "uuid": { 8 | "version": "3.3.3", 9 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", 10 | "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BUCKET= 4 | STACK_NAME=alexa-smart-home-demo 5 | 6 | ALEXA_SKILL_ID= 7 | ALEXA_VENDOR_ID= 8 | 9 | # Install Lambda function dependencies 10 | sam build 11 | 12 | sam package \ 13 | --s3-bucket $BUCKET \ 14 | --template-file .aws-sam/build/template.yaml \ 15 | --output-template-file packaged.yaml 16 | 17 | sam deploy \ 18 | --template-file packaged.yaml \ 19 | --stack-name $STACK_NAME \ 20 | --capabilities CAPABILITY_IAM \ 21 | --parameter-overrides AlexaSkillId=$ALEXA_SKILL_ID AlexaVendorId=$ALEXA_VENDOR_ID 22 | -------------------------------------------------------------------------------- /lambda/layers/smart-home-helpers/nodejs/node_modules/smart-home-helpers/index.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 3 | 4 | module.exports = class Helper { 5 | constructor() { 6 | this.applicationTable = ''; // the DynamoDB user table containing our Cognito user to IoT Thing mappings 7 | } 8 | set userTable(applicationTable) { 9 | this._applicationTable = applicationTable; 10 | } 11 | get applicationTable() { 12 | return this.applicationTable; 13 | } 14 | async getUserEndpoints(userId) { 15 | return `${userId} - thing 123`; 16 | } 17 | } -------------------------------------------------------------------------------- /lambda/README.md: -------------------------------------------------------------------------------- 1 | ## Contents and Usage 2 | 3 | The **functions** directory contains source code for AWS Lambda functions that get created by CloudFormation. If a function has dependencies that are not part of the native Lambda runtime, then those dependencies must be installed (e.g. by a `npm install` command) in the function directory prior to running the the `./deploy.sh` script in the root of this project. If the dependencies are not installed, the function will error out when it is invoked due to a `module not found` error. 4 | 5 | The **layers** directory contains source code for AWS [Lambda Layers](https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html). A Lambda Layer is a way of packaging up one or more dependencies and then sharing those layers across one or more Lambda functions. A Layer allows you to install/maintain frequently-used dependencies in a single place rather than bundling the dependency source with each individual function. -------------------------------------------------------------------------------- /lambda/functions/cognito-verifyAuthToken/test-good-token.json: -------------------------------------------------------------------------------- 1 | { 2 | "token": "eyJraWQiOiJVbSszRHY1bENkVDZsNWNHRE5QWUVkZVZpOU5qTWx2SzlRV0FXNVRFZURBPSIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiJiN2E0NmE4Mi04MTc3LTQwNDMtOWZjOS04NjA5ZGU1MTI5MjgiLCJ0b2tlbl91c2UiOiJhY2Nlc3MiLCJzY29wZSI6InBob25lIG9wZW5pZCIsImF1dGhfdGltZSI6MTU1NzgwMjgxNCwiaXNzIjoiaHR0cHM6XC9cL2NvZ25pdG8taWRwLnVzLWVhc3QtMS5hbWF6b25hd3MuY29tXC91cy1lYXN0LTFfUlVhbEY3MlBOIiwiZXhwIjoxNTU3ODg4NDgwLCJpYXQiOjE1NTc4ODQ4ODAsInZlcnNpb24iOjIsImp0aSI6ImE5NDdhYzIwLTcwMzItNDVkMi1iNGMyLTExZTE2NWM0MWMxYyIsImNsaWVudF9pZCI6Ijd0dnA1cXVnNzdsdWxua2JyazE2Z2p0bjgwIiwidXNlcm5hbWUiOiJiN2E0NmE4Mi04MTc3LTQwNDMtOWZjOS04NjA5ZGU1MTI5MjgifQ.JcICrcRespXj660g7oYqmlIXCXWUHsD7e6pDnbPUGFBtXXBR0A6hNiokLdbDnbdLmVOeWn-O_BCU7g0JXBr-3PqSDHArHPxiXt6LXlgXR_BUYDsEvZu2f6C2sZbUPa7kJ_tEtevrIJKazB1jujySoxJ7rywE57bCEvk_6bvXXZYB2nyg2AbvhRbhLywsUyf87qnobQaPzuBSuxAz_CMzLAPa5bWKotzXxxQQgqrwvKeqNpJQRVwpWweB44aaHrMDtE2GWsNC0qViQe1OluG0nZMHqzjN0yr1VY8bW-WA9Uav9W6hRlJ0JFJd_nvthRqMD_QAR_cCK-kXF3UYwqy-hA" 3 | } -------------------------------------------------------------------------------- /lambda/functions/ddb-associateThingToUser/index.js: -------------------------------------------------------------------------------- 1 | var AWS = require("aws-sdk"); 2 | var dynamodb = new AWS.DynamoDB.DocumentClient(); 3 | 4 | var deviceTableName = process.env.DEVICE_TABLE; 5 | 6 | exports.handler = async (event) => { 7 | 8 | try { 9 | console.log(`Received event:\n${JSON.stringify(event)}`); 10 | await associateDeviceToUser(event); 11 | const response = { 12 | statusCode: 200, 13 | message: `User ${event.userId} associated to thing ${event.thingName}` 14 | }; 15 | return response; 16 | } catch (error) { 17 | console.log(`Error:\n${error}`); 18 | throw error; 19 | } 20 | }; 21 | 22 | 23 | async function associateDeviceToUser(event) { 24 | 25 | var hashId = "userId_" + event.userId; 26 | var sortId = "thingName_" + event.thingName; 27 | 28 | var params = { 29 | Item: { 30 | hashId: hashId, 31 | sortId: sortId, 32 | userId: event.userId, 33 | thingName: event.thingName 34 | }, 35 | ReturnConsumedCapacity: "TOTAL", 36 | TableName: deviceTableName 37 | }; 38 | var response = await dynamodb.put(params).promise(); 39 | console.log('PutItem response:\n' + JSON.stringify(response, null, 2)); 40 | } -------------------------------------------------------------------------------- /lambda/functions/ddb-createThingType/event.json: -------------------------------------------------------------------------------- 1 | { 2 | "deviceType": "Smart Home Device", 3 | "manufacturerName": "Smarthome Products, Inc.", 4 | "modelName": "model 1", 5 | "friendlyName": "Smart Device", 6 | "description": "Super cool smart home product", 7 | "displayCategories": [ 8 | "OTHER" 9 | ], 10 | "capabilities": [ 11 | { 12 | "type": "AlexaInterface", 13 | "interface": "Alexa.ThermostatController", 14 | "version": "3", 15 | "configuration": { 16 | "supportsScheduling": false, 17 | "supportedModes": [ 18 | "HEAT", 19 | "COOL", 20 | "OFF" 21 | ], 22 | "properties": { 23 | "supported": [ 24 | { 25 | "name": "targetSetpoint" 26 | }, 27 | { 28 | "name": "thermostatMode" 29 | }, 30 | { 31 | "name": "targetSetpointDelta" 32 | } 33 | ] 34 | } 35 | } 36 | }, 37 | { 38 | "type": "AlexaInterface", 39 | "interface": "Alexa", 40 | "version": "3" 41 | } 42 | ] 43 | } -------------------------------------------------------------------------------- /lambda/functions/alexa-skill/discover-event.json: -------------------------------------------------------------------------------- 1 | { 2 | "directive": { 3 | "header": { 4 | "namespace": "Alexa.Discovery", 5 | "name": "Discover", 6 | "payloadVersion": "3", 7 | "messageId": "1bd5d003-31b9-476f-ad03-71d471922820" 8 | }, 9 | "payload": { 10 | "scope": { 11 | "type": "BearerToken", 12 | "token": "eyJraWQiOiJVbSszRHY1bENkVDZsNWNHRE5QWUVkZVZpOU5qTWx2SzlRV0FXNVRFZURBPSIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiJiN2E0NmE4Mi04MTc3LTQwNDMtOWZjOS04NjA5ZGU1MTI5MjgiLCJ0b2tlbl91c2UiOiJhY2Nlc3MiLCJzY29wZSI6InBob25lIG9wZW5pZCIsImF1dGhfdGltZSI6MTU1NzgwMjgxNCwiaXNzIjoiaHR0cHM6XC9cL2NvZ25pdG8taWRwLnVzLWVhc3QtMS5hbWF6b25hd3MuY29tXC91cy1lYXN0LTFfUlVhbEY3MlBOIiwiZXhwIjoxNTU3ODg4NDgwLCJpYXQiOjE1NTc4ODQ4ODAsInZlcnNpb24iOjIsImp0aSI6ImE5NDdhYzIwLTcwMzItNDVkMi1iNGMyLTExZTE2NWM0MWMxYyIsImNsaWVudF9pZCI6Ijd0dnA1cXVnNzdsdWxua2JyazE2Z2p0bjgwIiwidXNlcm5hbWUiOiJiN2E0NmE4Mi04MTc3LTQwNDMtOWZjOS04NjA5ZGU1MTI5MjgifQ.JcICrcRespXj660g7oYqmlIXCXWUHsD7e6pDnbPUGFBtXXBR0A6hNiokLdbDnbdLmVOeWn-O_BCU7g0JXBr-3PqSDHArHPxiXt6LXlgXR_BUYDsEvZu2f6C2sZbUPa7kJ_tEtevrIJKazB1jujySoxJ7rywE57bCEvk_6bvXXZYB2nyg2AbvhRbhLywsUyf87qnobQaPzuBSuxAz_CMzLAPa5bWKotzXxxQQgqrwvKeqNpJQRVwpWweB44aaHrMDtE2GWsNC0qViQe1OluG0nZMHqzjN0yr1VY8bW-WA9Uav9W6hRlJ0JFJd_nvthRqMD_QAR_cCK-kXF3UYwqy-hA" 13 | } 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /lambda/functions/cfProvider-uuid/index.js: -------------------------------------------------------------------------------- 1 | const uuidv4 = require('uuid/v4'); 2 | const cfnResponse = require('cfn-response-async'); 3 | 4 | /* 5 | This function is meant to act as a custom CloudFormation resource which 6 | generates a random v4 UUID. This could be used to generate an external ID 7 | for a Cognito SMS configuration and corresponding IAM role's trust policy, 8 | or anything else that needs a UUID. 9 | */ 10 | exports.handler = async (event, context) => { 11 | 12 | console.log("REQUEST RECEIVED:\n" + JSON.stringify(event)); 13 | 14 | let responseData; 15 | 16 | try { 17 | if (event.RequestType == "Delete") { 18 | // There's nothing to delete, so just send a success response 19 | return await cfnResponse.send(event, context, "SUCCESS"); 20 | } 21 | else if (event.RequestType === "Create" || event.RequestType === "Update") { 22 | let uuid = uuidv4(); 23 | let physicalResourceId = uuid; 24 | responseData = { uuid: uuid }; 25 | return await cfnResponse.send(event, context, "SUCCESS", responseData, physicalResourceId); 26 | } 27 | } 28 | catch (err) { 29 | responseData = { Error: err }; 30 | console.log(err); 31 | return await cfnResponse.send(event, context, "FAILED", responseData); 32 | } 33 | 34 | }; -------------------------------------------------------------------------------- /docs/04-esp32-first-time-setup.md: -------------------------------------------------------------------------------- 1 | # First-time Setup for ESP32 2 | 3 | Before we dive into anything specific to this project, let's keep it simple and make sure you have basic connectivity to your ESP32 and can flash the Mongoose OS demo application: 4 | 5 | 1. Follow steps 1 through 3 in the [Download and install the MOS tool](https://mongoose-os.com/docs/mongoose-os/quickstart/setup.md) guide. 6 | 7 | 2. Before we do anything complicated, follow steps 4 through 7 in the [MOS tool guide](https://mongoose-os.com/docs/mongoose-os/quickstart/setup.md) above to confirm you can successfully connect to and flash your ESP32. 8 | 9 | 3. If you've successfully flashed your ESP32 and confirmed its sending messages to the MOS console on your computer, you're ready to proceed! 10 | 11 | ## Troubleshooting 12 | 13 | **Note** - if using a Mac, some additional drivers / troubleshooting may be needed to things working. Pay careful attention to the guide. 14 | 15 | **Note** - After many hours of troubleshooting, I learned that not all USB cables are created equally :(. Some can only carry power to the ESP32, while others enable data connectivity. You will need the latter. [See this post](https://electronics.stackexchange.com/questions/140225/how-can-i-tell-charge-only-usb-cables-from-usb-data-cables) for additional information. 16 | 17 | 18 | ## Next Ste ps 19 | 20 | Proceed to [Step 5 - Build Your ESP32 Thermostat](./05-build-esp32-thermostat.md). -------------------------------------------------------------------------------- /lambda/functions/cfProvider-iotEndpoint/index.js: -------------------------------------------------------------------------------- 1 | const aws = require("aws-sdk"); 2 | const iot = new aws.Iot(); 3 | const cfnResponse = require('cfn-response-async'); 4 | 5 | /* 6 | This function is meant to act as a custom CloudFormation resource which 7 | generates obtains your AWS account's particular IoT Endpoint and stores it 8 | as a retrievable property of the CloudFormation resource. 9 | */ 10 | exports.handler = async (event, context) => { 11 | 12 | console.log("REQUEST RECEIVED:\n" + JSON.stringify(event)); 13 | 14 | let responseData; 15 | 16 | try { 17 | if (event.RequestType == "Delete") { 18 | // There's nothing to delete, so just send a success response 19 | return await cfnResponse.send(event, context, "SUCCESS"); 20 | } 21 | else if (event.RequestType === "Create" || event.RequestType === "Update") { 22 | var response = await iot.describeEndpoint({}).promise(); 23 | var endpointAddress = response.endpointAddress; 24 | var physicalResourceId = endpointAddress; 25 | responseData = { IotEndpointAddress: endpointAddress }; 26 | return await cfnResponse.send(event, context, "SUCCESS", responseData, physicalResourceId); 27 | } 28 | } 29 | catch (err) { 30 | responseData = { Error: err }; 31 | console.log(err); 32 | return await cfnResponse.send(event, context, "FAILED", responseData); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /lambda/functions/ddb-createThingType/index.js: -------------------------------------------------------------------------------- 1 | var AWS = require("aws-sdk"); 2 | var dynamodb = new AWS.DynamoDB.DocumentClient(); 3 | 4 | exports.handler = async (event) => { 5 | 6 | try { 7 | console.log(`Received event:\n${JSON.stringify(event)}`); 8 | await createDeviceType(event); 9 | const response = { 10 | statusCode: 200, 11 | message: `Device type ${event.deviceType} created/updated.` 12 | }; 13 | return response; 14 | } catch (error) { 15 | console.log(`Error:\n${error}`); 16 | throw error; 17 | } 18 | }; 19 | 20 | 21 | async function createDeviceType(event) { 22 | 23 | var hashId = "deviceType_" + event.deviceType; 24 | var sortId = "metadata"; 25 | var tableName = process.env.DEVICE_TABLE; 26 | 27 | var params = { 28 | Item: { 29 | hashId: hashId, 30 | sortId: sortId, 31 | deviceType: event.deviceType, 32 | manufacturerName: event.manufacturerName, 33 | modelName: event.modelName, 34 | friendlyName: event.friendlyName, 35 | description: event.description, 36 | displayCategories: event.displayCategories, 37 | capabilities: event.capabilities 38 | }, 39 | ReturnConsumedCapacity: "TOTAL", 40 | TableName: tableName 41 | }; 42 | var response = await dynamodb.put(params).promise(); 43 | console.log('PutItem response:\n' + JSON.stringify(response, null, 2)); 44 | } -------------------------------------------------------------------------------- /lambda/layers/cfn-response/nodejs/node_modules/cfn-response-sync/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright 2015 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | This file is licensed to you under the AWS Customer Agreement (the "License"). 3 | You may not use this file except in compliance with the License. 4 | A copy of the License is located at http://aws.amazon.com/agreement/ . 5 | This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. 6 | See the License for the specific language governing permissions and limitations under the License. */ 7 | 8 | exports.send = function(event, context, responseStatus, responseData, physicalResourceId, noEcho) { 9 | 10 | var responseBody = JSON.stringify({ 11 | Status: responseStatus, 12 | Reason: "See the details in CloudWatch Log Stream: " + context.logStreamName, 13 | PhysicalResourceId: physicalResourceId || context.logStreamName, 14 | StackId: event.StackId, 15 | RequestId: event.RequestId, 16 | LogicalResourceId: event.LogicalResourceId, 17 | NoEcho: noEcho || false, 18 | Data: responseData 19 | }); 20 | 21 | console.log("Response body:\n", responseBody); 22 | 23 | var https = require("https"); 24 | var url = require("url"); 25 | 26 | 27 | var parsedUrl = url.parse(event.ResponseURL); 28 | var options = { 29 | hostname: parsedUrl.hostname, 30 | port: 443, 31 | path: parsedUrl.path, 32 | method: "PUT", 33 | headers: { 34 | "content-type": "", 35 | "content-length": responseBody.length 36 | } 37 | }; 38 | 39 | var request = https.request(options, function(response) { 40 | console.log("Status code: " + response.statusCode); 41 | console.log("Status message: " + response.statusMessage); 42 | context.done(); 43 | }); 44 | 45 | request.on("error", function(error) { 46 | console.log("send(..) failed executing https.request(..): " + error); 47 | context.done(); 48 | }); 49 | 50 | request.write(responseBody); 51 | request.end(); 52 | } -------------------------------------------------------------------------------- /lambda/layers/cfn-response/nodejs/node_modules/cfn-response-async/index.js: -------------------------------------------------------------------------------- 1 | /* Copyright 2015 Amazon Web Services, Inc. or its affiliates. All Rights Reserved. 2 | This file is licensed to you under the AWS Customer Agreement (the "License"). 3 | You may not use this file except in compliance with the License. 4 | A copy of the License is located at http://aws.amazon.com/agreement/. 5 | This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. 6 | See the License for the specific language governing permissions and limitations under the License. */ 7 | 8 | exports.send = function(event, context, responseStatus, responseData, physicalResourceId, noEcho) { 9 | 10 | return new Promise((resolve, reject) => { 11 | 12 | var responseBody = JSON.stringify({ 13 | Status: responseStatus, 14 | Reason: "See the details in CloudWatch Log Stream: " + context.logStreamName, 15 | PhysicalResourceId: physicalResourceId || context.logStreamName, 16 | StackId: event.StackId, 17 | RequestId: event.RequestId, 18 | LogicalResourceId: event.LogicalResourceId, 19 | NoEcho: noEcho || false, 20 | Data: responseData 21 | }); 22 | 23 | console.log("Response body:\n", responseBody); 24 | 25 | var https = require("https"); 26 | var url = require("url"); 27 | 28 | var parsedUrl = url.parse(event.ResponseURL); 29 | var options = { 30 | hostname: parsedUrl.hostname, 31 | port: 443, 32 | path: parsedUrl.path, 33 | method: "PUT", 34 | headers: { 35 | "content-type": "", 36 | "content-length": responseBody.length 37 | } 38 | }; 39 | 40 | var request = https.request(options, function(response) { 41 | console.log("Status code: " + response.statusCode); 42 | console.log("Status message: " + response.statusMessage); 43 | resolve(context.done()); 44 | }); 45 | 46 | request.on("error", function(error) { 47 | console.log("send(..) failed executing https.request(..): " + error); 48 | reject(context.done(error)); 49 | }); 50 | 51 | request.write(responseBody); 52 | request.end(); 53 | }) 54 | 55 | } -------------------------------------------------------------------------------- /lambda/functions/cfProvider-cognitoClientConfiguration/index.js: -------------------------------------------------------------------------------- 1 | const aws = require("aws-sdk"); 2 | const cognitoIdentityServiceProvider = new aws.CognitoIdentityServiceProvider(); 3 | const cfnResponse = require('cfn-response-async'); 4 | 5 | /* 6 | This function acts as a custom CloudFormation resource and therefore must 7 | handle one of three request types: Create, Update, or Delete. 8 | */ 9 | exports.handler = async (event, context) => { 10 | 11 | console.log("REQUEST RECEIVED:\n" + JSON.stringify(event)); 12 | 13 | let responseData; 14 | 15 | try { 16 | if (event.RequestType === 'Create' || event.RequestType === 'Update') { 17 | 18 | await cognitoIdentityServiceProvider.updateUserPoolClient({ 19 | UserPoolId: event.ResourceProperties.UserPoolId, 20 | ClientId: event.ResourceProperties.UserPoolClientId, 21 | SupportedIdentityProviders: event.ResourceProperties.SupportedIdentityProviders, 22 | CallbackURLs: [event.ResourceProperties.CallbackURL], 23 | LogoutURLs: [event.ResourceProperties.LogoutURL], 24 | AllowedOAuthFlowsUserPoolClient: (event.ResourceProperties.AllowedOAuthFlowsUserPoolClient == 'true'), 25 | AllowedOAuthFlows: event.ResourceProperties.AllowedOAuthFlows, 26 | AllowedOAuthScopes: event.ResourceProperties.AllowedOAuthScopes 27 | }).promise(); 28 | 29 | responseData = { 30 | UserPoolId: event.ResourceProperties.UserPoolId, 31 | ClientId: event.ResourceProperties.UserPoolClientId 32 | }; 33 | 34 | let physicalResourceId = event.LogicalResourceId; 35 | 36 | return await cfnResponse.send(event, context, "SUCCESS", responseData, physicalResourceId); 37 | } 38 | else if (event.RequestType === 'Delete') { 39 | // For now, we don't actually delete anything... but maybe we should reset values to default??? 40 | return await cfnResponse.send(event, context, "SUCCESS"); 41 | } 42 | } 43 | catch (err) { 44 | responseData = { Error: err }; 45 | console.log(err); 46 | return await cfnResponse.send(event, context, "FAILED", responseData); 47 | } 48 | } -------------------------------------------------------------------------------- /lambda/functions/cognito-verifyAuthToken/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cfprovider-uuid", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "base64url": { 8 | "version": "3.0.1", 9 | "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", 10 | "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" 11 | }, 12 | "es6-promise": { 13 | "version": "4.2.8", 14 | "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", 15 | "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" 16 | }, 17 | "https": { 18 | "version": "1.0.0", 19 | "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", 20 | "integrity": "sha1-PDfHrhqO65ZpBKKtHpdaGUt+06Q=" 21 | }, 22 | "lodash": { 23 | "version": "4.17.15", 24 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", 25 | "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" 26 | }, 27 | "long": { 28 | "version": "4.0.0", 29 | "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", 30 | "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" 31 | }, 32 | "node-forge": { 33 | "version": "0.8.5", 34 | "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.8.5.tgz", 35 | "integrity": "sha512-vFMQIWt+J/7FLNyKouZ9TazT74PRV3wgv9UT4cRjC8BffxFbKXkgIWR42URCPSnHm/QDz6BOlb2Q0U4+VQT67Q==" 36 | }, 37 | "node-jose": { 38 | "version": "1.1.3", 39 | "resolved": "https://registry.npmjs.org/node-jose/-/node-jose-1.1.3.tgz", 40 | "integrity": "sha512-kupfi4uGWhRjnOmtie2T64cLge5a1TZyalEa8uWWWBgtKBcu41A4IGKpI9twZAxRnmviamEUQRK7LSyfFb2w8A==", 41 | "requires": { 42 | "base64url": "^3.0.1", 43 | "es6-promise": "^4.2.6", 44 | "lodash": "^4.17.11", 45 | "long": "^4.0.0", 46 | "node-forge": "^0.8.1", 47 | "uuid": "^3.3.2" 48 | } 49 | }, 50 | "uuid": { 51 | "version": "3.3.3", 52 | "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", 53 | "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /esp32/mos.yml: -------------------------------------------------------------------------------- 1 | author: mongoose-os 2 | description: AWS IoT Alexa Smart Home Demo 3 | # arch: PLATFORM 4 | version: 1.0 5 | manifest_version: 2017-05-18 6 | libs_version: ${mos.version} 7 | modules_version: ${mos.version} 8 | mongoose_os_version: ${mos.version} 9 | platform: esp32 10 | 11 | config_schema: 12 | - ["mqtt.enable", true] 13 | 14 | # the values below are unique to your AWS account / IoT thing and you need 15 | # to update them accordingly. The ssl_cert and ssl_key must be downloaded from 16 | # AWS IoT and stored in the fs/ directory to be included within the build: 17 | - ["mqtt.ssl_cert", "67e48f5611-certificate.pem.crt"] 18 | - ["mqtt.ssl_key", "67e48f5611-private.pem.key"] 19 | - ["mqtt.server", "a2mvse6841elo7-ats.iot.us-east-1.amazonaws.com:8883"] 20 | - ["aws.thing_name", "alexa-smart-home-demo-SmartHomeThing-1LW418RIHGL2X"] 21 | 22 | # Number of seconds without connectivity before AWS IoT determines device has 23 | # disconnected; this has a direct impact on how long it takes for the LWT to 24 | # trigger: 25 | - ["mqtt.keep_alive", 5] 26 | 27 | # Mongoose OS comes with a default ca.pem root CA that will work for AWS. 28 | - ["mqtt.ssl_ca_cert", "ca.pem"] 29 | 30 | # If you want to configure WiFi over Bluetooth, you need this enabled: 31 | - ["bt.enable", true] 32 | 33 | # If you want to keep Bluetooth on instead of it auto-disabling after WiFi, 34 | # set keep_enabled to true. Note, this is not secure as no auth is enforced: 35 | - ["bt.keep_enabled", true] 36 | 37 | # Enable WiFi 38 | - ["wifi.ap.enable", false] 39 | - ["wifi.sta.enable", true] 40 | 41 | # Configure WiFi below, or easily do it wirelessly over Bluetooth from your laptop at: 42 | # https://mongoose-os.com/ble/#/ 43 | # - ["wifi.sta.ssid", "YOUR WIFI NAME"] 44 | # - ["wifi.sta.pass", "YOUR WIFI PASSWORD"] 45 | 46 | # Hide debugging info. If you want to see more, set this to 3 or remove from build 47 | - ["debug.level", 1] 48 | 49 | tags: 50 | - js 51 | 52 | filesystem: 53 | - fs 54 | 55 | libs: 56 | - origin: https://github.com/mongoose-os-libs/boards 57 | - origin: https://github.com/mongoose-os-libs/js-demo-bundle 58 | - origin: https://github.com/mongoose-os-libs/dht 59 | - origin: https://github.com/mongoose-os-libs/mjs 60 | - origin: https://github.com/mongoose-os-libs/rpc-service-config 61 | - origin: https://github.com/mongoose-os-libs/rpc-service-fs 62 | - origin: https://github.com/mongoose-os-libs/rpc-uart 63 | - origin: https://github.com/mongoose-os-libs/wifi -------------------------------------------------------------------------------- /docs/06-test-skill-with-esp32.md: -------------------------------------------------------------------------------- 1 | 2 | # Test Your Alexa Smart Home Thermostat Skill with an ESP32 3 | 4 | The CloudFormation template in [template.yaml](./../template.yaml) has a `UsePhysicalDevice` parameter that has a default value of false. If this value is false, the Alexa Lambda function will not actually interact with IoT Core and will instead just send back mock responses to the Alexa service. Now that we have a functioning ESP32 tied to our IoT Thing in IoT Core, we can modify the `UsePhysicalDevice` parameter to interact with our real device: 5 | 6 | 1. Navigate to the `alexa-smart-home-demo` CloudFormation stack, choose **Update stack**, and modify the parameter **UsePhysicalDevice** to have a value of `true`; deploy your updated stack. When this parameter is `false`, the Lambda function invoked by Alexa will directly update the AWS IoT thing's reported state to match the requested state from Alexa; when set to `true`, the Lambda will only update the desired state because our assumption is that there is a physical device that will receive the state change, physically update state, and report back the new state itself. 7 | 8 | 2. Within AWS IoT, open the device shadow of your smart home's AWS Thing in the device registry and view the device shadow. If your device is connected, it may look something like this: 9 | 10 |

11 | 12 |

13 | 14 | 3. If your ESP32 is connected (white LED on), you should see the `uptime` value incrementing in the reported state. If you blow "hot" air directly over the DHT11 for a few seconds, you should eventually see the reported `temperature` and `humidity` change. 15 | 16 | 4. Press the thermostat mode button on the ESP32 and notice that the mode (via red and blue LEDs) changes on the device and that the reported `thermostatMode` toggles between `OFF`, `COOL`, and `HEAT`. 17 | 18 | 5. Manually edit the IoT device shadow by adding the following section to the shadow document: 19 | 20 | Note - replace "COOL" with either "HEAT" or "OFF", if your device is already in COOL mode 21 | 22 | ```json 23 | "desired": { 24 | "thermostatMode": "COOL" 25 | } 26 | ``` 27 | 28 | Save the changes and notice within the MOS terminal that the device received a message on the shadow/update MQTT topic that there is a difference between the device's reported and desired state. Note that the device then updates the device's reported state and publishes this new state to the AWS IoT shadow. 29 | 30 | 6. Now, the fun part! Again, talk to your Alexa device but this time see how it interacts with the device to change modes, get the current temperature, or update the target setpoint: 31 | 32 | * "Alexa, set thermostat to COOL" 33 | * "Alexa, set thermostat to OFF" 34 | * "Alexa, what is the thermostat temperature?" 35 | * "Alexa, set the thermostat to 65 degrees" 36 | * "Aelxa, increase the thermostat temperature" 37 | -------------------------------------------------------------------------------- /lambda/functions/iot-createThing/index.js: -------------------------------------------------------------------------------- 1 | var AWS = require("aws-sdk"); 2 | var iot = new AWS.Iot(); 3 | 4 | exports.handler = async (event) => { 5 | 6 | try { 7 | console.log(`Received event:\n${JSON.stringify(event)}`); 8 | 9 | var thing = event.thing; 10 | thing.thingName = thing.deviceType + '_' + thing.serialNumber; 11 | 12 | var thingExistsResponse = await thingExists(thing.thingName); 13 | 14 | if (thingExistsResponse.doesExist) { 15 | thing.version = thingExistsResponse.version; 16 | await updateThing(thing); 17 | const response = { 18 | statusCode: 200, 19 | message: 'Existing thing updated.' 20 | }; 21 | return response; 22 | } else { 23 | var createThingResponse = await createThing(thing); 24 | const response = { 25 | statusCode: 200, 26 | message: 'New thing created.', 27 | thingName: createThingResponse.thingName, 28 | thingArn: createThingResponse.thingArn, 29 | thingId: createThingResponse.thingId 30 | }; 31 | return response; 32 | } 33 | } catch (error) { 34 | console.log(`Error:\n${error}`); 35 | throw error; 36 | } 37 | }; 38 | 39 | async function updateThing(thing) { 40 | console.log("Updating thing..."); 41 | var params = { 42 | thingName: thing.thingName, 43 | attributePayload: { 44 | attributes: { 45 | 'serialNumber': thing.serialNumber, 46 | 'deviceType': thing.deviceType 47 | }, 48 | merge: false 49 | }, 50 | expectedVersion: thing.version 51 | }; 52 | 53 | var updateThingResponse = await iot.updateThing(params).promise(); 54 | console.log(`Thing updated!`); 55 | return updateThingResponse; 56 | } 57 | 58 | async function thingExists(thingName) { 59 | 60 | try { 61 | var params = { 62 | thingName: thingName 63 | }; 64 | var describeThingResponse = await iot.describeThing(params).promise(); 65 | console.log(`Thing ${thingName} already exists...`); 66 | return { 67 | doesExist: true, 68 | version: describeThingResponse.version 69 | }; 70 | } catch (err) { 71 | 72 | console.log(`Error: ${err}`); 73 | return { 74 | doesExist: false 75 | }; 76 | } 77 | 78 | } 79 | 80 | 81 | async function createThing(thing) { 82 | 83 | var params = { 84 | thingName: thing.thingName, 85 | attributePayload: { 86 | attributes: { 87 | 'serialNumber': thing.serialNumber, 88 | 'deviceType': thing.deviceType 89 | } 90 | } 91 | }; 92 | console.log('Calling iot.createThing()...'); 93 | var createThingResponse = await iot.createThing(params).promise(); 94 | console.log(`Thing created:\n${JSON.stringify(createThingResponse, null, 2)}`); 95 | return createThingResponse; 96 | } -------------------------------------------------------------------------------- /docs/03-test-skill-without-device.md: -------------------------------------------------------------------------------- 1 | 2 | # Test Your Alexa Skill (without a device) 3 | 4 | Note - at this stage, we've created the backend but there is no physical device. Therefore, we must hard-code a **reported state** into our AWS IoT thing's shadow to make it look like a physical device is connected and reporting state. When the Alexa service invokes our Lambda to ask about current state (e.g. temperature, current heating or cooling mode, etc.), our Lambda will read from this shadow. 5 | 6 | 1. [Click here to open the AWS IoT Things window](https://us-east-1.console.aws.amazon.com/iot/home#/thinghub), and click thing with a name like `alexa-smart-home-demo-SmartHomeThing-XXXXXXXXXXX`, click **Shadow** on the left nav bar, click **Edit**, paste the info below, and click **Save**: 7 | 8 | ```json 9 | { 10 | "reported": { 11 | "temperature": { 12 | "scale": "FAHRENHEIT", 13 | "value": 77 14 | }, 15 | "thermostatMode": "COOL", 16 | "targetSetpoint": { 17 | "value": 60, 18 | "scale": "FAHRENHEIT" 19 | }, 20 | "connectivity": "OK", 21 | "deviceType": "AlexaSmartHomeDemo" 22 | } 23 | } 24 | ``` 25 | 26 | 2. Talk to your Alexa device (an Echo, Alexa mobile app, etc.) to test the following: 27 | 28 | * "Alexa, set thermostat to COOL" 29 | * "Alexa, set thermostat to OFF" 30 | * "Alexa, what is the thermostat temperature?" 31 | * "Alexa, set the thermostat to 65 degrees" 32 | * "Alexa, set increase the thermostat temperature" 33 | 34 | If you ask Alexa for information, your skill's Lambda function will read the the info (e.g. temperature) from the device's last reported state in the IoT device shadow. If you instruct Alexa to adjust your thermostat, the Lambda function will update the device's desired state in the device shadow. 35 | 36 | 3. You can also use the Alexa mobile app's built-in thermostat interface to view and interact with your device: 37 | 38 | * From the Devices, screen, tap **All Devices**: 39 | 40 | 41 | 42 | * You should see a device named **Smart Thermostat**: 43 | 44 | 45 | 46 | * Note that you may optionally edit the thermostat's name: 47 | 48 | 49 | 50 | * This is what you will see if the device is connected (`{state: { reported: { "connectivity": "OK" } } }`) but thermostat mode is off (`{state: { reported: { "thermostatMode": "OFF" } } }`): 51 | 52 | 53 | 54 | * This is what you will see if the device is connected and the thermostat mode is set to "COOL": 55 | 56 | 57 | 58 | * This is what you will see if the device is connected and the thermostat mode is set to "HEAT": 59 | 60 | 61 | 62 | 63 | ## Next Steps 64 | 65 | Proceed to [Step 4 - First Time Setup of Your ESP32](./04-esp32-first-time-setup.md). -------------------------------------------------------------------------------- /lambda/functions/alexa-skill/discoveryConfig.js: -------------------------------------------------------------------------------- 1 | /* 2 | Schema of discoveryConfig should be in the following format: 3 | modelNumber: 4 | softwareVersion: 5 | 6 | 7 | The modelNumber and softwareVersion should match the values of the IoT Thing 8 | attributes of the same name. 9 | */ 10 | 11 | const discoveryConfig = { 12 | 'smartThing-v1': { 13 | '1.00': { 14 | manufacturerName: 'SmartHome Products, Inc.', 15 | modelName: 'Model 001', 16 | friendlyName: 'Smart Device', 17 | description: 'My SmartHome Product!', 18 | displayCategories: [ 19 | 'OTHER', 20 | 'THERMOSTAT', 21 | 'TEMPERATURE_SENSOR' 22 | ], 23 | validRange: { 24 | minimumValue: { 25 | value: 60.0, 26 | scale: "FAHRENHEIT" 27 | }, 28 | maximumValue: { 29 | value: 90.0, 30 | scale: "FAHRENHEIT" 31 | } 32 | }, 33 | capabilities: [ 34 | { 35 | // Basic capability that should be included for all 36 | // Alexa Smart Home API discovery responses: 37 | type: 'AlexaInterface', 38 | interface: 'Alexa', 39 | version: '3' 40 | }, 41 | { 42 | type: "AlexaInterface", 43 | interface: "Alexa.EndpointHealth", 44 | "version":"3", 45 | properties: { 46 | supported: [ 47 | { 48 | name: "connectivity" 49 | } 50 | ], 51 | retrievable: true 52 | } 53 | }, 54 | { 55 | type: "AlexaInterface", 56 | interface: "Alexa.TemperatureSensor", 57 | version: "3", 58 | properties: { 59 | supported: [ 60 | { 61 | "name": "temperature" 62 | } 63 | ], 64 | retrievable: true 65 | } 66 | }, 67 | { 68 | type: 'AlexaInterface', 69 | interface: 'Alexa.ThermostatController', 70 | version: '3', 71 | properties: { 72 | supported: [ 73 | { 74 | name: 'targetSetpoint' 75 | }, 76 | { 77 | name: 'thermostatMode' 78 | }, 79 | { 80 | name: 'targetSetpointDelta' 81 | } 82 | ], 83 | retrievable: true 84 | }, 85 | configuration: { 86 | supportsScheduling: false, 87 | supportedModes: [ 88 | 'HEAT', 89 | 'COOL', 90 | 'OFF' 91 | ] 92 | } 93 | } 94 | ] 95 | } 96 | } 97 | }; 98 | 99 | module.exports = discoveryConfig; -------------------------------------------------------------------------------- /lambda/functions/cognito-verifyAuthToken/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | https://github.com/awslabs/aws-support-tools/blob/master/Cognito/decode-verify-jwt/decode-verify-jwt.js 3 | Copyright 2017-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file 5 | except in compliance with the License. A copy of the License is located at 6 | http://aws.amazon.com/apache2.0/ 7 | or in the "license" file accompanying this file. This file is distributed on an "AS IS" 8 | BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 9 | License for the specific language governing permissions and limitations under the License. 10 | */ 11 | 12 | // Idea for async version? https://www.tomas-dvorak.cz/posts/nodejs-request-without-dependencies/ 13 | 14 | var https = require('https'); 15 | var jose = require('node-jose'); 16 | 17 | var region = process.env.REGION; 18 | var userpool_id = process.env.USER_POOL_ID; 19 | var app_client_id = process.env.APP_CLIENT_ID; 20 | var keys_url = 'https://cognito-idp.' + region + '.amazonaws.com/' + userpool_id + '/.well-known/jwks.json'; 21 | 22 | exports.handler = (event, context, callback) => { 23 | 24 | var ignoreExpiredToken = false; 25 | if (event.hasOwnProperty('ignoreExpiredToken')) { 26 | if (event.ignoreExpiredToken === true) { 27 | console.log('event.ignoreExpiredToken = true.') 28 | ignoreExpiredToken = true; 29 | } 30 | } 31 | console.log('Keys url: ' + keys_url); 32 | var token = event.token; 33 | var sections = token.split('.'); 34 | // get the kid from the headers prior to verification 35 | var header = jose.util.base64url.decode(sections[0]); 36 | header = JSON.parse(header); 37 | console.log('Token header:\n' + JSON.stringify(header, null, 2)); 38 | var kid = header.kid; 39 | // download the public keys 40 | https.get(keys_url, function(response) { 41 | if (response.statusCode == 200) { 42 | response.on('data', function(body) { 43 | var keys = JSON.parse(body)['keys']; 44 | // search for the kid in the downloaded public keys 45 | var key_index = -1; 46 | for (var i=0; i < keys.length; i++) { 47 | if (kid == keys[i].kid) { 48 | key_index = i; 49 | break; 50 | } 51 | } 52 | if (key_index == -1) { 53 | console.log('Public key not found in jwks.json'); 54 | callback('Public key not found in jwks.json'); 55 | } 56 | // construct the public key 57 | jose.JWK.asKey(keys[key_index]). 58 | then(function(result) { 59 | // verify the signature 60 | jose.JWS.createVerify(result). 61 | verify(token). 62 | then(function(result) { 63 | // now we can use the claims 64 | var claims = JSON.parse(result.payload); 65 | // additionally we can verify the token expiration 66 | var current_ts = Math.floor(new Date() / 1000); 67 | if (current_ts > claims.exp && ignoreExpiredToken === false) { 68 | callback('Token is expired'); 69 | } 70 | // and the Audience (use claims.client_id if verifying an access token) 71 | //if (claims.aud != app_client_id) { 72 | if (claims.client_id != app_client_id) { 73 | callback('Token was not issued for this audience'); 74 | } 75 | callback(null, claims); 76 | }). 77 | catch(function() { 78 | callback('Signature verification failed'); 79 | }); 80 | }); 81 | }); 82 | } 83 | }); 84 | } -------------------------------------------------------------------------------- /lambda/functions/ddb-getUserThings/index.js: -------------------------------------------------------------------------------- 1 | const AWS = require("aws-sdk"); 2 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 3 | 4 | const DEVICE_TABLE_NAME = process.env.DEVICE_TABLE; 5 | 6 | exports.handler = async (event, context) => { 7 | 8 | try { 9 | console.log(`Received event:\n${JSON.stringify(event)}`); 10 | var deviceList = await getDevicesByUser(event.userId); 11 | const response = { 12 | deviceList: deviceList 13 | }; 14 | return response; 15 | } catch (error) { 16 | console.log(`Error:\n${error}`); 17 | throw error; 18 | } 19 | }; 20 | 21 | async function getDevicesByUser(userId) { 22 | 23 | // First, get the IoT Thing Names associated to our user. 24 | var params = { 25 | ExpressionAttributeValues: { 26 | ":hashId": 'userId_' + userId, 27 | ":sortPrefix": 'thingName_' 28 | }, 29 | //ProjectionAttributes: "hashId, sortId", 30 | KeyConditionExpression: "hashId = :hashId and begins_with(sortId, :sortPrefix)", 31 | TableName: DEVICE_TABLE_NAME 32 | }; 33 | var query_response = await dynamodb.query(params).promise(); 34 | var userDeviceAssociations = query_response.Items; 35 | console.log('User devices associations:\n' + JSON.stringify(userDeviceAssociations, null, 2)); 36 | 37 | // Each device name has the device type as part of the name. We parse out 38 | // a list of unique device types from the list of device names into an 39 | // object map, below. Initially, each key in the object is a deviceType 40 | // equal to {}. By having a unique list of device types, we can do a single 41 | // BatchGetItem() call to DDB to get all of the device type metadata at once. 42 | // If we instead looped through each device once at a time to call DDB 43 | // for the corresponding metadata, we would instead make multiple calls 44 | // and potentially call to get the same metadata twice. 45 | var deviceTypeList = getDeviceTypeListFromUserDeviceAssociations(userDeviceAssociations); 46 | console.log("Device type list:\n" + JSON.stringify(deviceTypeList, null, 2)); 47 | 48 | // Now that we have the list of device types, we will query DDB for each 49 | // device type's metadata and then use that to populate our object map: 50 | var deviceTypeMetadata = await getDeviceTypeMetadata(deviceTypeList); 51 | console.log('deviceTypeMetadata:\n' + JSON.stringify(deviceTypeMetadata, null, 2)); 52 | 53 | // Combine the device associations with the metadata to get our final device list: 54 | for (i in userDeviceAssociations) { 55 | var deviceType = userDeviceAssociations[i].thingType; 56 | console.log(`i=${i} and deviceType=${deviceType}`); 57 | console.log('Data to merge: ' + JSON.stringify(deviceTypeMetadata[deviceType], null, 2)); 58 | Object.assign(userDeviceAssociations[i], deviceTypeMetadata[deviceType]); 59 | 60 | } 61 | console.log('Updated user device associations:\n' + JSON.stringify(userDeviceAssociations, null, 2)); 62 | 63 | return userDeviceAssociations; 64 | } 65 | 66 | function getDeviceTypeListFromUserDeviceAssociations(userDeviceAssociations) { 67 | 68 | var deviceTypeSet = new Set(); 69 | userDeviceAssociations.forEach(userDeviceAssociation => { 70 | deviceTypeSet.add(userDeviceAssociation.thingType); 71 | }); 72 | return Array.from(deviceTypeSet); 73 | } 74 | 75 | async function getDeviceTypeMetadata(deviceTypeList) { 76 | 77 | /* 78 | deviceTypeList = ['type1', 'type2', ...] 79 | */ 80 | 81 | var deviceTypeMetadata = {}; 82 | 83 | if (deviceTypeList.length === 0) { 84 | return deviceTypeMetadata; 85 | } else { 86 | var requestKeys = []; 87 | for (index in deviceTypeList) { 88 | var deviceTypeName = deviceTypeList[index]; 89 | var requestKey = { 90 | hashId: 'deviceType_' + deviceTypeName, 91 | sortId: 'metadata' 92 | } 93 | requestKeys.push(requestKey); 94 | } 95 | var requestItems = {}; 96 | requestItems[DEVICE_TABLE_NAME] = { 97 | Keys: requestKeys 98 | } 99 | var params = { 100 | RequestItems: requestItems 101 | }; 102 | console.log('BatchGet params are:\n' + JSON.stringify(params, null, 2)); 103 | var batchGetResponse = await dynamodb.batchGet(params).promise(); 104 | var metadataResponses = batchGetResponse.Responses[DEVICE_TABLE_NAME]; 105 | 106 | for (var index in metadataResponses) { 107 | var metadata = metadataResponses[index]; 108 | deviceTypeMetadata[metadata.deviceType] = metadata; 109 | } 110 | return deviceTypeMetadata; 111 | } 112 | } 113 | 114 | function isEmpty(obj) { 115 | for(var key in obj) { 116 | if(obj.hasOwnProperty(key)) 117 | return false; 118 | } 119 | return true; 120 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | packaged.yaml 2 | alexa 3 | node_modules/ 4 | mydeploy.sh 5 | backup.yaml 6 | .eslintrc.js 7 | web-app/ 8 | .vscode 9 | 10 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 11 | 12 | ### Linux ### 13 | *~ 14 | 15 | # temporary files which can be created if a process still has a handle open of a deleted file 16 | .fuse_hidden* 17 | 18 | # KDE directory preferences 19 | .directory 20 | 21 | # Linux trash folder which might appear on any partition or disk 22 | .Trash-* 23 | 24 | # .nfs files are created when an open file is removed but is still being accessed 25 | .nfs* 26 | 27 | ### OSX ### 28 | *.DS_Store 29 | .AppleDouble 30 | .LSOverride 31 | 32 | # Icon must end with two \r 33 | Icon 34 | 35 | # Thumbnails 36 | ._* 37 | 38 | # Files that might appear in the root of a volume 39 | .DocumentRevisions-V100 40 | .fseventsd 41 | .Spotlight-V100 42 | .TemporaryItems 43 | .Trashes 44 | .VolumeIcon.icns 45 | .com.apple.timemachine.donotpresent 46 | 47 | # Directories potentially created on remote AFP share 48 | .AppleDB 49 | .AppleDesktop 50 | Network Trash Folder 51 | Temporary Items 52 | .apdisk 53 | 54 | ### PyCharm ### 55 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 56 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 57 | 58 | # User-specific stuff: 59 | .idea/**/workspace.xml 60 | .idea/**/tasks.xml 61 | .idea/dictionaries 62 | 63 | # Sensitive or high-churn files: 64 | .idea/**/dataSources/ 65 | .idea/**/dataSources.ids 66 | .idea/**/dataSources.xml 67 | .idea/**/dataSources.local.xml 68 | .idea/**/sqlDataSources.xml 69 | .idea/**/dynamic.xml 70 | .idea/**/uiDesigner.xml 71 | 72 | # Gradle: 73 | .idea/**/gradle.xml 74 | .idea/**/libraries 75 | 76 | # CMake 77 | cmake-build-debug/ 78 | 79 | # Mongo Explorer plugin: 80 | .idea/**/mongoSettings.xml 81 | 82 | ## File-based project format: 83 | *.iws 84 | 85 | ## Plugin-specific files: 86 | 87 | # IntelliJ 88 | /out/ 89 | 90 | # mpeltonen/sbt-idea plugin 91 | .idea_modules/ 92 | 93 | # JIRA plugin 94 | atlassian-ide-plugin.xml 95 | 96 | # Cursive Clojure plugin 97 | .idea/replstate.xml 98 | 99 | # Ruby plugin and RubyMine 100 | /.rakeTasks 101 | 102 | # Crashlytics plugin (for Android Studio and IntelliJ) 103 | com_crashlytics_export_strings.xml 104 | crashlytics.properties 105 | crashlytics-build.properties 106 | fabric.properties 107 | 108 | ### PyCharm Patch ### 109 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 110 | 111 | # *.iml 112 | # modules.xml 113 | # .idea/misc.xml 114 | # *.ipr 115 | 116 | # Sonarlint plugin 117 | .idea/sonarlint 118 | 119 | ### Python ### 120 | # Byte-compiled / optimized / DLL files 121 | __pycache__/ 122 | *.py[cod] 123 | *$py.class 124 | 125 | # C extensions 126 | *.so 127 | 128 | # Distribution / packaging 129 | .Python 130 | build/ 131 | develop-eggs/ 132 | dist/ 133 | downloads/ 134 | eggs/ 135 | .eggs/ 136 | lib/ 137 | lib64/ 138 | parts/ 139 | sdist/ 140 | var/ 141 | wheels/ 142 | *.egg-info/ 143 | .installed.cfg 144 | *.egg 145 | 146 | # PyInstaller 147 | # Usually these files are written by a python script from a template 148 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 149 | *.manifest 150 | *.spec 151 | 152 | # Installer logs 153 | pip-log.txt 154 | pip-delete-this-directory.txt 155 | 156 | # Unit test / coverage reports 157 | htmlcov/ 158 | .tox/ 159 | .coverage 160 | .coverage.* 161 | .cache 162 | .pytest_cache/ 163 | nosetests.xml 164 | coverage.xml 165 | *.cover 166 | .hypothesis/ 167 | 168 | # Translations 169 | *.mo 170 | *.pot 171 | 172 | # Flask stuff: 173 | instance/ 174 | .webassets-cache 175 | 176 | # Scrapy stuff: 177 | .scrapy 178 | 179 | # Sphinx documentation 180 | docs/_build/ 181 | 182 | # PyBuilder 183 | target/ 184 | 185 | # Jupyter Notebook 186 | .ipynb_checkpoints 187 | 188 | # pyenv 189 | .python-version 190 | 191 | # celery beat schedule file 192 | celerybeat-schedule.* 193 | 194 | # SageMath parsed files 195 | *.sage.py 196 | 197 | # Environments 198 | .env 199 | .venv 200 | env/ 201 | venv/ 202 | ENV/ 203 | env.bak/ 204 | venv.bak/ 205 | 206 | # Spyder project settings 207 | .spyderproject 208 | .spyproject 209 | 210 | # Rope project settings 211 | .ropeproject 212 | 213 | # mkdocs documentation 214 | /site 215 | 216 | # mypy 217 | .mypy_cache/ 218 | 219 | ### VisualStudioCode ### 220 | .vscode/* 221 | !.vscode/settings.json 222 | !.vscode/tasks.json 223 | !.vscode/launch.json 224 | !.vscode/extensions.json 225 | .history 226 | 227 | ### Windows ### 228 | # Windows thumbnail cache files 229 | Thumbs.db 230 | ehthumbs.db 231 | ehthumbs_vista.db 232 | 233 | # Folder config file 234 | Desktop.ini 235 | 236 | # Recycle Bin used on file shares 237 | $RECYCLE.BIN/ 238 | 239 | # Windows Installer files 240 | *.cab 241 | *.msi 242 | *.msm 243 | *.msp 244 | 245 | # Windows shortcuts 246 | *.lnk 247 | 248 | # Build folder 249 | 250 | */build/* 251 | 252 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode 253 | 254 | 255 | !lambda/layers/cfn-response/nodejs/node_modules/cfn-response-async/index.js 256 | !lambda/layers/cfn-response/nodejs/node_modules/cfn-response-sync/index.js 257 | -------------------------------------------------------------------------------- /lambda/functions/cfProvider-cognitoUserPoolDomain/index.js: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/rosberglinhares/CloudFormationCognitoCustomResources/ 2 | const AWS = require('aws-sdk'); 3 | const cognitoIdentityServiceProvider = new AWS.CognitoIdentityServiceProvider(); 4 | const cfnResponse = require('cfn-response-async'); 5 | 6 | /* 7 | This custom CloudFormation resource provider crates or updates the Cognito-hosted 8 | domain name of a Cognito User Pool, which looks like this: 9 | https://.auth..amazoncognito.com/ 10 | 11 | This resource does not currently set/update a full custom domain or any other 12 | Cognito domain-related settings. 13 | */ 14 | exports.handler = async function (event, context) { 15 | 16 | console.log("REQUEST RECEIVED:\n" + JSON.stringify(event)); 17 | let responseData; 18 | 19 | try { 20 | 21 | if (event.RequestType === 'Create') { 22 | await cognitoIdentityServiceProvider.createUserPoolDomain( 23 | { 24 | UserPoolId: event.ResourceProperties.UserPoolId, 25 | Domain: event.ResourceProperties.DomainPrefix, 26 | } 27 | ).promise(); 28 | responseData = { 29 | DomainPrefix: event.ResourceProperties.DomainPrefix, 30 | FullDomain: getFullDomain(event.ResourceProperties.DomainPrefix) 31 | }; 32 | let physicalResourceId = event.LogicalResourceId; 33 | return await cfnResponse.send(event, context, "SUCCESS", responseData, physicalResourceId); 34 | } 35 | else if (event.RequestType === 'Update') { 36 | /* Even though an UpdateUserPoolDomain() API exists, it is used for changing the 37 | SSL certificate of a custom domain; it cannot change the actual domain name. 38 | 39 | Therefore, to perform an update, we have to first delete the old domain name 40 | and then create a new one in Cognito. This differs from the 'standard' 41 | CloudFormation replace behavior in that CloudFormation typically will first 42 | create the new resource and, only if the create is successful, will it delete 43 | the old resource. The Cognito domain is different in that a user pool cannot 44 | have two domains (e.g. an old one and a new one). So, we have to do things 45 | in reverse order. Upon update, we have to first delete the old domain 46 | and then create the new one. This is also why we *must* always return the 47 | same PhysicalResourceId in our response. If we were to return a different 48 | resource ID (e.g. an ID based on the domain value itself), then we would 49 | trigger CloudFormation's standard procedure of first issuing an Update command 50 | to our Lambda and, after update success, issuing a Delete command to the Lambda. 51 | Again, because a single user pool cannot have two domains, this would lead to 52 | unexpected behavior (e.g. a missing domain name upon the second delete). 53 | That is why our PhysicalResourceId is simply the LogicalResourceId from the 54 | CF template. The ID stays the same unless we delete the resource itself. 55 | */ 56 | // Delete the old domain 57 | await deleteUserPoolDomain(event.OldResourceProperties.DomainPrefix); 58 | 59 | // Create the new domain 60 | await cognitoIdentityServiceProvider.createUserPoolDomain({ 61 | UserPoolId: event.ResourceProperties.UserPoolId, 62 | Domain: event.ResourceProperties.DomainPrefix 63 | }).promise(); 64 | 65 | responseData = { 66 | DomainPrefix: event.ResourceProperties.DomainPrefix, 67 | FullDomain: getFullDomain(event.ResourceProperties.DomainPrefix) 68 | }; 69 | let physicalResourceId = event.LogicalResourceId; 70 | return await cfnResponse.send(event, context, "SUCCESS", responseData, physicalResourceId); 71 | } 72 | else { 73 | await deleteUserPoolDomain(event.ResourceProperties.DomainPrefix); 74 | return await cfnResponse.send(event, context, "SUCCESS"); 75 | } 76 | } catch (err) { 77 | responseData = { Error: err }; 78 | console.log(err); 79 | return await cfnResponse.send(event, context, "FAILED", responseData); 80 | } 81 | } 82 | 83 | 84 | function getFullDomain(domainPrefix) { 85 | var fullDomain = ( 86 | 'https://' 87 | + domainPrefix 88 | + '.auth.' 89 | + process.env.AWS_REGION 90 | + '.amazoncognito.com' 91 | ); 92 | return fullDomain; 93 | } 94 | 95 | /* 96 | Describe the domain to determine its UserPoolId. Note that this describe API 97 | will return the owning User Pool ID regardless of which AWS account owns the pool. 98 | The API call will return an error if the domain does not exist. 99 | */ 100 | async function deleteUserPoolDomain(domain) { 101 | var response = await cognitoIdentityServiceProvider.describeUserPoolDomain({ 102 | Domain: domain 103 | }).promise(); 104 | 105 | if (response.DomainDescription.Domain) { 106 | await cognitoIdentityServiceProvider.deleteUserPoolDomain({ 107 | UserPoolId: response.DomainDescription.UserPoolId, 108 | Domain: domain 109 | }).promise(); 110 | } 111 | } -------------------------------------------------------------------------------- /docs/05a-esp32-parts-list.md: -------------------------------------------------------------------------------- 1 | # ESP 32 Bill of Materials for Smart Thermostat Demo 2 | 3 | Rather than buying the parts below individually, I strong recommend buying an IoT component "starter kit", as these will give you most of what you need at a typically lower cost. The IoT kits often come with many other sensors that you can use with additional projects, too. 4 | 5 | The one area where you **might** want to get the exact same item below is the ESP32 development board. There are many ESP32 dev boards out there, and you can likely use any one of them, but the boards often have slightly different pin layouts and capabilities. If you use the same board as me, you can use the same wiring pattern that I did. This will make things easier but its not a hard requirement. 6 | 7 | If you're wondering, I do not receive any form of affiliate incentive or compensation if you buy the items below. 8 | 9 | ## Parts List 10 | 11 | 1. [ESP32 Development Board](https://www.amazon.com/gp/product/B0718T232Z/ref=ppx_yo_dt_b_asin_title_o02_s01?ie=UTF8&psc=1) 12 | 13 | 2. [Breadboard](https://www.amazon.com/Qunqi-point-Experiment-Breadboard-5-5%C3%978-2%C3%970-85cm/dp/B0135IQ0ZC/ref=sr_1_13?keywords=breadboard&qid=1563661634&s=electronics&sr=1-13) - this is the size I used. Note that it was a very tight fit with just one unused row of holes in the breadboard. You may want to get a slightly longer board to allow room to breath (unless you like a challenge!). 14 | 15 | 3. [Three LEDs of different colors, forward voltage ~2-3V, ~0.06 watts](https://www.amazon.com/Outgeek-Emitting-Assorted-Electronics-Component/dp/B07FTDWLK8/ref=sr_1_13?keywords=30+ma+led&qid=1563739605&s=gateway&sr=8-13) - I think most "electronics kit" type LEDs should be fine. I suggest at least a blue and red LED to represent the COOL and HEAT modes of our device. 16 | 17 | 4. [Three 100-ohm 1/4 watt resistors](https://www.amazon.com/AUSTOR-Resistors-Assortment-Resistor-Experiments/dp/B07BKRS4QZ/ref=sr_1_3?keywords=resistors&qid=1563739861&s=gateway&sr=8-3) 18 | 19 | 5. [Two 10K-ohm 1/4 watt resistors](https://www.amazon.com/AUSTOR-Resistors-Assortment-Resistor-Experiments/dp/B07BKRS4QZ/ref=sr_1_3?keywords=resistors&qid=1563739861&s=gateway&sr=8-3) 20 | 21 | 6. [DHT11 temperature / humidity sensor](https://www.amazon.com/DHT-11-Digital-Temperature-Humidity-Arduino/dp/B0184Y3L4A/ref=sr_1_9?keywords=DHT11&qid=1563662201&s=gateway&sr=8-9) - Again, buy a DHT11 as part of a variety kit to save $$$$ and get other fun stuff. 22 | 23 | 7. [TACT Switch](https://www.amazon.com/microtivity-IM206-6x6x6mm-Tact-Switch/dp/B004RXKWI6/ref=sr_1_7?keywords=tact+switch&qid=1563662373&s=gateway&sr=8-7) - really, any push-button switch should work, wiring might vary slightly. 24 | 25 | 8. [Jumper wires](https://www.amazon.com/AUSTOR-Lengths-Assorted-Preformed-Breadboard/dp/B07CJYSL2T/ref=sr_1_2?keywords=jumper+wires+electronics&qid=1563662443&s=gateway&sr=8-2) 26 | 27 | ## ESP32 Overview in 30 seconds 28 | 29 | The ESP32 is a popular microcontroller made by Espressif. The production version used in devices all over the world looks like this: 30 | 31 | 32 | 33 | We will be using an ESP32 development board, which looks like this: 34 | 35 | 36 | 37 | You can see that the development board also has an ESP32, but it is attached to an additional PCB that makes it easy to plug into a breadboard and experiment. 38 | 39 | ## Electronic calculations 40 | 41 | ### LEDs 42 | 43 | Some quick Googling suggests a lot of common hobbyist LEDs are rated for 20-30 mA (milliamps, or 0.001 amps). 44 | 45 | Our formula is `([source voltage] - [LED forward voltage]) / resistance = amps`. Depending on the color of our LEDs, they will range from *roughly* 2-3V of forward voltage drop, and we will be using the 3.3V output from our ESP32. So we have: 46 | 47 | * 2V LED formula -> `(3.3V - 2V) / 100 Ohm = 13 mA` 48 | * 3V LED formula -> `(3.3V - 3V) / 100 Ohm = 3 mA` 49 | 50 | Per above, a 100 Ohm resistor puts us well within the safe range of an LED rated for ~20 mA. 51 | 52 | If we instead use the 5V output from the ESP32, our formulas look like this: 53 | 54 | * 2V LED formula -> `(3.3V - 2V) / 100 Ohm = 30 mA` 55 | * 3V LED formula -> `(3.3V - 3V) / 100 Ohm = 20 mA` 56 | 57 | As you can see, 5V with a 100 Ohm resistor puts us right around (what I think?) is a common max rating (~20-30 mA) for common hobbyist LEDs. 58 | 59 | Now, we could use 5V and just increase the resistance to bring down the amps, but the reason I chose 3.3V is that the push button I use in this project causes the input voltage to flow to an input pin on the ESP32 when the button is pressed... and I read that it **is generally not safe to use a 5V input to the ESP32 (and similar board) GPIO input pins**. By keeping my LEDs on 3.3V, I can just deal with one voltage for everything and keep the wiring more simple. 60 | 61 | ### DHT11 Temperature/Humidity Sensor 62 | 63 | I followed this [Adafruit guide](https://learn.adafruit.com/dht?view=all), which calls for a 10K Ohm resistor. It says that the DHT11 works with 3.3 - 5V, but sometimes 3.3V might not be enough, in which case you can do 5V (also with a 10K Ohm resistor). At least for my DHT11, 3.3V seems to work just fine. 64 | 65 | ### Push-button to Change Thermostat Mode 66 | 67 | One of the parts kits I bought came with the same type of buttons [described in this Tumblr guide](https://tymkrs.tumblr.com/post/19734219441/the-four-pin-switch-hooking-it-up). Refer to the guide for wiring instructions. It's ok if you have a different type of button... it just may require slightly different wiring. Again, I used a 10K Ohm resistor per guidance at https://learn.sparkfun.com/tutorials/pull-up-resistors/all. 68 | 69 | ## Next Steps 70 | 71 | Once you have your components, complete [Step 5 - Build Your ESP32 Thermostat](./05-build-esp32-thermostat.md). -------------------------------------------------------------------------------- /docs/02-sign-up-for-your-skill.md: -------------------------------------------------------------------------------- 1 | # Sign up for your Alexa Skill 2 | 3 | Here, we will play the role of smart home customer by registering to use our thermostat skill with Alexa. We will then "map" our AWS IoT thing to our new user ID and finally run the Alexa discovery process to allow Alexa to interact with our device. 4 | 5 | ## Sign up 6 | 7 | Now that our skill and backend cloud infrastructure exists, we can now sign up to use the skill: 8 | 9 | 1. Install the Amazon Alexa app ([Android](https://play.google.com/store/apps/details?id=com.amazon.dee.app&hl=en_US), [iOS](https://itunes.apple.com/us/app/amazon-alexa/id944011620?mt=8)) and sign in **using the same email address** that you used to create your Alexa skill. Alternatively, you may follow the [Alexa Beta Tester Guide](https://developer.amazon.com/docs/custom-skills/skills-beta-testing-for-alexa-skills.html) to invite others to use your skill. 10 | 11 | 2. Open the Alexa app and click **Devices** in the lower right: 12 | 13 | 14 | 15 | 3. Click **Your Smart Home Skills** in the middle of the screen: 16 | 17 | 18 | 19 | 4. You should see a skill named **alexa-smart-home-demo** (or, whatever name you used when creating your skill in the Alexa Developer Console). If you do not see your skill, ensure that you are signed in to the Alexa app using the same email you used in the Alexa Developer Console or using an email that you sent a beta invitation to. Click the skill name: 20 | 21 | 22 | 23 | 5. Click **Enable to use**: 24 | 25 | 26 | 27 | 6. You will be brought to a sign-in screen. Click **sign up**: 28 | 29 | 30 | 31 | 7. Sign up for your skill with your mobile number (for US numbers, must be in the format "+1xxxyyyzzzz"): 32 | 33 | 34 | 35 | 8. You will receive a verification code via SMS text. Enter that code to complete the sign-up process: 36 | 37 | 38 | 39 | 9. You should be greeted with a **successfully linked** message: 40 | 41 | 42 | 43 | 44 | ## Map our Skill's User ID (from Cognito) to our AWS IoT Thing 45 | 46 | At this point, we need to link your new app user ID (from Amazon Cognito) to the demo AWS IoT thing we created for you previously in the Part 1 CloudFormation Template. Normally, a device manufacturer would have some process to do this for us (e.g. a web or mobile app), but we will manually create this association by adding it to a DynamoDB table used by our skill's Lambda function. The Lambda will query this table to find the device(s) owned by our user. 47 | 48 | Proceed as follows: 49 | 50 | 1. Navigate to the [Cognito user pool console](https://console.aws.amazon.com/cognito/users) and click the user pool with a name like `CognitoUserPool-XXXXXXXXXXXX`, click the **Users and groups** on the left: 51 | 52 |

53 | 54 |

55 | 56 | 2. You should see only one username similar to `e478e49d-e8d9-4490-9b53-bcb0fba2f18b`, since you're app is not publically open for registration. Just to be sure, click the username and on the next page, verify that the user's phone number matches the number you used when signing up. If it matches, make note of the username; you will need this in step 5. 57 | 58 | 3. Navigate to the [IoT thing list](https://us-east-1.console.aws.amazon.com/iot/home#/thinghub) and make note of the thing name that looks similar to `alexa-smart-home-demo-SmartHomeThing-1LW418RIHGL2X`; you will need this in step 5. 59 | 60 | 4. Navigate to the [DynamoDB table list](https://console.aws.amazon.com/dynamodb/home#tables:), click the table with a name similar to `alexa-smart-home-demo-DeviceTable-1EM716Y4F5F6H`, click the **Items** tab, and click **Create item**: 61 | 62 |

63 | 64 |

65 | 66 | 5. On the create item popup window, click the top-left dropdown and change it from "Tree" to "Text", then copy paste the info below and click **Save**. Be sure to replace the user ID and thing name with your unique values from Steps 1 and 2: 67 | 68 | ```json 69 | { 70 | "hashId": "userId_e478e49d-e8d9-4490-9b53-bcb0fba2f18b", 71 | "sortId": "thingName_alexa-smart-home-demo-SmartHomeThing-1LW418RIHGL2X", 72 | "thingName": "alexa-smart-home-demo-SmartHomeThing-1LW418RIHGL2X", 73 | "userId": "e478e49d-e8d9-4490-9b53-bcb0fba2f18b" 74 | } 75 | ``` 76 | 77 | **Note** - be sure to include the `userId_` and `thingName_` prefix in the `hashId` and `sortId` columns, but do **not** keep them in the `thingName` and `userId` columns. 78 | 79 | ## Discover your Devices with Alexa 80 | 81 | Now that our backend has an association between our skill's user ID and our IoT thing, we can Ask Alexa (or use the Alexa web or mobile app) to discover our devices. The examples below show the mobile app, but you could just as easily ask your Alexa device "Alexa, discover devices". 82 | 83 | 1. Click **Discover Devices** to have the Alexa Cloud invoke your skill's Lambda function to search for and tell Alexa which device(s) are registered to your account: 84 | 85 | 86 | 87 | 2. If Alexa says/shows that a new device was discovered, you can proceed. You can also verify that the proper device was added by viewing the list of your smart home devices from within the Alexa web or mobile app and confirming a new "Smart Thermostat" device is listed. 88 | 89 | ## Next Steps 90 | 91 | Proceed to [Step 3 - Test your skill](./03-test-skill-without-device.md). -------------------------------------------------------------------------------- /docs/01-create-alexa-skill-and-aws-backend.md: -------------------------------------------------------------------------------- 1 | # Part 1 - Create Alexa Skill and AWS Cloud Backend Infrastructure 2 | 3 | First, we create our Alexa Smart Home Skill in the Amazon-managed Alexa Service and our smart home application infrastructure in our AWS account. We connect the two components by giving the Alexa Cloud permission to invoke a Lambda function within our AWS account and by giving the Lambda function permission to send responses to our skill in the Alexa Cloud: 4 | 5 | 1. Register a developer account with the [Alexa Developer Console](https://developer.amazon.com/). Note that the **email address** you use should match the email address you later plan to test your skill with. You could optionally complete additional steps to open up testing to others, but this demo does not cover that and focuses on testing by one user (you). 6 | 7 | 2. Navigate to the [Alexa Skills Kit (ASK) Dashboard](https://developer.amazon.com/alexa/console/ask) and click **Create Skill**. 8 | 9 | 3. Give your skill a name, such as **alexa-smart-home-demo** and select **Smart Home** as the skill model. 10 | 11 | 12 | 13 | 4. Click **Create Skill**. You will be taken to a configuration page. We need to first create additional resources before we use their values to complete this page. For now, copy your **Skill ID** (e.g. `amzn1.ask.skill.xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into a text editor. You will need this value later. 14 | 15 | 5. Navigate to [https://developer.amazon.com/settings/console/mycid](https://developer.amazon.com/settings/console/mycid) and copy your **Alexa Vendor ID** into your text editor, along with your Skill ID. Be sure to use your `Vendor ID`, **not** your `Customer ID`: 16 | 17 |

18 | 19 |

20 | 21 | 6. Open **deploy.sh** and enter your Alexa skill ID and vendor ID into their corresponding variables. Note, these values are considered secrets so you would not normally commit these to source in a production environment; you instead may want to manage them with a secrets manager like [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/): 22 | 23 | ```sh 24 | # deploy.sh 25 | ALEXA_SKILL_ID=amzn1.ask.skill.1bb2f0b3-1234-1234-1234-1234ea6b04b3 26 | ALEXA_VENDOR_ID=1234N12341234 27 | ``` 28 | 29 | 7. Edit **deploy.sh** and set the `BUCKET=` variable to the name of a pre-existing S3 bucket to which you have write access. This bucket will store the artifacts used by CloudFormation to launch your stack. It's recommend that you leave the `STACK_NAME=alexa-smart-home-demo` as we will reference this stack name in later steps. 30 | 31 | ```sh 32 | # deploy.sh 33 | BUCKET=your_bucket_name 34 | ``` 35 | 36 | 8. Install the [SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html). The SAM CLI provides several tools that make serverless app development on AWS easy, including the ability to locally test AWS Lambda functions. The specific functionality we will use is SAM's ability to translate and deploy short-hand SAM YAML templates ([see specification here](https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md)) into full-fledged CloudFormation templates. 37 | 38 | 10. Build and deploy an AWS CloudFormation stack by running **deploy.sh** from the project root. This stack will create and configure the majority of this project's resources: 39 | 40 | ```sh 41 | $ ./deploy.sh 42 | ``` 43 | 44 | 11. Monitor the status of your stack from the [CloudFormation console](https://console.aws.amazon.com/cloudformation/) and wait for the status to show as **CREATE_COMPLETE**. 45 | 46 | 12. From the CloudFormation console, click the **alexa-smart-home-demo** stack and then click the **Outputs** section. Here, you will see a number of values that we will plug in to your skill's configuration in the Alexa Developer Console to complete our skill setup. 47 | 48 | 13. While keeping the CloudFormation console open, open the [Alexa Developer Console](https://developer.amazon.com/alexa/console/ask/), click **Edit** next to the skill you created previously, and copy-paste (or enter) the following values: 49 | 50 | 1. Click the **Smart Home** tab of the Alexa skill console, and: 51 | 52 | 1. Copy the value of the **AlexaDefaultEndpoint** output from CloudFormation into the **Default endpoint** box of the Alexa configuration: 53 | 54 | 2. Cick **Save** 55 | 56 | 2. Click the **Account Linking** tab of the Alexa skill console, and: 57 | 58 | 1. Copy the value of the **AlexaAuthorizationURI** output from CloudFormation into the **Authorization URI** box of the Alexa configuration. It should look like this: 59 | 60 | ``` 61 | https://012345678910-alexa-smart-home-demo-domain.auth.us-east-1.amazoncognito.com/oauth2/authorize?response_type=code&client_id=fjiejfo4pvmkdirkfhg8572d03redirect_uri=https://pitangui.amazon.com/api/skill/link/FKEN3OMDOQ12&state=STATE 62 | ``` 63 | 64 | 2. Copy the value of the **AlexaAccessTokenURI** output from CloudFormation into the **Access Token URI** box of the Alexa configuration. It should look like this: 65 | 66 | ``` 67 | https://012345678910-alexa-smart-home-demo-domain.auth.us-east-1.amazoncognito.com/oauth2/token?state=STATE 68 | ``` 69 | 70 | 3. Copy the value of the **AlexaClientId** output from CloudFormation into the **Client ID** box of the Alexa configuration. It should look like this: 71 | 72 | ``` 73 | fjiejfo4pvmkdirkfhg8572d03 74 | ``` 75 | 76 | 4. Click the link in the value field of **AlexaClientSecret** in CloudFormation; you will be taken to a secret in AWS Secrets Manager; scroll down and click **Retrieve secret value** and copy the value of **clientSecret** from AWS Secrets Manager into the **Client Secret** box of the Alexa configuration. 77 | 78 | 5. Select **HTTP Basic (recommended)** as the **Client Authentication Scheme** in the Alexa configuration. 79 | 80 | 6. Add **phone** and **openid** as values to the **Scope** section of the Alexa Configuration. Note - spelling and case must exactly match. It should look like this: 81 | 82 | 83 | 84 | 7. Leave **Domain List** and **Default Access Token Expiration Time** blank. 85 | 86 | 8. Click Save. 87 | 88 | ## Next Steps 89 | 90 | Proceed to [Step 2 - Sign up for your skill](./02-sign-up-for-your-skill.md). -------------------------------------------------------------------------------- /lambda/functions/cfProvider-cognitoClientSecret/index.js: -------------------------------------------------------------------------------- 1 | const aws = require("aws-sdk"); 2 | const cfnResponse = require('cfn-response-async'); 3 | const cognito = new aws.CognitoIdentityServiceProvider(); 4 | const secretsmanager = new aws.SecretsManager(); 5 | 6 | /* 7 | This function acts as a custom CloudFormation resource and therefore must 8 | handle one of three request types: Create, Update, or Delete. 9 | */ 10 | exports.handler = async (event, context) => { 11 | 12 | console.log("REQUEST RECEIVED:\n" + JSON.stringify(event)); 13 | 14 | var responseData; 15 | var stackName = (event.StackId).split('/')[1]; 16 | var logicalResourceId = event.LogicalResourceId; 17 | 18 | try { 19 | if (event.RequestType === 'Create' || event.RequestType === 'Update') { 20 | 21 | // Error handling checks.... 22 | if (event.ResourceProperties.hasOwnProperty('RecoveryWindowInDays') 23 | && event.ResourceProperties.hasOwnProperty('ForceDeleteWithoutRecovery') 24 | ) 25 | { 26 | throw ('Cannot simultaneously specify RecoveryWindowInDays and ForceDeleteWithoutRecovery ' 27 | + 'in properties; must specify one or neither(default is 7 day recovery retention).') 28 | ; 29 | } 30 | else if (event.ResourceProperties.hasOwnProperty('UserPoolId') === false) { 31 | throw ("Resource parameters missing required property 'UserPoolId'"); 32 | } 33 | else if (event.ResourceProperties.hasOwnProperty('AppClientId') === false) { 34 | throw ("Resource parameters missing required property 'AppClientId'"); 35 | } 36 | 37 | // The 'Create' and 'Update' are largely the same, though a few 38 | // differences when it comes to the interaction with Secrets Manager. 39 | // I opted to combine the logic in a single block. 40 | 41 | let userPoolId = event.ResourceProperties.UserPoolId; 42 | let clientId = event.ResourceProperties.AppClientId; 43 | 44 | let clientDescription = await cognito.describeUserPoolClient({ 45 | UserPoolId: userPoolId, 46 | ClientId: clientId 47 | }).promise(); 48 | 49 | // This is the JSON object we will store in AWS Secrets Manager: 50 | let secretPayload = { 51 | userPoolId: userPoolId, 52 | clientId: clientId, 53 | clientSecret: clientDescription.UserPoolClient.ClientSecret 54 | }; 55 | 56 | // Generate or use existing secret name 57 | let secretName; 58 | if (event.RequestType === 'Create') { 59 | secretName = generateSecretName(stackName, logicalResourceId); 60 | } 61 | else if (event.RequestType === 'Update') { 62 | secretName = event.PhysicalResourceId; 63 | } 64 | 65 | console.log('Secret name is: ' + secretName); 66 | 67 | let secretDescription = `App client secret for app ${clientId} of Cognito user pool` 68 | + ` ${userPoolId} for logical resource ${logicalResourceId}` 69 | + ` in CloudFormation stack ${stackName}` 70 | ; 71 | 72 | let secretsManagerParams = { 73 | Description: secretDescription, 74 | SecretString: JSON.stringify(secretPayload) 75 | }; 76 | 77 | // Parameters and API call depends on Create vs. Update 78 | let secretResponse; 79 | 80 | if (event.RequestType === 'Create') { 81 | secretsManagerParams.Name = secretName; 82 | secretsManagerParams.Tags = [ 83 | { 84 | Key: 'custom:cloudformation:stack-name', 85 | Value: stackName 86 | }, 87 | { 88 | Key: 'custom:cloudformation:logical-id', 89 | Value: logicalResourceId 90 | } 91 | ]; 92 | console.log('API params are: \n' + JSON.stringify(secretsManagerParams, null, 2)); 93 | secretResponse = await secretsmanager.createSecret(secretsManagerParams).promise(); 94 | } 95 | else if (event.RequestType === 'Update') { 96 | secretsManagerParams.SecretId = secretName; 97 | console.log('API params are: \n' + JSON.stringify(secretsManagerParams, null, 2)); 98 | secretResponse = await secretsmanager.updateSecret(secretsManagerParams).promise(); 99 | } 100 | 101 | responseData = { 102 | SecretArn: secretResponse.ARN, 103 | SecretName: secretResponse.Name, 104 | SecretVersionId: secretResponse.secretVersionId 105 | }; 106 | let physicalResourceId = secretResponse.Name; 107 | 108 | return await cfnResponse.send(event, context, "SUCCESS", responseData, physicalResourceId); 109 | } 110 | else if (event.RequestType === 'Delete') { 111 | 112 | // If the CloudFormation template did not specify either of the two delete parameters below, 113 | // then we will use a default recoveryWindowInDays of 7 just to be safe that we don't lose 114 | // an important secret by mistake. 115 | if (event.ResourceProperties.hasOwnProperty('RecoveryWindowInDays') === false 116 | && event.ResourceProperties.hasOwnProperty('ForceDeleteWithoutRecovery' === false) 117 | ) { 118 | event.ResourceProperties.RecoveryWindowInDays = 7; 119 | } 120 | 121 | // In params below, only one or neither of RecoveryWindowInDays or ForceDeleteWithoutRecovery 122 | // will be present in the ResourceProperties because we block the initial Create or Update 123 | // if both properties are specified. 124 | var deleteParams = { 125 | SecretId: event.PhysicalResourceId, 126 | RecoveryWindowInDays: event.ResourceProperties.RecoveryWindowInDays, 127 | ForceDeleteWithoutRecovery: event.ResourceProperties.ForecDeleteWithoutRecovery 128 | }; 129 | 130 | await secretsmanager.deleteSecret(deleteParams).promise(); 131 | return await cfnResponse.send(event, context, "SUCCESS"); 132 | } 133 | } catch (err) { 134 | responseData = { Error: err }; 135 | console.log(err); 136 | return await cfnResponse.send(event, context, "FAILED", responseData); 137 | } 138 | }; 139 | 140 | /* 141 | This function generates a resource name for our AWS Secret in a format similar 142 | to those automatically generated by CloudFormation, i.e. a concatenation of 143 | stackName-resourceName-, where is 12 capitalized 144 | alpha/numeric characters. 145 | */ 146 | function generateSecretName(stackName, logicalResourceId) { 147 | var result = `${stackName}-${logicalResourceId}-`; 148 | var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; 149 | var charactersLength = characters.length; 150 | for ( var i = 0; i < 12; i++ ) { 151 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 152 | } 153 | return result; 154 | } -------------------------------------------------------------------------------- /lambda/functions/alexa-skill/AlexaResponse.js: -------------------------------------------------------------------------------- 1 | // -*- coding: utf-8 -*- 2 | 3 | // Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | // Licensed under the Amazon Software License (the "License"). You may not use this file except in 6 | // compliance with the License. A copy of the License is located at 7 | 8 | // http://aws.amazon.com/asl/ 9 | 10 | // or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific 12 | // language governing permissions and limitations under the License. 13 | // 14 | // adapted from https://github.com/alexa/skill-sample-nodejs-smarthome-switch/blob/master/lambda/smarthome/alexa/skills/smarthome/AlexaResponse.js 15 | 16 | 'use strict'; 17 | 18 | let uuid = require('uuid'); 19 | 20 | /** 21 | * Helper class to generate an AlexaResponse. 22 | * @class 23 | */ 24 | class AlexaResponse { 25 | 26 | /** 27 | * Check a value for validity or return a default. 28 | * @param value The value being checked 29 | * @param defaultValue A default value if the passed value is not valid 30 | * @returns {*} The passed value if valid otherwise the default value. 31 | */ 32 | checkValue(value, defaultValue) { 33 | 34 | if (value === undefined || value === {} || value === "") 35 | return defaultValue; 36 | 37 | return value; 38 | } 39 | 40 | /** 41 | * Constructor for an Alexa Response. 42 | * @constructor 43 | * @param opts Contains initialization options for the response 44 | */ 45 | constructor(opts) { 46 | 47 | if (opts === undefined) 48 | opts = {}; 49 | 50 | if (opts.context !== undefined) 51 | this.context = this.checkValue(opts.context, undefined); 52 | 53 | if (opts.event !== undefined) 54 | this.event = this.checkValue(opts.event, undefined); 55 | else 56 | this.event = { 57 | "header": { 58 | "namespace": this.checkValue(opts.namespace, "Alexa"), 59 | "name": this.checkValue(opts.name, "Response"), 60 | "messageId": this.checkValue(opts.messageId, uuid()), 61 | "correlationToken": this.checkValue(opts.correlationToken, undefined), 62 | "payloadVersion": this.checkValue(opts.payloadVersion, "3") 63 | }, 64 | "endpoint": { 65 | "scope": { 66 | "type": "BearerToken", 67 | "token": this.checkValue(opts.token, "INVALID"), 68 | }, 69 | "endpointId": this.checkValue(opts.endpointId, "INVALID") 70 | }, 71 | "payload": this.checkValue(opts.payload, {}) 72 | }; 73 | 74 | // No endpoint in an AcceptGrant or Discover request 75 | if (this.event.header.name === "AcceptGrant.Response" || this.event.header.name === "Discover.Response") 76 | delete this.event.endpoint; 77 | 78 | } 79 | 80 | /** 81 | * Add a property to the context. 82 | * @param opts Contains options for the property. 83 | */ 84 | addContextProperty(opts) { 85 | 86 | if (this.context === undefined) 87 | this.context = { properties: [] }; 88 | 89 | if (opts !== null) { 90 | this.context.properties.push(this.createContextProperty(opts)); 91 | } 92 | } 93 | 94 | /** 95 | * Add an endpoint to the payload. 96 | * @param opts Contains options for the endpoint. 97 | */ 98 | addPayloadEndpoint(opts) { 99 | 100 | if (this.event.payload.endpoints === undefined) 101 | this.event.payload.endpoints = []; 102 | 103 | this.event.payload.endpoints.push(this.createPayloadEndpoint(opts)); 104 | } 105 | 106 | /** 107 | * Set endpoints to [], to be used when discovery errors occur: 108 | * https://developer.amazon.com/docs/device-apis/alexa-discovery.html 109 | */ 110 | setEndpointsToEmptyArrayDueToDiscoveryError() { 111 | 112 | if (this.event.payload.endpoints === undefined) 113 | this.event.payload.endpoints = []; 114 | 115 | this.event.payload.endpoints = []; 116 | } 117 | 118 | /** 119 | * Creates a property for the context. 120 | * @param opts Contains options for the property. 121 | */ 122 | createContextProperty(opts) { 123 | return { 124 | 'namespace': this.checkValue(opts.namespace, "Alexa.EndpointHealth"), 125 | 'name': this.checkValue(opts.name, "connectivity"), 126 | 'value': this.checkValue(opts.value, {"value": "OK"}), 127 | 'timeOfSample': new Date().toISOString(), 128 | 'uncertaintyInMilliseconds': this.checkValue(opts.uncertaintyInMilliseconds, 0) 129 | }; 130 | } 131 | 132 | /** 133 | * Creates an endpoint for the payload. 134 | * @param opts Contains options for the endpoint. 135 | */ 136 | createPayloadEndpoint(opts) { 137 | 138 | if (opts === undefined) opts = {}; 139 | 140 | // Return the proper structure expected for the endpoint 141 | let endpoint = 142 | { 143 | "capabilities": this.checkValue(opts.capabilities, []), 144 | "description": this.checkValue(opts.description, "Sample Endpoint Description"), 145 | "displayCategories": this.checkValue(opts.displayCategories, ["OTHER"]), 146 | "endpointId": this.checkValue(opts.endpointId, 'endpoint-001'), 147 | // "endpointId": this.checkValue(opts.endpointId, 'endpoint_' + (Math.floor(Math.random() * 90000) + 10000)), 148 | "friendlyName": this.checkValue(opts.friendlyName, "Sample Endpoint"), 149 | "manufacturerName": this.checkValue(opts.manufacturerName, "Sample Manufacturer") 150 | }; 151 | 152 | if (opts.hasOwnProperty("cookie")) 153 | endpoint["cookie"] = this.checkValue('cookie', {}); 154 | 155 | return endpoint 156 | } 157 | 158 | /** 159 | * Creates a capability for an endpoint within the payload. 160 | * @param opts Contains options for the endpoint capability. 161 | */ 162 | createPayloadEndpointCapability(opts) { 163 | 164 | if (opts === undefined) opts = {}; 165 | 166 | // Empty params lead to creating default "Alexa" interface: 167 | // https://developer.amazon.com/docs/device-apis/alexa-interface.html 168 | let capability = {}; 169 | capability['type'] = this.checkValue(opts.type, "AlexaInterface"); 170 | capability['interface'] = this.checkValue(opts.interface, "Alexa"); 171 | capability['version'] = this.checkValue(opts.version, "3"); 172 | let supported = this.checkValue(opts.supported, false); 173 | if (supported) { 174 | capability['properties'] = {}; 175 | capability['properties']['supported'] = supported; 176 | capability['properties']['proactivelyReported'] = this.checkValue(opts.proactivelyReported, false); 177 | capability['properties']['retrievable'] = this.checkValue(opts.retrievable, false); 178 | } 179 | return capability 180 | } 181 | 182 | /** 183 | * Get the composed Alexa Response. 184 | * @returns {AlexaResponse} 185 | */ 186 | get() { 187 | return this; 188 | } 189 | } 190 | 191 | module.exports = AlexaResponse; -------------------------------------------------------------------------------- /esp32/fs/init.js: -------------------------------------------------------------------------------- 1 | load('api_aws.js'); 2 | load('api_config.js'); 3 | load('api_events.js'); 4 | load('api_gpio.js'); 5 | load('api_mqtt.js'); 6 | load('api_shadow.js'); 7 | load('api_timer.js'); 8 | load('api_sys.js'); 9 | load('api_dht.js'); 10 | 11 | let aws_thing_name = Cfg.get('aws.thing_name'); 12 | 13 | // A "last will and testament" (LWT) is configured by the device and instructs 14 | // the AWS IoT Core pub/sub broker to send this message if the device disconnects. 15 | // Without an LWT, if a device suddenly disconnected, it's shadow would incorrectly 16 | // report { connectivity: "OK" } when it is not actually connected. 17 | let last_will_message = JSON.stringify( 18 | { 19 | state: { 20 | reported: { 21 | connectivity: 'UNREACHABLE' 22 | } 23 | } 24 | } 25 | ); 26 | let last_will_message_config = { 27 | mqtt: { 28 | will_message: last_will_message 29 | } 30 | }; 31 | Cfg.set(last_will_message_config, true); 32 | 33 | // If you do not specify a topic, Mongoose OS will publish the last will (LWT) to the 34 | // reserved topic device shadow, as expected. However, currently, AWS IoT Core 35 | // does not allow LWT messages to directly update a shadow. So instead, we have 36 | // to publish to a custom topic and then set up a rule in IoT Core that forwards 37 | // the message the actual reserved shadow topic. 38 | // https://docs.aws.amazon.com/iot/latest/developerguide/device-shadow-data-flow.html 39 | let last_will_topic = 'alexaSmartHomeDemo/lastWill/' + aws_thing_name; 40 | let last_will_topic_config = { 41 | mqtt: { 42 | will_topic: last_will_topic 43 | } 44 | }; 45 | Cfg.set(last_will_topic_config, true); 46 | 47 | // State that we will report back to AWS IoT 48 | let reported_state = { 49 | deviceType: "AlexaSmartHomeDemo", 50 | connectivity: undefined, 51 | uptime: 0, 52 | targetSetpoint: { 53 | scale: undefined, 54 | value: undefined 55 | }, 56 | thermostatMode: "OFF", // when device first boots up, set device mode to off. 57 | temperature: { 58 | scale: undefined, 59 | value: undefined 60 | }, 61 | ram_total: Sys.total_ram(), 62 | }; 63 | 64 | // If we change state by physically interacting with the device, we need to 65 | // clear the desired state in IoT Core. Otherwise, as soon as we change reported 66 | // state at the device, a difference between reported and desired will be sent 67 | // to the device and the device will then revert back to desired (not what we want). 68 | let desired_state = {}; 69 | 70 | // Pin numbers are specific to your board manufacturer & model number 71 | //let red_led = 13; // 5th pin from bottom left 72 | //let blue_led = 5; // 10th pin from bottom right 73 | //let white_led = 17; // 9th pin from bottom right 74 | let blue_led = 25; 75 | let red_led = 33; 76 | let white_led = 32; 77 | let tempHumidityPin = 26; 78 | let button_pin = 27; 79 | 80 | // Initialize DHT library for the temp / humidity sensor 81 | let dht = DHT.create(tempHumidityPin, DHT.DHT11); 82 | 83 | // Set initial output mode 84 | GPIO.setup_output(red_led, 0); 85 | GPIO.setup_output(blue_led, 0); 86 | GPIO.setup_output(white_led, 0); 87 | 88 | // Set LED pins to output mode 89 | GPIO.set_mode(red_led, GPIO.MODE_OUTPUT); 90 | GPIO.set_mode(blue_led, GPIO.MODE_OUTPUT); 91 | GPIO.set_mode(white_led, GPIO.MODE_OUTPUT); 92 | GPIO.set_mode(button_pin, GPIO.MODE_INPUT); 93 | 94 | // Set LED to on or off 95 | let setLED = function (led, state) { 96 | GPIO.write(led, state); 97 | print('LED ', led, ' on -> ', state); 98 | }; 99 | 100 | // Only used if we change desired state by physically interacting with our device; 101 | let publishDesiredState = function () { 102 | print('Published desired state changes...'); 103 | let topic = '$aws/things/' + aws_thing_name + '/shadow/update'; 104 | 105 | let message = JSON.stringify({ 106 | state: { 107 | desired: desired_state 108 | } 109 | }); 110 | 111 | print(topic, '->', message); 112 | MQTT.pub(topic, message, 0); 113 | }; 114 | 115 | // Only used if we physically interact with device to change the thermostat mode; 116 | let clearDesiredThermostatMode = function () { 117 | print('Current desired state: ', JSON.stringify(desired_state)); 118 | desired_state.thermostatMode = null; 119 | print('Cleared thermostat mode from desired state: ', JSON.stringify(desired_state)); 120 | publishDesiredState(); 121 | }; 122 | 123 | // Simulate changing the thermostat mode; in our case, we simply toggle LEDs 124 | let setThermostatMode = function (mode) { 125 | 126 | print('Adjusting mode to ' + mode); 127 | if (mode === 'COOL') { 128 | setLED(blue_led, true); 129 | setLED(red_led, false); 130 | } 131 | else if (mode === 'HEAT') { 132 | setLED(blue_led, false); 133 | setLED(red_led, true); 134 | } 135 | else if (mode === 'OFF') { 136 | setLED(blue_led, false); 137 | setLED(red_led, false); 138 | } 139 | else { 140 | print('ERROR: unexpected mode ', mode, 'turning off device...'); 141 | setLED(blue_led, false); 142 | setLED(red_led, false); 143 | mode = "OFF"; 144 | } 145 | 146 | reported_state.thermostatMode = mode; 147 | 148 | }; 149 | 150 | // When device first starts up, set mode to OFF: 151 | setThermostatMode("OFF"); 152 | 153 | // Simulate changing the desired target temperature; 154 | let setTargetTemperature = function (newSetpoint) { 155 | 156 | print('Adjusting target temperature...'); 157 | 158 | for (let key in newSetpoint) { 159 | reported_state.targetSetpoint[key] = newSetpoint[key]; 160 | } 161 | }; 162 | 163 | // report state back to AWS IoT Core 164 | let reportState = function() { 165 | Shadow.update(0, reported_state); 166 | }; 167 | 168 | //let tempMode = false; 169 | 170 | // Update state every 2000 ms, and report to cloud if connected to AWS 171 | Timer.set(2000, Timer.REPEAT, function () { 172 | 173 | /* 174 | setLED(blue_led, tempMode); 175 | setLED(red_led, tempMode); 176 | setLED(white_led, tempMode); 177 | tempMode = !(tempMode); 178 | */ 179 | 180 | reported_state.uptime = Sys.uptime(); 181 | reported_state.ram_free = Sys.free_ram(); 182 | let temp_celsius = dht.getTemp(); 183 | let humidity = dht.getHumidity(); 184 | 185 | if (isNaN(humidity) || isNaN(temp_celsius)) { 186 | print('Failed to read data temp/humidity from sensor'); 187 | reported_state.temperature.value = null; 188 | reported_state.temperature.scale = 'FAHRENHEIT'; 189 | reported_state.humidity = null; 190 | } 191 | else { 192 | let temp_fahrenheit = (temp_celsius * (9 / 5)) + 32; 193 | reported_state.temperature.value = temp_fahrenheit; 194 | reported_state.temperature.scale = 'FAHRENHEIT'; 195 | reported_state.humidity = humidity; 196 | } 197 | if (reported_state.connectivity === "OK") { 198 | reportState(); 199 | } 200 | 201 | print(JSON.stringify(reported_state)); 202 | 203 | }, null); 204 | 205 | // Set up Shadow handler to synchronise device state with the shadow state 206 | Shadow.addHandler(function (event, obj) { 207 | 208 | if (event === 'UPDATE_DELTA') { 209 | desired_state = obj; 210 | print('Received delta from shadow:', JSON.stringify(desired_state)); 211 | 212 | for (let key in desired_state) { 213 | 214 | if (key === 'thermostatMode') { 215 | setThermostatMode(desired_state.thermostatMode); 216 | } 217 | if (key === 'targetSetpoint') { 218 | setTargetTemperature(desired_state.targetSetpoint); 219 | } 220 | 221 | } 222 | reportState(); // Report our new state, hopefully clearing delta 223 | } 224 | }); 225 | 226 | let setConnectivity = function (isConnected) { 227 | 228 | if (isConnected === true) { 229 | print('Connected to AWS IoT Core!'); 230 | reported_state.connectivity = "OK"; 231 | setLED(white_led, true); 232 | } 233 | else { 234 | print('Disconnected from AWS IoT Core!'); 235 | reported_state.connectivity = "UNREACHABLE"; 236 | setLED(white_led, false); 237 | } 238 | }; 239 | 240 | Event.on(Event.CLOUD_CONNECTED, function () { 241 | setConnectivity(true); 242 | }, null); 243 | 244 | Event.on(Event.CLOUD_DISCONNECTED, function() { 245 | setConnectivity(false); 246 | }, null); 247 | 248 | 249 | // this handler cycles the thermostat mode between OFF, ON, and COOL. 250 | GPIO.set_button_handler(button_pin, GPIO.PULL_UP, GPIO.INT_EDGE_NEG, 50, function(x) { 251 | 252 | print("Thermostat button pressed on device!"); 253 | let desired_mode = undefined; 254 | 255 | if (reported_state.thermostatMode === "OFF") { 256 | desired_mode = "COOL"; 257 | } 258 | else if (reported_state.thermostatMode === "COOL") { 259 | desired_mode = "HEAT"; 260 | } 261 | else if (reported_state.thermostatMode === "HEAT") { 262 | desired_mode = "OFF"; 263 | } 264 | 265 | if (desired_mode !== undefined) { 266 | clearDesiredThermostatMode(); 267 | setThermostatMode(desired_mode); 268 | } 269 | else { 270 | print("Button press failed, unexpected current state: ", reported_state.thermostatMode); 271 | } 272 | 273 | }, true); -------------------------------------------------------------------------------- /docs/appendix-user-stories.md: -------------------------------------------------------------------------------- 1 | # Appendix - AWS IoT + Alexa Smart Home User Stories 2 | 3 | Example user stories are described in the following sections. I am **not** an IoT or Amazon Alexa expert; these are just based on my early learning. 4 | 5 | The users in our story are: 6 | 7 | 1. **SmartCompany** - Company that owns and sells the smart home thermostat. 8 | 2. **Manufacturer** - the manufacturer of the physical thermostat. This could be SmartCompany or a third-party manufacturer. 9 | 3. **Alexa Service** - the Alexa Cloud Service, owned and operated by Amazon Web Services (AWS). 10 | 4. **Alexa** - "Alexa" will refer to the any Alexa device, such as an Amazon Echo, or the Alexa web or mobile app. 11 | 4. **Customer** - buyer of SmartCompany's thermostat; will use their own previously-purchased Alexa-enabled device (e.g. an Amazon Echo) or the Amazon Alexa mobile app or website to interact with SmartCompany's Alexa skill. 12 | 13 | ## SmartCompany Creates an Alexa Skill and Application Backend 14 | 15 | 2. SmartCompany creates a new Smart Home skill in the [Alexa Developer Console](https://developer.amazon.com/alexa/console/ask) 16 | 3. SmartCompany builds an AWS Lambda function to interact with commands or requests from the Alexa Service 17 | 4. SmartCompany builds an Amazon Cognito user pool to act as the identity provider (IdP) for their skill 18 | 5. SmartCompany links their Alexa Smart Home Skill with their AWS Lambda function and Amazon Cognito user pool 19 | 20 | We will use AWS CloudFormation to deploy and configure most of this story; some manual steps are needed to create the Alexa skill and link it to your AWS backend. 21 | 22 | ## Manufacturer Builds Device and Embeds AWS IoT Certificate for Authentication 23 | 24 | 1. SmartCompany engages Manufacturer to build their thermostat. 25 | 26 | 2. Manufacturer obtains an AWS IoT device certificate. There are many ways to do this, some examples are below: 27 | 28 | * SmartCompany pre-provisions certificates in AWS IoT Core and provides them to Manufacturer 29 | 30 | * SmartCompany builds a "certificate vending machine" (aka CVM) and gives Manufacturer access to use/invoke the CVM. As an example, the AVM could be an AWS Lambda function accessed via an Amazon API Gateway. 31 | 32 | * SmartCompany registers a Certificate Authority (CA) with AWS IoT Core and gives the CA to the Manufacturer; the Manufacturer may now generate certificates itself and embed them on the device; these certificates can be automatically registered with AWS IoT Core using a [just-in-time registration (JITR) strategy](https://aws.amazon.com/blogs/iot/just-in-time-registration-of-device-certificates-on-aws-iot/) or the easier [just-in-time provisioning process (JITP)](https://aws.amazon.com/blogs/iot/setting-up-just-in-time-provisioning-with-aws-iot-core/). 33 | 34 | * Manufacturer has their own CA which they give to SmartCompany, and SmartCompany registers as a custom IoT CA within their AWS account; Manufacturer provisions and embeds their own certificates into the devices and, through the JITR/JITP processes, the devices can register within SmartCompany's IoT registry when they first connect. The initial registration might only allow the devices to connect but not do anything else. Then, Manufacturer gives a list of the trusted certificates they generated to SmartCompany, and SmartCompany swaps the certificate policy for those certs with a more permissive cert that allows the devices to become functional (e.g. publish or subscribe to topics). 35 | 36 | 3. At some point, SmartCompany creates an AWS IoT `thing` in the AWS IoT device registry to act as a logical representation of the physical thermostat. An IoT 'thing' is needed to use advanced AWS IoT Core features such as the device shadow. Similar to the device certificates, the thing may be created in a number of ways and at different times. Regardless of how and when, typically the thing attributes in IoT Core would contain essential information such as device serial number, model number, firmware version etc. 37 | 38 | 4. Manufacturer ships device to SmartCompany and/or retail locations, 3rd-party distributors, etc. for eventual purchase by customer. 39 | 40 | In our demo, the project's CloudFormation template will create an AWS IoT thing for us. If you build the optional ESP32 mock thermostat, you will manually generate certificates in the AWS IoT web console, download them, and flash them to the ESP32. 41 | 42 | ## Customer Buys SmartCompany's Thermostat and Signs Up for their Alexa Skill 43 | 44 | 1. Customer buys SmartCompany's thermostat and brings it home 45 | 46 | 2. Customer registers their thermostat with SmartCompany's application backend. There are many ways to do this, with one common approach being the Customer uses a mobile app developed by SmartCompany to provide WiFi credentials to the thermostat make an API call to SmartCompany's back-end to associate the device to the customer. The registration should be done with the same identity provider (IdP) that SmartCompany linked to their Alexa skill. 47 | 48 | 3. During the registration process, SmartCompany's backend should now know the Customer's IdP user ID and, either directly or indirectly via the device's serial number, it's AWS IoT thing name. Using this information, the backend should create an association between the AWS IoT thing and the IdP user ID. 49 | 50 | 3. Customer then downloads/installs SmartCompany's Alexa skill on their Alexa-enabled device (e.g. an Amazon Echo) or via the free Alexa mobile or web app. 51 | 52 | 4. Customer uses the Alexa web or mobile app to sign in to SmartCompany's Alexa skill using same credentials from Step 2. 53 | 54 | In our demo, we will sign up for our Alexa skill using the Alexa mobile app from Amazon, as this is much easier than building a full-fledged registration app / website ourselves. After signup, we will manually create an associate between our device's IoT thing and our user's Cognito user ID by adding an entry into a DynamoDB table. 55 | 56 | ## Customer Discovers Devices Compatible with SmartCompany's Alexa Skill 57 | 58 | Even though SmartCompany's AWS backend has an association between customers and their devices, e.g. in a DynamoDB database, remember that the Alexa Service is separately owned and maintained by AWS. Therefore, the Alexa Service must ask SmartCompany for a list of device(s) owned by a given a customer. The customer must explicitly initiate this via the discovery process: 59 | 60 | 1. Customer asks their Alexa device (or Alexa web/mobile app), "Alexa, discover devices" 61 | 62 | 2. The Alexa device sends the audio request to the Alexa Service 63 | 64 | 3. The Alexa service uses machine learning (ML) to convert the audio to an intent; in this case, it understands that a "Discovery" intent has been requested. 65 | 66 | 4. The Alexa service invokes SmartCompany's AWS Lambda function with a "Discovery" intent and the customer's IdP user ID in the payload. 67 | 68 | 5. The Lambda function queries SmartCompany's AWS backend application to ask for a list of devices belonging to the customer. 69 | 70 | 6. The Lambda function sends the list of device(s) back to the Alexa Service; these device(s) are remembered by the Alexa Service and referred to as `endpoints` from here onward. 71 | 72 | 7. The Alexa Service sends an audio response back to the Alexa device containing a message as to which endpoints, if any, were discovered. 73 | 74 | 8. The Alexa device says something such as "I discovered XYZ device" 75 | 76 | 9. The Customer may now interact with discovered endpoints and will also see them appear in the list of devices in the Alexa web or mobile app. 77 | 78 | In our demo, we will cover this use case as described. In our case, we have a DynamoDB table that maintains the list of device(s) associated to a given user's Cognito user ID (partition key = userID, sort key = AWS IoT Thing name). The Lambda function will obtain the device's serial number, model number, and firmware version from the IoT thing's attributes and use that info to look up the device's Alexa capabilities against a configuration map stored as part of the Lambda's code. It will use this information to form the proper response to the Alexa Service. 79 | 80 | ## Customer Interacts with Smart Thermostat by Speaking to Alexa 81 | 82 | 1. Customer interacts with their smart thermostat by speaking to Alexa; example utterances include: 83 | 84 | * "Alexa, what is the temperature of my smart thermostat?" 85 | * "Alexa, set my thermostat to COOL" 86 | * "Alexa, set my thermostat to HEAT" 87 | * "Alexa, set my thermostat to OFF" 88 | * "Alexa, set the temperature to 70 degrees" 89 | * "Alexa, increase the temperature" 90 | 91 | 2. In all cases, the user's Alexa device (or web/mobile app) will send the request to the Alexa Service. 92 | 93 | 3. The Alexa Service will determine which Smart Home API the user is requesting (e.g. get temperature, or set mode) and send that request to SmartCompany's Lambda function; the request will also include the customer's user ID and endpoint ID. Note that the Alexa Service keeps a mapping between SmartCompany's internal endpoint ID and the device name spoken by the user. SmartCompany must define a default name for their device during the discovery process, but the Customer can always change it in the Alexa app. 94 | 95 | 4. In the case of the user asking for information, such as current temperature, the Lambda function will typically read this from the device's last reported state in the AWS IoT Core device shadow. 96 | 97 | 5. In the case of the user directing the device to do something, such as change the mode or target temperature, the Lambda function will typically update the `desired state` in the device shadow; if the device is online, or when it next reconnects, the shadow service will send a `shadow delta` message to the device if the reported and desired states do not match. The device's application logic should have code that works to resolve the delta by changing desired state to match reported state. 98 | -------------------------------------------------------------------------------- /docs/05-build-esp32-thermostat.md: -------------------------------------------------------------------------------- 1 | # Build a (mock) Smart Thermostat with an ESP32 2 | 3 | In this optional section, we will wire up an ESP32 to act as a mock thermostat connected to our AWS backend. 4 | 5 | ## Key Components 6 | 7 | * **Red and blue LEDs** used to indicate that thermostat is in HEAT or COOL mode, respectively 8 | * **White LED** to indicate that the device is successfully connected to your AWS IoT Core cloud backend 9 | * **DHT11** temp/humidity sensor from which the device will take readings and send to AWS IoT 10 | * **Push-button** to allow the user to physically change the thermostat between HEAT, COOL, and OFF 11 | 12 | ## Bill of Materials 13 | 14 | Refer to the [ESP32 Thermostat bill of materials (BOM)](./05a-esp32-parts-list.md) for the components needed to build your mock device. 15 | 16 | ## Wire up your ESP32 17 | 18 | The instructions and images below assume you are using the exact same ESP32 dev board that I listed above. If you are not, the pin numbers and locations may be different for your board's manufacturer, so be sure to reference their pinout diagram. 19 | 20 | 1. Board and schematic: 21 | 22 | 23 | 24 | Up-close (1): 25 | 26 | 27 | Up-close (2): 28 | 29 | 30 | Pinout (this is specific to my ESP32 manufacturer): 31 | 32 | 33 | ## Flash ESP32 with thermostat skill 34 | 35 | We will flash the ESP32 with [Mongoose OS](https://mongoose-os.com/), an open-source IoT operating system. Mongoose OS (MOS) supports C/C++ and Javascript. We will be using the Javascript version in this demo. 36 | 37 | ## Generate and Download Device Certificates 38 | 39 | The CloudFormation template you deployed in previous steps created an AWS IoT "Thing" for you in the device registry. A registry `thing` is only a logical representation of a physical device. In order to create a link between a physical device and your thing, you must generate certificates and keys, attach them to your thing, and install them on your device. Then, when your device connects to AWS IoT Core's MQTT pub/sub broker, IoT Core will know which thing the device is based on its certificates. 40 | 41 | 1. Navigate to the [AWS IoT registry](https://us-east-1.console.aws.amazon.com/iot/home#/thinghub). 42 | 43 | 2. Click the thing with a name similar to `alexa-smart-home-demo-SmartHomeThing-1LW418RIHGL2X` 44 | 45 | 3. Click **Security** on the left and then click the **Create certificate** button: 46 | 47 | ![alt text](./../images/cert-01.png) 48 | 49 | 4. On the next screen, you should see a **Certificate created!** message. Follow these steps: 50 | 51 | 1. Download the device certificate and private key to the `esp32/fs` directory of your project repository. For this demo, you do not need to download the public key: 52 | 53 | ![alt text](./../images/esp32-directory.png) 54 | 55 | 2. Click **Activate** to activate your certificate. 56 | 57 | 3. Click **Attach a policy** in the lower right corner: 58 | 59 | ![alt text](./../images/cert-02.png) 60 | 61 | 5. The CloudFormation template you launched previously has already created an IoT certificate policy for you with a name similar to `alexa-smart-home-demo-IoTThingPolicy-ABCDEFG`. Search for this policy, check the box next to it, and click **Done**: 62 | 63 | ![alt text](./../images/cert-03.png) 64 | 65 | ## Prepare Mongoose OS Configuration File 66 | 67 | Before we flash your ESP32 with the thermostat code, we need to make a few changes to `/esp32/mos.yml` in your local project directory. This file controls the build process, including which libraries, environment variables, etc. that we use: 68 | 69 | 1. From the root of your local project directory, open the file `esp32/mos.yml` 70 | 71 | 2. Within `esp32/mos.yml`, set the name of the MQTT SSL cert and SSL key to the names of the cert files you previously downloaded to the `esp32/fs` directory: 72 | 73 | ```yaml 74 | - ["mqtt.ssl_cert", "67e48f5611-certificate.pem.crt"] 75 | - ["mqtt.ssl_key", "67e48f5611-private.pem.key"] 76 | ``` 77 | 78 | 3. Within `esp32/mos.yml`, set the name of your MQTT server to your custom AWS endpoint: 79 | 80 | ```yaml 81 | - ["mqtt.server", "a2mvse6841elo7-ats.iot.us-east-1.amazonaws.com:8883"] 82 | ``` 83 | 84 | You can find your custom endpoint from your [AWS IoT Core Settings](https://us-east-1.console.aws.amazon.com/iot/home#/settings): 85 | ![alt text](./../images/iot-settings.png) 86 | 87 | 5. Within `esp32/mos.yml`, set the name of your AWS IoT thing: 88 | 89 | ```yaml 90 | - ["aws.thing_name", "alexa-smart-home-demo-SmartHomeThing-1LW418RIHGL2X"] 91 | ``` 92 | 93 | Your can find your thing name from the [AWS IoT Registry](https://us-east-1.console.aws.amazon.com/iot/home#/thinghub): 94 | ![alt text](./../images/thing-name-registry.png) 95 | 96 | 6. You can optionally uncomment the lines below and enter your WiFi SSID and password, though this step isn't required. We will later show you how to set these values wirelessly over Bluetooth: 97 | 98 | ```yaml 99 | # - ["wifi.sta.ssid", "YOUR WIFI NAME"] 100 | # - ["wifi.sta.pass", "YOUR WIFI PASSWORD"] 101 | ``` 102 | 103 | ## Flash ESP32 with Thermostat Code and AWS IoT Certificates 104 | 105 | Now we will flash the thermostat code and AWS IoT certificates to your ESP32. If you haven't already, complete the [ESP32 First-time setup instructions](./04-esp32-first-time-setup.md), then proceed below: 106 | 107 | 1. Plug in your ESP32 to your computer via USB 108 | 109 | 2. From a terminal, type `mos` to start the mos UI 110 | 111 | 3. Within the mos UI, navigate to the `esp32` directory of your project root: 112 | 113 | ```bash 114 | cd path-to-project/aws-alexa-smart-home-demo/esp32 115 | ``` 116 | 117 | 4. Within the mos UI, type `mos build` to build your project. The UI will send the contents of `mos.yml` and your `esp32/fs` directory to a Mongoose OS build server and the server will return the compiled project. You can optionally build locally (refer to Mongoose OS docs for details): 118 | 119 | Issue the build command: 120 | ![alt text](./../images/mos-build-01.png) 121 | 122 | Wait for build to complete: 123 | ![alt text](./../images/mos-build-02.png) 124 | 125 | 5. Within the mos UI, type `mos flash` to flash your ESP32: 126 | 127 | Flash in process: 128 | 129 | 130 | 131 | Flash complete: 132 | 133 | 134 | 135 | ## Verify Flash was Successful 136 | 137 | 1. Once the flash completes, you should see telemetry from the ESP32 display within your MOS console: 138 | 139 | ![alt text](./../images/esp32-telemetry.png) 140 | 141 | 2. If you see something similar to `"temperature": { "value": 75, "scale": "FAHRENHEIT"}` and `"humidity": 37` in the telemetry, your DHT11 temp/humidity sensor is working. 142 | 143 | 3. If you press your button on the red and blue LEDs toggle on/off, your button is working (the button does not affect the white LED): 144 | 145 | * Thermostat mode is HEAT: 146 | 147 | 148 | 149 | * Thermostat mode is COOL: 150 | 151 | 152 | 153 | * Thermostat mode is OFF: 154 | 155 | 156 | 157 | ## Configure WiFi on your ESP32 158 | 159 | If the white LED on your ESP32 is glowing solid, you are already connected to WiFi and AW IoT Core and you can skip this step. 160 | 161 | 1. Navigate to https://mongoose-os.com/ble/#/ 162 | 163 | 2. Click **Choose device** and search for and select a device with a name similar to `ESP32_B2CE5D` (your device will have a different suffix). 164 | 165 | 3. Enter your WiFi SSID and password. Note - the ESP32 I use only works with 2G WiFi. To be safe, I recommend you first test a 2G WiFi before testing 5G. 166 | 167 | 4. Click **Save WiFi settings**, and after a few moments a popup alert should say **Done!**. 168 | 169 | ## Verify Connectivity to AWS IoT Core 170 | 171 | If you followed all steps closely and if my instructions are correct :), then your white LED should be on and solid, meaning it is connected to both WiFi and AWS IoT Core. Congrats! 172 | 173 | If your white LED is off, then (a) you are not connected to WiFi or (b) you are on WiFi but not connected to AWS IoT Core. 174 | 175 | 1. You entered incorrect WiFi credentials. 176 | 2. You entered a 5G WiFi configuration, but your ESP32 only supports 2G WiFi. 177 | 3. You did not flash your AWS certificates to the device by including them in the `esp32/fs` directory before executing `mos build` 178 | 4. You did not activate your certificates in AWS IoT Core. 179 | 5. You did not attach a certificate policy to the certificates which allows them to connect to AWS IoT Core 180 | 181 | To troubleshoot, open the MOS UI, plug in your device to your computer, and reboot the device. Then, carefully review the boot logs to see if there are any error messages related to WiFi or MQTT. 182 | 183 | WiFi error messages might look like this, `WiFi STA: Connect timeout`: 184 | 185 | ![alt text](./../images/wifi-error.png) 186 | 187 | ## Next Steps 188 | 189 | Once you have your components, complete [Step 6 - Test Your Skill with an ESP32 Thermostat](./06-test-skill-with-esp32.md). 190 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws-alexa-smart-home-demo 2 | 3 | ## Overview 4 | This project shows you how to build an Amazon Alexa skill to control a (mock) thermostat using the [Alexa Smart Home API](https://developer.amazon.com/docs/smarthome/understand-the-smart-home-skill-api.html) and an ESP32 development board (optional). 5 | 6 | Whereas many Alexa examples focus only on the AWS Lambda function and dummy JSON responses sent to/from the Alexa service, this project aims to give a more complete end-to-end demo by demonstrating ways that one might use Amazon DynamoDB, AWS IoT Core, Amazon Cognito, and other services. 7 | 8 | They say a picture is worth a thousand words: 9 | 10 | 11 | 12 | ## No Physical Devices Necessary 13 | 14 | * **You do not need a physical Alexa device** to interact with your skill or control your thermostat. You can use the free [Alexa mobile app](https://www.amazon.com/gp/help/customer/display.html?nodeId=201602060) or [Alexa Web Console](https://alexa.amazon.com) sign up and beta test your skill. 15 | 16 | * **You do not need an ESP32 for the thermostat** if you do not want to bother creating a mock thermostat. Your Alexa skill can interact purely with the AWS IoT device shadow, and you can place dummy data into the shadow to simulate the device being online. That being said, the project is way more fun if you build the ESP32 mock thermostat, too :) 17 | 18 | ## Demo Video 19 | 20 | This video shows the ESP32 in action by asking Alexa to change the device's state and asking it for the current temperature: 21 | https://www.youtube.com/watch?v=Cc9Y0D2bzJ8 22 | 23 | ## Disclaimer 24 | 25 | This is my first dive into AWS IoT + an Alexa skill. There are no doubt ways to do things better; some of my design choices may not be ideal. For example: 26 | 27 | * In this project, I use a DynamoDB table to store a mapping of each IoT Core thing name to each user's Cognito User Pool ID. Would it make more sense to just use an attribute in the IoT Core Device Registry to track the related user ID and remove the need for DynamoDB? 28 | 29 | * In this project, I include a JSON file in the Alexa skill's Lambda handler function that describes the capabilities of the device (which the Lambda function uses to tell the Alexa service what your device can do). Is storing this configuration file with the Lambda function the right place to do so? I wonder if a DynamoDB table or AWS Systems Manager Parameter Store is a more appropriate place? 30 | 31 | ## Prerequisites 32 | 33 | 1. AWS Account with administrative access 34 | 2. A pre-existing Amazon S3 Bucket to store CloudFormation templates (or, you can create one as you go) 35 | 3. [Node10.x](https://nodejs.org/en/download/) and [npm](https://www.npmjs.com/get-npm) (to install Lambda dependencies before uploading to AWS) 36 | 4. Optional - an ESP32 and related components, if you want to build the physical "thermostat" - [ESP32 Bill of Materials](./docs/05a-esp32-parts-list.md) 37 | 38 | ## Deployment 39 | 40 | **Part 1 - Alexa Skill and AWS Cloud Backend [REQUIRED]:** 41 | 42 | 1. [Create Alexa Skill and AWS Backend](./docs/01-create-alexa-skill-and-aws-backend.md) 43 | 1. [Sign up for your skill](./docs/02-sign-up-for-your-skill.md) 44 | 1. [Test your skill](./docs/03-test-skill-without-device.md) 45 | 46 | **Part 2 - Thermostat with ESP32 [OPTIONAL]:** 47 | 48 | 4. [ESP32 First-time Setup](./docs/04-esp32-first-time-setup.md) 49 | 5. [Build your ESP32 Thermostat](./docs/05-build-esp32-thermostat.md) (See [ESP32 Bill of Materials](./docs/05a-esp32-parts-list.md)) 50 | 6. [Test your skill and thermostat](./docs/06-test-skill-with-esp32.md) 51 | 52 | ## Architecture Overview 53 | 54 | ### Amazon Alexa skills 55 | 56 | At it's core, an Amazon Alexa skill is simply an [AWS Lambda function](https://aws.amazon.com/lambda/) that gets invoked by the AWS-managed Alexa service when a user speaks to their Alexa. You create a skill in the [Alexa Developer Console](https://developer.amazon.com/alexa/console/ask) and then link it to a Lambda function in your AWS account, and your users register to use the skill via the Alexa mobile app or website. You need an OAuth identity provider (IdP) to keep track of your registered users, and for this demo we will use [Amazon Cognito](https://aws.amazon.com/cognito/). 57 | 58 | [Custom Alexa Skills](https://developer.amazon.com/docs/custom-skills/understanding-custom-skills.html) essentially allow you to do anything you want when your Lambda function gets invoked. Custom Alexa skills give you more control but require additional development and planning. 59 | 60 | As an alternative to a custom Alexa skill, the Amazon Alexa team has created a number of [pre-made Alexa Skill Kits and APIs](https://developer.amazon.com/docs/ask-overviews/understanding-the-different-types-of-skills.html) that give you a framework for rapidly developing skills for popular use cases. If your use case matches a skill kit's capabilities, it may be worth trading the flexibility of a custom skill for the time savings offerred by the skills kit. 61 | 62 | While you could certainly build a smart home thermostat skill from scratch, we will use Alexa's [Smart Home Skill Kit & API](https://developer.amazon.com/docs/ask-overviews/understanding-the-different-types-of-skills.html#smart-home-skills-pre-built-model) in this project to speed up development. 63 | 64 | The Smart Home Skill Kit & API includes controller interfaces for common smart home devices, such as thermostats, locks, cameras, lights, and more. For this project, we will use the [Thermostat Controller Interface](https://developer.amazon.com/docs/smarthome/build-smart-home-skills-for-hvac-devices.html) and [Temperature Sensor Interface](https://developer.amazon.com/docs/device-apis/alexa-temperaturesensor.html). 65 | 66 | ### AWS IoT Core 67 | 68 | AWS offers several IoT services, including but not limited to AWS IoT Core, IoT Analytics, IoT Events, IoT SiteWise, IoT Things Graph, and Amazon FreeRTOS. 69 | 70 | In this project, we only need to use [AWS IoT Core](https://aws.amazon.com/iot-core/) to create a `thing`, which is a logical representation of a physical device. Even if you do not build the optional ESP32 thermostat in this project, this project allows you to interact with your IoT `thing` as if it were a physical device. 71 | 72 | A core component of AWS IoT Core is the [device shadow](https://docs.aws.amazon.com/iot/latest/developerguide/iot-device-shadows.html), which is a JSON document stored in the cloud and which contains two key components: 73 | 74 | * **`Reported state`**, which is a JSON message sent by a physical device to the AWS IoT Core service over MQTT. This message contains the device's current physical state (e.g. sensor readings, operating mode, etc.). 75 | 76 | * **`Desired state`**, which is a JSON document that represents the the target state we want our physical device to match (e.g. changing temperature, changing from heating to cooling mode, etc.). 77 | 78 | When the IoT Core shadow service detects a difference between the reported and desired state in the shadow, it will send a `delta` message over MQTT to the physical device. In normal operating conditions, we expect that the device will receive this delta, respond by changing its state (e.g. switching on/off, adjusting volume, etc.) and then report back its new `reported state` to the cloud. Once IoT Core see's that desired state matches reported state, IoT Core stops sending delta state messages. 79 | 80 | In this project, we will use the device shadow's `reported state` to track things such as the current temperature, thermostat mode (heat, cool, or off), and current target temperature. When a user talks to Alexa to change our thermostat settings, Alexa will invoke a Lambda which will then change our device's `desired state` in the shadow. The shadow will detect the change and send the `delta` to the physical device to respond accordingly. 81 | 82 | ### Amazon Cognito and Amazon DynamoDB 83 | 84 | We will use Amazon Cognito User Pools to manage the users that sign up for our skill. 85 | 86 | When a user invokes their Alexa skill, the Alexa service needs to know which AWS IoT `thing` belong to that user so that Alexa can read from (or update) the appropriate device shadow. 87 | 88 | We will store the mapping between Cognito users and IoT things in [Amazon DynamoDB](https://aws.amazon.com/dynamodb/), a fully-managed NoSQL key-value database. 89 | 90 | ### Questionable Design Decisions 91 | 92 | This section contains design choices that I am unsure of or where I want to further explain my reasoning. 93 | 94 | #### DynamoDB to store user-to-device mappings 95 | 96 | I chose DynamoDB because I wanted the possibility of a "many to one" mapping of users to a single device. For example, maybe a household (family or roommates) all want to control the same thermostat using their separately-registered Alexa devices and accounts. If we only needed a "one-to-one" mapping, then it would be simpler to use an attribute in our IoT thing's device registry. 97 | 98 | #### Storing Alexa discovery config in Lambda code 99 | 100 | When a user asks Alexa to discover available devices, Alexa will query your backend application (via a Lambda) to ask for a list of devices associated to the user's Cognito ID. You must return a [Discovery response](https://developer.amazon.com/docs/device-apis/alexa-discovery.html#response) in the form of a list for each device containing basic information (e.g. device name, manufacturer name) as well as complex nested structures that describe the device's capabilities (e.g. [ThermostatController](https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html)). 101 | 102 | While the simple attributes could be stored as attributes in the AWS IoT Registry, the registry didn't seem like a good fit for the complex structures that describe device capabilities/interfaces. So, I opted to contain both the simple and complex information in a Javascript map object as part of the Alexa Lambda's source code (`./lambda/functions/alexa-skill/discoveryConfig.js`). 103 | 104 | The config object has the following format: 105 | 106 | ```javascript 107 | const discoveryConfig = { 108 | 'smartThing-v1': { 109 | '1.00': { 110 | manufacturerName: 'SmartHome Products, Inc.', 111 | modelName: 'Model 001', 112 | friendlyName: 'Smart Device', 113 | description: 'My SmartHome Product!', 114 | displayCategories: [ 115 | 'OTHER', 116 | 'THERMOSTAT', 117 | 'TEMPERATURE_SENSOR' 118 | ], 119 | capabilities: [ 120 | { 121 | type: "AlexaInterface", 122 | interface: "Alexa.EndpointHealth", 123 | "version":"3", 124 | properties: { 125 | supported: [ 126 | { 127 | name: "connectivity" 128 | } 129 | ], 130 | retrievable: true 131 | } 132 | }, 133 | ... 134 | ] 135 | } 136 | } 137 | ``` 138 | 139 | The first key, `smartThing-v1` is the physical device's model number, while the second key, `1.00`, is the device's version. The keys within that represent the device's basic attributes and Smart Home capabilities. My thought was that both the physical device (model number) and its firmware version both dictate what capabilities it has, so that's how I opted to organize configuration. 140 | 141 | As I revisit this logic, I **do** like the idea of keeping the complex nested structures for things like capabilities as part of the Alexa skill's source code, as these (should) be static for a given model and firmware version and generally only need to be retrieved by the Alexa Lambda. 142 | 143 | However, I imagine that in a production scenario, you could have the same model number and firmware version built by multiple manufacturers, so it feels wrong to include this as a static attribute. I think it would be better to move this to either an attribute on a per-thing basis in the IoT device registry or to a separate data store like DynamoDB. 144 | 145 | #### Modifying Shadow's Desired State from the Physical Device 146 | 147 | Most of the literature I read calls for two de-facto rules: 148 | 1. Physical device should only modify `reported` state and **never** modify `desired` state in the device shadow. 149 | 2. Cloud should only modify `desired` state and never modify `reported` state; only the device knows what `reported` state is. 150 | 151 | I agree with #2, but ran into challenges with this demo that could only be solved by breaking rule #1. 152 | 153 | First, for this project, my functional requirements are such that: 154 | 1. The thermostat should be controllable by both cloud-side and physical button on device 155 | 2. The user's physical interaction with the thermostat should always overrule desired state (if any) previously set by the cloud 156 | 157 | The challenge I ran in to was this: 158 | 1. Hypothetically, say a user previously asked Alexa to set mode to COOL; Alexa (via Lambda) set `desired` state to `COOL`, the thermostat received a shadow delta via MQTT, and accordingly changed the mode to COOL and sent a `reported` state value of `COOL` back to the shadow. Desired and reported state match, we are at peace. 159 | 2. User walks up to the thermostat and presses a button to change the mode to `HEAT`. The device correctly changes the physical mode and then sends a shadow update of `reported = HEAT`. 160 | 3. AWS IoT Shadow Service now sees a delta in state, because `reported = HEAT` but `desired = COOL` (from previous Step 1) 161 | 4. Shadow Service sends a shadow delta message to the thermostat (desired = COOL) 162 | 5. Thermostat sees that desired state is COOL, changes physical state to COOL, and sends a shadow update of `reported = COOL`. 163 | 6. Again, reported and desired state are in harmony and equal COOL, but our user wants the mode to be HEAT. 164 | 165 | The only way I could solve for the problem above was to have the physical ESP32 thermostat clear the `desired` state when the user physically interacts to change the mode (i.e. pushes the mode button). 166 | 167 | I think the **theory** behind my solution is correct, but the actual implementation is faulty as I didn't account for scenarios where, for example, the device is disconnected from the internet and the user presses a button. In such a case, the command to "clear" the desired state would be dropped and when the device re-connects, we would again run into the same problem. Perhaps incorporating timestamps into state requests make sense... e.g. if a delta is old (because of previously-lost connectivity), ignore it and clear desired state? Or, if a device was disconnected and the user physically changed its state, compare the timestamp of the physical state change with the timestamp of the desired state change and let the latest timestamp win? 168 | 169 | I suppose it depends on your specific use case and who/when/where/how disputes should be resolved. For ecample, if your business use case was such that "orders from the cloud should always override user's physical interaction with the device', then this would be a non-issue. For normal retail and consumer smart home applications, I imagine user interaction should always take precedent. For commercial, industrial, and other large-scale use cases, perhaps cloud should always take precedent? 170 | 171 | Would love to hear thoughts from anyone that has production experience with this type of scenario. 172 | 173 | ## User Stories 174 | 175 | I've created the [Appendix - User Stories](./docs/appendix-user-stories.md) to document my (basic) understanding of the stories that a Smart Home Company, device manufacturer, and end-user might follow. 176 | 177 | ## Cost 178 | 179 | On the AWS side of things, everything we do should cost near-zero (maybe pennies a month). I haven't calculated exact costs, but the [AWS Free Tier](https://aws.amazon.com/free/) covers a lot. As of this writing (July 2019), the "always free tier" provides more than enough usage to cover: 180 | 181 | * AWS Lambda (1 million invocations / month) 182 | * Amazon Cognito (50,000 user pool actions / month) 183 | * Amazon DynamoDB (25 GB of storage, 25 RCU & 25 WCUs equivalant to 200M requests per month) 184 | 185 | The free tier also includes 12 months of free usage for certain services, including but not limited to: 186 | 187 | * AWS IoT (250K published or delivered messages per month) 188 | 189 | Even if outside of the free tier usage, everything we are using will be pay-as-you-go based on actual usage, which should amount to mere pennies a month. That being said, remember you are responsible for watching your cost and usage. 190 | -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: > 4 | aws-alexa-smart-home-demogit stat 5 | 6 | #Mappings: 7 | # Config: 8 | # Alexa: 9 | # # This value must match the name of the AWS Secrets Manager secret that 10 | # # contains your Alexa SkillId, VendorId, and CustomerId: 11 | # SecretName: AlexaSmartHomeSkillCredentials 12 | # ForceUpdate: 13 | # UpdateString: xxxx # Change this to *any* value to force an update to resources that rely on resolving values from the manually-created secret named "AlexaSmartHomeSkillCredentials" in AWS Secrets Manager 14 | 15 | #Conditions: 16 | # UpdateResourceCondition: 17 | # !Not [ !Equals [ !FindInMap [ Config, ForceUpdate, UpdateString ], do_not_update ] ] 18 | 19 | Parameters: 20 | UsePhysicalDevice: 21 | Type: String 22 | AllowedValues: 23 | - "true" 24 | - "false" 25 | Description: Will a physical device (i.e. ESP32) be used for your thermostat? 26 | Default: "false" 27 | 28 | AlexaSkillId: 29 | Type: String 30 | Description: Your Alexa Skill ID as shown from the Alexa Developer Console 31 | NoEcho: true 32 | 33 | AlexaVendorId: 34 | Type: String 35 | Description: Your Alexa Vendor ID as shown from the Alexa Developer Console 36 | NoEcho: true 37 | 38 | Globals: 39 | Function: 40 | Runtime: nodejs10.x 41 | Timeout: 10 42 | MemorySize: 128 43 | Environment: 44 | Variables: 45 | DEVICE_TABLE: !Ref DeviceTable 46 | STACK_NAME: !Ref AWS::StackName 47 | 48 | Resources: 49 | 50 | ############################################################################## 51 | CloudFormationResponseLayer: 52 | Type: AWS::Serverless::LayerVersion 53 | Properties: 54 | Description: Contains a sync and async helper function to send responses to CloudFormation for custom resources 55 | ContentUri: lambda/layers/cfn-response/ 56 | CompatibleRuntimes: 57 | - nodejs10.x 58 | - nodejs8.10 59 | LicenseInfo: 'MIT' 60 | 61 | ############################################################################## 62 | IotEndpoint: 63 | Type: 'Custom::IotEndpoint' 64 | Properties: 65 | ServiceToken: !GetAtt IotEndpointProvider.Arn 66 | 67 | IotEndpointProvider: 68 | Type: 'AWS::Serverless::Function' 69 | Properties: 70 | Handler: index.handler 71 | CodeUri: lambda/functions/cfProvider-iotEndpoint/ 72 | Layers: 73 | - !Ref CloudFormationResponseLayer 74 | Policies: 75 | - Version: '2012-10-17' 76 | Statement: 77 | - Effect: Allow 78 | Action: 79 | - iot:DescribeEndpoint 80 | Resource: 81 | - '*' 82 | 83 | 84 | ############################################################################## 85 | CognitoClientSecret: 86 | Type: 'Custom::CognitoClientSecret' 87 | Properties: 88 | ServiceToken: !GetAtt CognitoClientSecretProvider.Arn 89 | UserPoolId: !Ref CognitoUserPool 90 | AppClientId: !Ref CognitoAlexaAppClient 91 | ForceDeleteWithoutRecovery: true 92 | 93 | CognitoClientSecretProvider: 94 | Type: 'AWS::Serverless::Function' 95 | Properties: 96 | Handler: index.handler 97 | CodeUri: lambda/functions/cfProvider-cognitoClientSecret/ 98 | MemorySize: 128 99 | Layers: 100 | - !Ref CloudFormationResponseLayer 101 | Policies: 102 | # TODO: Scope down this policy, it is overly permissive 103 | - Version: '2012-10-17' 104 | Statement: 105 | - 106 | Sid: "CognitoPermissions" 107 | Effect: Allow 108 | Action: 109 | - 'cognito-idp:DescribeUserPoolClient' 110 | Resource: 111 | - '*' 112 | - 113 | Sid: "SecretsManagerPermissions" 114 | Effect: Allow 115 | Action: 116 | - 'secretsmanager:CreateSecret' 117 | - 'secretsmanager:TagResource' 118 | - 'secretsmanager:UpdateSecret' 119 | - 'secretsmanager:DeleteSecret' 120 | Resource: 121 | - '*' 122 | 123 | 124 | ############################################################################## 125 | 126 | CognitoSMSRoleExternalId: 127 | Type: 'Custom::Uuid' 128 | Properties: 129 | ServiceToken: !GetAtt UuidProvider.Arn 130 | 131 | UuidProvider: 132 | Type: 'AWS::Serverless::Function' 133 | Properties: 134 | Handler: index.handler 135 | Runtime: nodejs10.x 136 | CodeUri: lambda/functions/cfProvider-uuid/ 137 | MemorySize: 128 138 | Layers: 139 | - !Ref CloudFormationResponseLayer 140 | 141 | 142 | ############################################################################## 143 | 144 | 145 | ############################################################################## 146 | 147 | CognitoUserPool: 148 | Type: AWS::Cognito::UserPool 149 | DependsOn: CognitoSMSPolicy 150 | Properties: 151 | AdminCreateUserConfig: 152 | AllowAdminCreateUserOnly: False 153 | AutoVerifiedAttributes: 154 | - 'phone_number' 155 | MfaConfiguration: 'ON' # Required for Alexa 156 | Policies: 157 | PasswordPolicy: 158 | MinimumLength: 8 159 | RequireLowercase: true 160 | RequireNumbers: true 161 | RequireSymbols: true 162 | RequireUppercase: true 163 | TemporaryPasswordValidityDays: 7 164 | UsernameAttributes: 165 | - 'phone_number' 166 | SmsConfiguration: 167 | # The ExternalId must match the sts:ExternalId specified in the trust 168 | # policy of the IAM role references by SnsCallerArn. The external ID 169 | # should be a random ID; to allow for automated deployment, we use a 170 | # custom CloudFormation resource that generates a V4 UUID: 171 | ExternalId: !GetAtt CognitoSMSRoleExternalId.uuid 172 | SnsCallerArn: !Sub "arn:aws:iam::${AWS::AccountId}:role/service-role/${CognitoSMSRole}" 173 | 174 | # App Client that allows Alexa to interact with our Cognito User Pool: 175 | CognitoAlexaAppClient: 176 | Type: AWS::Cognito::UserPoolClient 177 | Properties: 178 | GenerateSecret: true 179 | UserPoolId: !Ref CognitoUserPool 180 | 181 | # Note - the Callback URL below contains your Alexa vendor ID and is passed 182 | # to the custom Lambda resource in the API call in plain text. As your vendor 183 | # ID is a secret, this method is not secure. TODO: update code to pass the 184 | # secret name to the custom Lambda, rather than the secret value. Then, the 185 | # Lambda should retrieve the secret value itself directly from AWS Secrets Manager. 186 | CognitoClientConfiguration: 187 | Type: 'Custom::CognitoClientConfiguration' 188 | # Condition: UpdateResourceCondition 189 | Properties: 190 | ServiceToken: !GetAtt CognitoClientConfigurationProvider.Arn 191 | UserPoolId: !Ref CognitoUserPool 192 | UserPoolClientId: !Ref CognitoAlexaAppClient 193 | SupportedIdentityProviders: 194 | - COGNITO 195 | CallbackURL: 196 | Fn::Join: 197 | - "" 198 | - - "https://pitangui.amazon.com/api/skill/link/" 199 | - !Ref AlexaVendorId 200 | # - !Sub 201 | # - '{{resolve:secretsmanager:${AlexaSecretName}:SecretString:VendorId}}' 202 | # - { AlexaSecretName: !FindInMap [Config, Alexa, SecretName]} 203 | AllowedOAuthFlowsUserPoolClient: true 204 | AllowedOAuthFlows: 205 | - "code" 206 | - implicit 207 | AllowedOAuthScopes: 208 | - openid 209 | - phone 210 | 211 | CognitoClientConfigurationProvider: 212 | Type: 'AWS::Serverless::Function' 213 | Properties: 214 | Handler: index.handler 215 | CodeUri: lambda/functions/cfProvider-cognitoClientConfiguration/ 216 | MemorySize: 128 217 | Layers: 218 | - !Ref CloudFormationResponseLayer 219 | Policies: 220 | - Version: '2012-10-17' 221 | Statement: 222 | - 223 | Effect: Allow 224 | Action: 'cognito-idp:UpdateUserPoolClient' 225 | Resource: 'arn:aws:cognito-idp:*:*:userpool/*' 226 | 227 | CognitoDomain: 228 | Type: 'Custom::CognitoUserPoolDomain' 229 | Properties: 230 | ServiceToken: !GetAtt CognitoDomainProvider.Arn 231 | DomainPrefix: !Sub "${AWS::AccountId}-${AWS::StackName}-domain" # Note - stack name cannot contain 'aws' or creation will fail 232 | UserPoolId: !Ref CognitoUserPool 233 | ClientId: !Sub CognitoAlexaAppClient 234 | 235 | CognitoDomainProvider: 236 | Type: 'AWS::Serverless::Function' 237 | Properties: 238 | Handler: index.handler 239 | CodeUri: lambda/functions/cfProvider-cognitoUserPoolDomain/ 240 | MemorySize: 128 241 | Layers: 242 | - !Ref CloudFormationResponseLayer 243 | Policies: 244 | - Version: '2012-10-17' 245 | Statement: 246 | - 247 | Effect: Allow 248 | Action: 'cognito-idp:CreateUserPoolDomain' 249 | Resource: 'arn:aws:cognito-idp:*:*:userpool/*' 250 | - 251 | Effect: Allow 252 | Action: 'cognito-idp:DeleteUserPoolDomain' 253 | Resource: 'arn:aws:cognito-idp:*:*:userpool/*' 254 | - 255 | Effect: Allow 256 | Action: 'cognito-idp:DescribeUserPoolDomain' 257 | Resource: '*' 258 | 259 | CognitoSMSRole: 260 | Type: AWS::IAM::Role 261 | Properties: 262 | AssumeRolePolicyDocument: 263 | Version: "2012-10-17" 264 | Statement: 265 | - Effect: "Allow" 266 | Principal: 267 | Service: 268 | - "cognito-idp.amazonaws.com" 269 | Action: 270 | - "sts:AssumeRole" 271 | Condition: 272 | StringEquals: 273 | # The ExternalId below must match the ExternalId we specify 274 | # in the Cognito User Pool's SMS configuration; otherwise, the 275 | # Cognito User Pool will fail to create: 276 | "sts:ExternalId": !GetAtt CognitoSMSRoleExternalId.uuid 277 | Path: "/service-role/" 278 | 279 | CognitoSMSPolicy: 280 | Type: "AWS::IAM::Policy" 281 | Properties: 282 | PolicyName: !Sub "${AWS::StackName}-CognitoSMSPolicy" 283 | PolicyDocument: 284 | Version: "2012-10-17" 285 | Statement: 286 | - Effect: "Allow" 287 | Action: 288 | - "sns:publish" 289 | Resource: 290 | - "*" 291 | Roles: 292 | - Ref: CognitoSMSRole 293 | 294 | VerifyCognitoTokenFunction: 295 | Type: AWS::Serverless::Function 296 | Properties: 297 | CodeUri: lambda/functions/cognito-verifyAuthToken/ 298 | Handler: index.handler 299 | Environment: 300 | Variables: 301 | USER_POOL_ID: !Ref CognitoUserPool 302 | REGION: !Ref "AWS::Region" 303 | APP_CLIENT_ID: !Ref CognitoAlexaAppClient 304 | 305 | ############################################################################## 306 | 307 | AlexaSkillFunction: 308 | Type: AWS::Serverless::Function 309 | Properties: 310 | CodeUri: lambda/functions/alexa-skill/ 311 | Handler: index.handler 312 | Environment: 313 | Variables: 314 | GET_DEVICES_BY_USER_FUNCTION: !Ref GetDevicesByUserFunction 315 | VERIFY_COGNITO_TOKEN_FUNCTION: !Ref VerifyCognitoTokenFunction 316 | IOT_ENDPOINT: !GetAtt IotEndpoint.IotEndpointAddress 317 | USE_PHYSICAL_DEVICE: !Ref UsePhysicalDevice 318 | Policies: 319 | - Statement: 320 | - 321 | Sid: InvokeOtherFunctions 322 | Effect: Allow 323 | Action: 324 | - lambda:InvokeFunction 325 | Resource: 326 | - !GetAtt GetDevicesByUserFunction.Arn 327 | - !GetAtt VerifyCognitoTokenFunction.Arn 328 | - Statement: 329 | - 330 | Sid: UpdateIotThingShadow 331 | Effect: Allow 332 | Action: 333 | - iot:* 334 | Resource: "*" 335 | - Statement: 336 | - 337 | Sid: ReadFromDynamoDB 338 | Effect: Allow 339 | Action: 340 | - dynamodb:GetItem 341 | - dynamodb:Query 342 | - dynamodb:Scan 343 | - dynamodb:BatchGetItem 344 | Resource: "*" 345 | 346 | AlexaSkillFunctionPermission: 347 | Type: AWS::Lambda::Permission 348 | DependsOn: AlexaSkillFunction 349 | # Condition: UpdateResourceCondition 350 | Properties: 351 | Action: lambda:InvokeFunction 352 | EventSourceToken: 353 | !Ref AlexaSkillId 354 | # !Sub 355 | # - '{{resolve:secretsmanager:${AlexaSecretName}:SecretString:SkillId}}' 356 | # - { AlexaSecretName: !FindInMap [Config, Alexa, SecretName]} 357 | FunctionName: !GetAtt AlexaSkillFunction.Arn 358 | Principal: alexa-connectedhome.amazon.com 359 | 360 | ############################################################################## 361 | 362 | SmartHomeThing: 363 | Type: AWS::IoT::Thing 364 | Properties: 365 | AttributePayload: 366 | Attributes: 367 | modelNumber: 'smartThing-v1' 368 | firmwareVersion: '1.00' 369 | serialNumber: '1234556789' 370 | 371 | # Today, AWS IoT Core allows standard MQTT last will messages to be specified, 372 | # and it does send them to the proper shadow topics, but the LWT specifically 373 | # cannot directly update the IoT device shadow. We have to use a workaround 374 | # by which we specify the LWT topic to publish to a custom topic rather than 375 | # the device shadow; we use the rule below to republish the LWT to the actual 376 | # reserved aws shadow topic, thus ensuring our LWT makes required changes to the 377 | # shadow. See link below for details: 378 | # https://docs.aws.amazon.com/iot/latest/developerguide/device-shadow-data-flow.html 379 | IoTRuleRepublishLastWill: 380 | Type: AWS::IoT::TopicRule 381 | Properties: 382 | TopicRulePayload: 383 | Sql: "SELECT * FROM 'alexaSmartHomeDemo/lastWill/+'" 384 | RuleDisabled: false 385 | Actions: 386 | - Republish: 387 | Topic: "$$aws/things/${topic(3)}/shadow/update" 388 | RoleArn: !GetAtt IotRulesRole.Arn 389 | 390 | 391 | IotRulesRole: 392 | Type: AWS::IAM::Role 393 | Properties: 394 | AssumeRolePolicyDocument: 395 | Version: "2012-10-17" 396 | Statement: 397 | - Effect: "Allow" 398 | Principal: 399 | Service: 400 | - "iot.amazonaws.com" 401 | Action: 402 | - "sts:AssumeRole" 403 | Path: "/service-role/" 404 | ManagedPolicyArns: 405 | - "arn:aws:iam::aws:policy/service-role/AWSIoTRuleActions" 406 | 407 | IoTThingPolicy: 408 | Type: AWS::IoT::Policy 409 | Properties: 410 | PolicyDocument: { 411 | "Version": "2012-10-17", 412 | "Statement": [ 413 | { 414 | "Effect": "Allow", 415 | "Action": "iot:*", 416 | "Resource": "*" 417 | } 418 | ] 419 | } 420 | 421 | ############################################################################## 422 | 423 | CreateThingFunction: 424 | Type: AWS::Serverless::Function 425 | Properties: 426 | CodeUri: lambda/functions/iot-createThing/ 427 | Handler: index.handler 428 | Policies: 429 | - Statement: 430 | - 431 | Sid: IoTCreateThingPolicy 432 | Effect: Allow 433 | Action: 434 | - iot:CreateThing 435 | - iot:DescribeThing 436 | - iot:UpdateThing 437 | Resource: '*' 438 | 439 | AssociateThingToUserFunction: 440 | Type: AWS::Serverless::Function 441 | Properties: 442 | CodeUri: lambda/functions/ddb-associateThingToUser/ 443 | Handler: index.handler 444 | Policies: 445 | - Statement: 446 | - 447 | Sid: DynamoDBCrudPolicy 448 | Effect: Allow 449 | Action: 450 | - dynamodb:GetItem 451 | - dynamodb:DeleteItem 452 | - dynamodb:PutItem 453 | - dynamodb:Scan 454 | - dynamodb:Query 455 | - dynamodb:UpdateItem 456 | - dynamodb:BatchWriteItem 457 | - dynamodb:BatchGetItem 458 | - dynamodb:DescribeTable 459 | Resource: 460 | - !GetAtt DeviceTable.Arn 461 | - !Sub "${DeviceTable.Arn}/index/*" 462 | 463 | CreateDeviceTypeFunction: 464 | Type: AWS::Serverless::Function 465 | Properties: 466 | CodeUri: lambda/functions/ddb-createThingType/ 467 | Handler: index.handler 468 | Policies: 469 | - Statement: 470 | - 471 | Sid: DynamoDBCrudPolicy 472 | Effect: Allow 473 | Action: 474 | - dynamodb:GetItem 475 | - dynamodb:DeleteItem 476 | - dynamodb:PutItem 477 | - dynamodb:Scan 478 | - dynamodb:Query 479 | - dynamodb:UpdateItem 480 | - dynamodb:BatchWriteItem 481 | - dynamodb:BatchGetItem 482 | - dynamodb:DescribeTable 483 | Resource: 484 | - !GetAtt DeviceTable.Arn 485 | - !Sub "${DeviceTable.Arn}/index/*" 486 | 487 | GetDevicesByUserFunction: 488 | Type: AWS::Serverless::Function 489 | Properties: 490 | CodeUri: lambda/functions/ddb-getUserThings/ 491 | Handler: index.handler 492 | Policies: 493 | - Statement: 494 | - 495 | Sid: DynamoDBReadPolicy 496 | Effect: Allow 497 | Action: 498 | - dynamodb:GetItem 499 | - dynamodb:Scan 500 | - dynamodb:Query 501 | - dynamodb:BatchGetItem 502 | - dynamodb:DescribeTable 503 | Resource: 504 | - !GetAtt DeviceTable.Arn 505 | - !Sub "${DeviceTable.Arn}/index/*" 506 | 507 | 508 | ############################################################################## 509 | 510 | DeviceTable: 511 | Type: AWS::DynamoDB::Table 512 | Properties: 513 | AttributeDefinitions: 514 | - 515 | AttributeName: hashId 516 | AttributeType: S 517 | - 518 | AttributeName: sortId 519 | AttributeType: S 520 | KeySchema: 521 | - 522 | AttributeName: hashId 523 | KeyType: HASH 524 | - 525 | AttributeName: sortId 526 | KeyType: RANGE 527 | ProvisionedThroughput: 528 | ReadCapacityUnits: 5 529 | WriteCapacityUnits: 5 530 | 531 | Outputs: 532 | 533 | IotEndpointAddress: 534 | Description: The IoT Endpoint for this particular AWS account. 535 | Value: !GetAtt IotEndpoint.IotEndpointAddress 536 | 537 | CognitoSMSRoleExternalId: 538 | Description: > 539 | The random UUID generated for the trust policy of the IAM role used by 540 | Cognito to send SMS verification messages. 541 | Value: !GetAtt CognitoSMSRoleExternalId.uuid 542 | 543 | AlexaDefaultEndpoint: 544 | Description: Used to configure skill in Alexa Developer Console. 545 | Value: !GetAtt AlexaSkillFunction.Arn 546 | 547 | # Note - the URI below contains your Alexa developer account ID, which should 548 | # be considered a secret. You may want to remove this output from the template 549 | # in a production scenario to protect this secret value. 550 | AlexaAuthorizationURI: 551 | # Condition: UpdateResourceCondition 552 | Description: Used to configure Account Linking in Alexa Developer Console. 553 | Value: 554 | Fn::Join: 555 | - '' 556 | - - !GetAtt CognitoDomain.FullDomain 557 | - '/oauth2/authorize?response_type=code&client_id=' 558 | - !Ref CognitoAlexaAppClient 559 | - '&redirect_uri=https://pitangui.amazon.com/api/skill/link/' 560 | - !Ref AlexaVendorId 561 | # - !Sub 562 | # - '{{resolve:secretsmanager:${AlexaSecretName}:SecretString:VendorId}}' 563 | # - { AlexaSecretName: !FindInMap [Config, Alexa, SecretName]} 564 | - '&state=STATE' 565 | 566 | AlexaAccessTokenURI: 567 | Description: Used to configure Account Linking in Alexa Developer Console. 568 | Value: 569 | #https://alexa-test.auth.us-east-1.amazoncognito.com 570 | Fn::Join: 571 | - '' 572 | - - !GetAtt CognitoDomain.FullDomain 573 | - '/oauth2/token?state=STATE' 574 | 575 | AlexaClientId: 576 | Description: Used to configure Account Linking in Alexa Developer Console. 577 | Value: !Ref CognitoAlexaAppClient 578 | 579 | AlexaClientSecret: 580 | Description: > 581 | Used to configure Account Linking in Alexa Developer Console. 582 | Paste this URL in the browser, scroll down, click 'Retrieve secret value', 583 | and use the value of the 'clientSecret' when configuring the Alexa skill. 584 | Value: 585 | Fn::Join: 586 | - "" 587 | - - "https://" 588 | - !Ref "AWS::Region" 589 | - ".console.aws.amazon.com/secretsmanager/home?region=" 590 | - !Ref "AWS::Region" 591 | - "#/secret?name=" 592 | - !GetAtt CognitoClientSecret.SecretName -------------------------------------------------------------------------------- /lambda/functions/alexa-skill/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const AWS = require("aws-sdk"); 3 | 4 | const GET_DEVICES_BY_USER_FUNCTION = process.env.GET_DEVICES_BY_USER_FUNCTION; 5 | const VERIFY_COGNITO_TOKEN_FUNCTION = process.env.VERIFY_COGNITO_TOKEN_FUNCTION; 6 | const IOT_ENDPOINT = process.env.IOT_ENDPOINT; 7 | const DEVICE_TABLE_NAME = process.env.DEVICE_TABLE; 8 | const USE_PHYSICAL_DEVICE = process.env.USE_PHYSICAL_DEVICE; 9 | 10 | const AlexaResponse = require("./AlexaResponse"); 11 | const discoveryConfig = require ("./discoveryConfig"); 12 | const iot = new AWS.Iot(); 13 | const iotdata = new AWS.IotData({ endpoint: IOT_ENDPOINT }); 14 | const dynamodb = new AWS.DynamoDB.DocumentClient(); 15 | const lambda = new AWS.Lambda(); 16 | const REQUIRED_PAYLOAD_VERSION = "3"; 17 | 18 | /* 19 | TODO: 20 | 21 | 1. Consider re-writing to store endpoint-to-user mapping as an attribute 22 | in IoT registry rather than DynamoDB; eliminate need for DDB? 23 | 24 | 2. Consider storing attributes needed for discovery, e.g. manufacturer name, 25 | as attributes in IoT core, instead of a Lambda config file? 26 | - on second thought, the complex nested structure of things like the 27 | Alexa capabilities means simple IoT attributes probably wouldn't 28 | suffice. DynamoDB comes to mind, but it's easier to store this info 29 | in a config file rather than an item in a DDB database. So, maybe 30 | makes sense to keep it where it is (in discoveryConfig.js) 31 | */ 32 | 33 | // For debugging / test purposes if you do not have a physical device linked to 34 | // your Thing in the IoT Core Registry, set the value below to true; this will 35 | // copy your desired state changes to the reported state changes. Normally, 36 | // only the physical device would update reported state. If using a physical device, 37 | // set the value below to false: 38 | const copyDesiredStateToReportedStateInShadow = !(USE_PHYSICAL_DEVICE); 39 | 40 | exports.handler = async function (request, context) { 41 | 42 | try { 43 | log("Alexa request:\n", request); 44 | log("Alexa context:\n", context); 45 | 46 | verifyRequestContainsDirective(request); 47 | 48 | var directive = request.directive; 49 | var header = directive.header; 50 | var namespace = header.namespace; 51 | var name = header.name; 52 | var ignoreExpiredToken = request.ignoreExpiredToken || false; // True for debugging purposes, if we're using an old token 53 | 54 | verifyPayloadVersionIsSupported(header.payloadVersion); 55 | 56 | var userId = await verifyAuthTokenAndGetUserId( 57 | namespace, 58 | directive, 59 | ignoreExpiredToken 60 | ); 61 | 62 | // Is this a request to discover available devices? 63 | if (namespace === 'Alexa.Discovery') { 64 | var response = await handleDiscovery(request, context, userId); 65 | return sendResponse(response.get()); 66 | } 67 | // If this is not a discovery request, it is a directive to do something 68 | // to or retrieve info about a specific endpoint. We must verify that 69 | // the endpoint exists * and * is mapped to our current user before 70 | // we do anything with it: 71 | else { 72 | 73 | var endpoint = await verifyEndpointAndGetEndpointDetail(userId, directive.endpoint.endpointId); 74 | 75 | verifyEndpointConnectivity(endpoint); 76 | 77 | if (namespace === 'Alexa.ThermostatController') { 78 | let response = await handleThermostatControl(request, context, endpoint); 79 | return sendResponse(response.get()); 80 | } 81 | else if (namespace === 'Alexa' && name === 'ReportState') { 82 | let response = await handleReportState(request, context, endpoint); 83 | return sendResponse(response.get()); 84 | } 85 | else { 86 | throw new AlexaException( 87 | 'INVALID_DIRECTIVE', 88 | `Namespace ${namespace} with name ${name} is unsupported by this skill.` 89 | ); 90 | } 91 | } 92 | } 93 | catch (err) { 94 | log(`Error: ${err.name}: ${err.message}`); 95 | log('Stack:\n' + err.stack); 96 | var errorType, errorMessage, additionalPayload; 97 | 98 | // if AlexaError key is present (regardless of value), then we threw 99 | // an error intentionally and we want to bubble up a specific Alexa 100 | // error type. If the key is not present, it is an unhandled error and 101 | // we respond with a generic Alexa INTERNAL_ERROR message. 102 | if (err.hasOwnProperty('AlexaError')) { 103 | errorType = err.name; 104 | errorMessage = err.message; 105 | additionalPayload = checkValue(err.additionalPayload, undefined); 106 | } else { 107 | errorType = 'INTERNAL_ERROR'; 108 | errorMessage = `Unhandled error: ${err}` 109 | } 110 | return sendErrorResponse(errorType, errorMessage, additionalPayload); 111 | } 112 | 113 | }; 114 | 115 | function verifyEndpointConnectivity(endpoint) { 116 | 117 | log('Verifying endpoint is online...'); 118 | var shadow = endpoint.shadow; 119 | 120 | if (shadow.hasOwnProperty('state') === false) { 121 | throw new AlexaException('ENDPOINT_UNREACHABLE', 'Shadow does not contain a state object'); 122 | } 123 | 124 | if (shadow.state.hasOwnProperty('reported') === false) { 125 | throw new AlexaException('ENDPOINT_UNREACHABLE', 'Shadow does not contain a state.reported object'); 126 | } 127 | 128 | if (shadow.state.reported.hasOwnProperty('connectivity') === false) { 129 | throw new AlexaException('ENDPOINT_UNREACHABLE', 'Shadow does not contain a state.reported.connectivity object'); 130 | } 131 | 132 | if (shadow.state.reported.connectivity !== "OK") { 133 | throw new AlexaException('ENDPOINT_UNREACHABLE', `Device unavailable, reported online=${shadow.state.reported.online}`); 134 | } 135 | 136 | log('Endpoint is online.'); 137 | 138 | } 139 | 140 | function verifyRequestContainsDirective(request) { 141 | // Basic error checking to confirm request matches expected format. 142 | if (!('directive' in request)) { 143 | throw new AlexaException( 144 | 'INVALID_DIRECTIVE', 145 | 'directive is missing from request' 146 | ); 147 | } 148 | } 149 | 150 | function verifyPayloadVersionIsSupported(payloadVersion) { 151 | // Basic error checking to confirm request matches expected format. 152 | if (payloadVersion !== REQUIRED_PAYLOAD_VERSION) { 153 | throw new AlexaException( 154 | 'INVALID_DIRECTIVE', 155 | `Payload version ${payloadVersion} unsupported.` 156 | + `Expected v${REQUIRED_PAYLOAD_VERSION}` 157 | ); 158 | } 159 | } 160 | 161 | async function verifyEndpointAndGetEndpointDetail(userId, endpointId) { 162 | /* 163 | If a directive (other than discovery) is received for a specific endpoint, 164 | there are two ways the endpoint may be invalid: Alexa Cloud may think it is 165 | mapped to our specific user but it has since been reassigned to another user, 166 | or, the mapping is correct but, for whatever reason, the "Thing" in AWS IoT 167 | no longer exists. In the former, an example scenario is that the device was 168 | given to a friend and that friend registered it to their account. The Alexa 169 | Cloud does not know this happened... we do not want the original owner to be 170 | able to control the device at this point, so we should be sure to verify the 171 | device is associated to the user invoking the skill. In the latter example, 172 | it wouldn't be typical to delete an IoT Thing for a given device but its not 173 | impossible (maybe by mistake, or maybe because a device was suspected as being 174 | compromised and we want to remove it from service?). 175 | */ 176 | 177 | // First, let's see if device is mapped to our user in DynamoDB: 178 | log('Verifying that endpoint is mapped to the user invoking the skill...') 179 | var params = { 180 | Key: { 181 | hashId: 'userId_' + userId, 182 | sortId: 'thingName_' + endpointId 183 | }, 184 | TableName: DEVICE_TABLE_NAME 185 | }; 186 | log('Calling dynamodb.getItem() with params:', params); 187 | var getResponse = await dynamodb.get(params).promise(); 188 | if (getResponse.hasOwnProperty('Item')) { 189 | // The Item key will only be present if the item exists. 190 | log("Endpoint is mapped to invoking user."); 191 | } 192 | else { 193 | throw new AlexaException( 194 | 'NO_SUCH_ENDPOINT', 195 | 'Endpoint ID exists in AWS IoT Registry but is not mapped to this user in DynamoDB' 196 | ); 197 | } 198 | 199 | // Now, let's see if the device actually exists in the Iot Core registry. 200 | // If it does exist, we will populate our response with the IoT thing details 201 | // and current reported shadow state. 202 | var endpoint = {}; 203 | try { 204 | // The IoT describeThing() API will throw an error if the given thing 205 | // name does not exist, so we must wrap in a try/catch block. 206 | console.log('Verifying existence of endpoint in AWS IoT Registry...'); 207 | let params = { 208 | thingName: endpointId 209 | }; 210 | log('Calling iot.describeThing() with params:', params); 211 | endpoint = await iot.describeThing(params).promise(); 212 | log("Endpoint exists in Iot Registry"); 213 | endpoint.config = getDeviceConfigFromIotThingAttributes(endpoint.attributes); 214 | 215 | // Endpoint ID is core concept when dealing with the Alexa Smart Home API; 216 | // We happen to use the IoT Registry's thingName as our endpointID, but 217 | // its possible other implementations may use a different value for the 218 | // endpoint ID. So, rather than let all later code refer to "thingName", 219 | // I'd rather explicitly create an endpointId key. That way, it's easier 220 | // to drop in a different value if you prefer not to use the IoT thing name. 221 | endpoint.endpointId = endpoint.thingName; 222 | endpoint.shadow = await getDeviceShadow(endpoint.thingName); 223 | log('Full endpoint detail:', endpoint); 224 | } 225 | catch (err) { 226 | if (err.name === "ResourceNotFoundException") { 227 | throw new AlexaException( 228 | 'NO_SUCH_ENDPOINT', 229 | 'Endpoint ID does not exist as Thing Name in AWS IoT Registry' 230 | ); 231 | } 232 | else { 233 | // If it's not a ResourceNotFound error, then it is an unexpected 234 | // error and we simply pass it upstream to our main error handler. 235 | throw(err); 236 | } 237 | } 238 | return endpoint; 239 | } 240 | 241 | async function handleDiscovery(request, context, userId) { 242 | 243 | try { 244 | log("Calling handleDiscovery()"); 245 | 246 | var alexaResponse = new AlexaResponse({ 247 | "namespace": "Alexa.Discovery", 248 | "name": "Discover.Response" 249 | }); 250 | 251 | var endpoints = await getUserEndpoints(userId); 252 | 253 | endpoints.forEach(endpoint => { 254 | alexaResponse.addPayloadEndpoint(endpoint); 255 | }); 256 | 257 | return alexaResponse; 258 | } 259 | catch (err) { 260 | throw new Error("handleDiscovery() failed: " + err); 261 | } 262 | 263 | } 264 | 265 | async function verifyAuthTokenAndGetUserId(namespace, directive, ignoreExpiredToken) { 266 | /* This function calls another function to determine whether the auth token 267 | provided by Alexa during invocation is valid and not expired. 268 | */ 269 | 270 | try { 271 | log('Validating auth token...'); 272 | // The location of the user's auth token is in the payload of the 273 | // request directive if the namespace is Discovery; otherwise, it is 274 | // in the request endpoint: 275 | var encodedAuthToken; 276 | if (namespace === 'Alexa.Discovery') { 277 | encodedAuthToken = directive.payload.scope.token; 278 | } 279 | else { 280 | encodedAuthToken = directive.endpoint.scope.token; 281 | } 282 | 283 | var params = { 284 | FunctionName: VERIFY_COGNITO_TOKEN_FUNCTION, 285 | InvocationType: "RequestResponse", 286 | Payload: JSON.stringify({ token: encodedAuthToken }) 287 | }; 288 | 289 | log('Calling lambda.invoke() with params:', params); 290 | var response = await lambda.invoke(params).promise(); 291 | 292 | if (response.hasOwnProperty('FunctionError')) { 293 | var authError = JSON.parse(response.Payload); 294 | if (authError.errorMessage === 'Token is expired' 295 | && ignoreExpiredToken === true) { 296 | throw new AlexaException( 297 | 'EXPIRED_AUTHORIZATION_CREDENTIAL', 298 | 'Auth token is expired' 299 | ); 300 | } 301 | else { 302 | throw new AlexaException( 303 | 'INVALID_AUTHORIZATION_CREDENTIAL', 304 | 'Auth token is invalid' 305 | ); 306 | } 307 | } 308 | console.log('Auth token is valid...'); 309 | var plaintextAuthToken = JSON.parse(response.Payload); 310 | 311 | // Cognito has both a username and a sub; a sub is unique and never 312 | // reasigned; a username can be reasigned; it is therefore important 313 | // to use the 'sub' and not the username as the User ID: 314 | var userId = plaintextAuthToken.sub; 315 | return userId; 316 | } 317 | catch (err) { 318 | console.log(`Error invoking ${VERIFY_COGNITO_TOKEN_FUNCTION}: ${err}`); 319 | throw (err); 320 | } 321 | } 322 | 323 | 324 | async function getUserEndpoints(userId) { 325 | /* 326 | This function takes a user ID obtained from the validated and not-expired auth 327 | token provided by Alexa in the function request and invokes another function 328 | that returns a list of all devices associated to that user. 329 | */ 330 | log("Getting user endpoints..."); 331 | var payload = JSON.stringify({ 332 | userId: userId 333 | }); 334 | 335 | var params = { 336 | FunctionName: GET_DEVICES_BY_USER_FUNCTION, 337 | InvocationType: "RequestResponse", 338 | Payload: payload 339 | }; 340 | 341 | log("Invoking Lambda with params: ", params); 342 | var getUserDevicesResponse = await lambda.invoke(params).promise(); 343 | 344 | log("User Device Lambda response: ", getUserDevicesResponse); 345 | var devices = (JSON.parse(getUserDevicesResponse.Payload)).deviceList; 346 | log("Devices: ", devices); 347 | /* 348 | response will contain: { 349 | thingName: "xxxx", 350 | userId: "yyyy" 351 | } 352 | */ 353 | 354 | var endpoints = []; 355 | 356 | for (const device of devices) { 357 | let params = { 358 | thingName: device.thingName 359 | }; 360 | 361 | log("Calling IoT.describeThing() with params: ", params); 362 | var iotDescription = await iot.describeThing(params).promise(); 363 | var iotAttributes = iotDescription.attributes; 364 | log("IoT Description:\n", iotDescription); 365 | var thingConfig = getDeviceConfigFromIotThingAttributes(iotAttributes); 366 | 367 | var endpoint = { 368 | endpointId: device.thingName, 369 | manufacturerName: thingConfig.manufacturerName, 370 | friendlyName: thingConfig.friendlyName, 371 | description: thingConfig.description, 372 | displayCategories: thingConfig.displayCategories, 373 | capabilities: thingConfig.capabilities, 374 | }; 375 | 376 | endpoints.push(endpoint); 377 | } 378 | log("User endpoints:\n", endpoints); 379 | return endpoints; 380 | } 381 | 382 | function getDeviceConfigFromIotThingAttributes(attributes) { 383 | console.log(`Looking up configuration for modelNumber ${attributes.modelNumber}, firmware version ${attributes.firmwareVersion}...`); 384 | 385 | if (discoveryConfig.hasOwnProperty(attributes.modelNumber)) { 386 | if (discoveryConfig[attributes.modelNumber].hasOwnProperty(attributes.firmwareVersion)) { 387 | return discoveryConfig[attributes.modelNumber][attributes.firmwareVersion]; 388 | } 389 | else { 390 | throw new Error(`Unknown firmware version ${attributes.firmwareVersion} for model ${attributes.modelNumber}, unable to get endpoint config.`); 391 | } 392 | } 393 | else { 394 | throw new Error(`Unknown model number ${attributes.modelNumber}, unable to get endpoint config.`); 395 | } 396 | } 397 | 398 | function log(message1, message2) { 399 | if (message2 == null) { 400 | console.log(message1); 401 | } else { 402 | console.log(message1 + JSON.stringify(message2, null, 2)); 403 | } 404 | } 405 | 406 | function validateSetpointIsInAllowedRange(setpoint, validRange) { 407 | 408 | // We convert to the minValue's scale; we don't bother to check whether the min and max are different 409 | // scales in the discoveryConfig.js file; it would be odd to have them different. 410 | let minValue = validRange.minimumValue.value; 411 | let maxValue = validRange.maximumValue.value; 412 | let validScale = validRange.minimumValue.scale; 413 | let desiredValue = convertTemperature(setpoint.value, setpoint.scale, validScale); 414 | 415 | if (minValue <= desiredValue && desiredValue <= maxValue) { 416 | log(`Requested setpoint of ${desiredValue} within allowed range of ${minValue} to ${maxValue} ${validScale}.`); 417 | return; 418 | } 419 | else { 420 | var errorPayload = { 421 | validRange: validRange 422 | }; 423 | throw new AlexaException( 424 | 'TEMPERATURE_VALUE_OUT_OF_RANGE', 425 | `Requested setpoint of ${desiredValue} outside allowed range of ${minValue} to ${maxValue} ${validScale}.`, 426 | errorPayload 427 | ); 428 | } 429 | 430 | } 431 | 432 | async function handleReportState(request, context, endpoint) { 433 | 434 | log('Gathering state from IoT Thing shadow to report back to Alexa...'); 435 | 436 | var endpointId = endpoint.endpointId; 437 | var token = request.directive.endpoint.scope.token; 438 | var correlationToken = request.directive.header.correlationToken; 439 | var currentState = endpoint.shadow.state.reported; 440 | 441 | // Basic response header 442 | var alexaResponse = new AlexaResponse( 443 | { 444 | "name": 'StateReport', 445 | "correlationToken": correlationToken, 446 | "token": token, 447 | "endpointId": endpointId 448 | } 449 | ); 450 | 451 | // Gather current properties and add to our response 452 | alexaResponse.addContextProperty({ 453 | namespace: "Alexa.EndpointHealth", 454 | name: "connectivity", 455 | value: { 456 | value: currentState.connectivity 457 | } 458 | }); 459 | 460 | alexaResponse.addContextProperty({ 461 | namespace: "Alexa.ThermostatController", 462 | name: "targetSetpoint", 463 | value: { 464 | value: currentState.targetSetpoint.value, 465 | scale: currentState.targetSetpoint.scale 466 | } 467 | }); 468 | 469 | alexaResponse.addContextProperty({ 470 | namespace: "Alexa.ThermostatController", 471 | name: "thermostatMode", 472 | value: currentState.thermostatMode 473 | }); 474 | 475 | alexaResponse.addContextProperty({ 476 | namespace: "Alexa.TemperatureSensor", 477 | name: "temperature", 478 | value: currentState.temperature 479 | }); 480 | 481 | return alexaResponse.get(); 482 | } 483 | 484 | async function handleThermostatControl(request, context, endpoint) { 485 | /* This function handles all requests that Alexa identifies as being a 486 | "ThermostatController" directive, such as: 487 | - Turn device to cool mode 488 | - Turn device to heat mode 489 | - Increase device temperature 490 | - Decrease device temperature 491 | - Set temperature to X degrees 492 | */ 493 | var endpointId = endpoint.endpointId; 494 | var thingName = endpoint.thingName; 495 | var token = request.directive.endpoint.scope.token; 496 | var correlationToken = request.directive.header.correlationToken; 497 | var requestMethod = request.directive.header.name; 498 | var payload = request.directive.payload; 499 | 500 | var alexaResponse = new AlexaResponse( 501 | { 502 | "correlationToken": correlationToken, 503 | "token": token, 504 | "endpointId": endpointId 505 | } 506 | ); 507 | 508 | log(`Running ThermostatControl handler for ${requestMethod} method`); 509 | 510 | if (requestMethod === 'SetTargetTemperature') { 511 | 512 | var targetSetpoint = payload.targetSetpoint; 513 | 514 | validateSetpointIsInAllowedRange(targetSetpoint, endpoint.config.validRange); 515 | 516 | let shadowState = { 517 | state: { 518 | desired: { 519 | targetSetpoint: { 520 | value: targetSetpoint.value, 521 | scale: targetSetpoint.scale 522 | } 523 | } 524 | } 525 | }; 526 | 527 | // For debugging purposes, we may choose to copy the desired state to reported state: 528 | if (copyDesiredStateToReportedStateInShadow === true) { 529 | shadowState.state.reported = shadowState.state.desired; 530 | } 531 | 532 | await updateThingShadow(thingName, shadowState); 533 | 534 | var targetpointContextProperty = { 535 | namespace: "Alexa.ThermostatController", 536 | name: "targetSetpoint", 537 | value: { 538 | value: targetSetpoint.value, 539 | scale: targetSetpoint.scale 540 | } 541 | }; 542 | alexaResponse.addContextProperty(targetpointContextProperty); 543 | return alexaResponse.get(); 544 | 545 | } 546 | else if (requestMethod === 'AdjustTargetTemperature') { 547 | 548 | var currentSetpoint = endpoint.shadow.state.reported.targetSetpoint; 549 | var currentValue = currentSetpoint.value; 550 | var currentScale = currentSetpoint.scale; 551 | 552 | var targetSetpointDelta = payload.targetSetpointDelta; 553 | var deltaValue = targetSetpointDelta.value; 554 | var deltaScale = targetSetpointDelta.scale; 555 | 556 | log('Current setpoint:', currentSetpoint); 557 | log('Target delta:', targetSetpointDelta); 558 | 559 | // It's possible that the requested temperature change is in a different 560 | // scale from that being used/reported by the device. In such a case, 561 | // the Alexa guide states we should report the new adjusted value in the 562 | // device's scale. When the scales are different, we must convert the 563 | // current temperature to the scale of the requested delta in order to 564 | // add (or subtract) the delta from the current temperature to arrive 565 | // at the new desired temperature. Then, we must convert this new value 566 | // back to the temp scale currently in use by the device. If the current 567 | // and delta scales are the same (e.g. both Fahrenheit or both Celsius), 568 | // then the convertTemperature() function simply returns the same value 569 | // it was provided, without modification. 570 | // https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html#adjusttargettemperature-directive 571 | var currentValueInDeltaScale = convertTemperature(currentValue, currentScale, deltaScale); 572 | var newValueInDeltaScale = currentValueInDeltaScale + deltaValue; 573 | var newValueInCurrentScale = convertTemperature(newValueInDeltaScale, deltaScale, currentScale); 574 | 575 | var newTargetSetpoint = { 576 | value: newValueInCurrentScale, 577 | scale: currentScale 578 | }; 579 | 580 | let shadowState = { 581 | state: { 582 | desired: { 583 | targetSetpoint: newTargetSetpoint 584 | } 585 | } 586 | }; 587 | 588 | // For debugging purposes, we may choose to copy the desired state to reported state: 589 | if (copyDesiredStateToReportedStateInShadow === true) { 590 | shadowState.state.reported = shadowState.state.desired; 591 | } 592 | 593 | await updateThingShadow(thingName, shadowState); 594 | 595 | alexaResponse.addContextProperty({ 596 | namespace: "Alexa.ThermostatController", 597 | name: "targetSetpoint", 598 | value: newTargetSetpoint 599 | }); 600 | 601 | return alexaResponse.get(); 602 | 603 | } 604 | else if (requestMethod === 'SetThermostatMode') { 605 | 606 | var thermostatMode = payload.thermostatMode; 607 | 608 | let shadowState = { 609 | state: { 610 | desired: { 611 | thermostatMode: thermostatMode.value 612 | } 613 | } 614 | }; 615 | 616 | // For debugging purposes, we may choose to copy the desired state to reported state: 617 | if (copyDesiredStateToReportedStateInShadow === true) { 618 | shadowState.state.reported = shadowState.state.desired; 619 | } 620 | 621 | await updateThingShadow(thingName, shadowState); 622 | 623 | // Context properties are how we affirmatively tell Alexa that the state 624 | // of our device after we have successfully completed the requested changes. 625 | // The not required, it is recommended that *all* properties be reported back, 626 | // regardless of whether they were changed. 627 | // At the moment, we are only reporting back the changed property. 628 | alexaResponse.addContextProperty({ 629 | namespace: "Alexa.ThermostatController", 630 | name: "thermostatMode", 631 | value: thermostatMode.value 632 | }); 633 | 634 | return alexaResponse.get(); 635 | } 636 | else { 637 | log(`ERROR: Unsupported request method ${requestMethod} for ThermostatController.`); 638 | throw new AlexaException( 639 | 'INTERNAL_ERROR', 640 | 'Unsupported request method ${requestMethod} for ThermostatController' 641 | ); 642 | } 643 | } 644 | 645 | function convertTemperature(temperature, currentScale, desiredScale) { 646 | if (currentScale === desiredScale) { 647 | return temperature; 648 | } 649 | else if (currentScale === 'FAHRENHEIT' && desiredScale === 'CELSIUS') { 650 | return convertFahrenheitToCelsius(temperature); 651 | } 652 | else if (currentScale === 'CELSIUS' && desiredScale === 'FAHRENHEIT') { 653 | return convertCelsiusToFahrenheit(temperature); 654 | } 655 | else { 656 | throw new Error(`Unable to convert ${currentScale} to ${desiredScale}, unsupported temp scale.`); 657 | } 658 | 659 | } 660 | 661 | function convertCelsiusToFahrenheit(celsius) { 662 | var fahrenheit = Math.round((celsius * 1.8) + 32); 663 | log(`Converted temperature to ${fahrenheit} FAHRENHEIT.`); 664 | return fahrenheit; 665 | } 666 | 667 | function convertFahrenheitToCelsius(fahrenheit) { 668 | var celsius = Math.round((fahrenheit - 32) * 0.556); 669 | log(`Converted temperature to ${celsius} CELSIUS.`); 670 | return celsius; 671 | } 672 | 673 | async function getDeviceShadow(thingName) { 674 | // Get the device's reported state per the state.reported object of the 675 | // corresponding IoT thing's device shadow. 676 | var params = { 677 | thingName: thingName 678 | }; 679 | log('Calling iotdata.getThingShadow() with params:', params); 680 | var response = await iotdata.getThingShadow(params).promise(); 681 | var shadow = JSON.parse(response.payload); 682 | log('getThingShadow() response:', shadow); 683 | return shadow; 684 | 685 | } 686 | 687 | async function updateThingShadow(thingName, shadowState) { 688 | try { 689 | var params = { 690 | payload: JSON.stringify(shadowState) /* Strings will be Base-64 encoded on your behalf */, /* required */ 691 | thingName: thingName /* required */ 692 | }; 693 | // Updating shadow updates our *control plane*, but it doesn't necessarily 694 | // mean our device state has changed. The device is responsible for monitoring 695 | // the device shadow and responding to changes in desired states. 696 | log(`Calling iotdata.updateThingShadow() with params:`, params); 697 | var updateShadowResponse = await iotdata.updateThingShadow(params).promise(); 698 | log(`Shadow update response:\n`, JSON.parse(updateShadowResponse.payload)); 699 | } 700 | catch (err) { 701 | console.log('Error: unable to update device shadow:', err); 702 | throw (err); 703 | } 704 | } 705 | 706 | function sendResponse(response) { 707 | log("Lambda response to Alexa Cloud:\n", response); 708 | return response 709 | } 710 | 711 | function sendErrorResponse(type, message, additionalPayload) { 712 | log("Preparing error response to Alexa Cloud..."); 713 | var payload = { 714 | "type": type, 715 | "message": message 716 | }; 717 | // Certain Alexa error response types allow or require additional parameters in the payload response 718 | if (additionalPayload !== undefined) { 719 | payload = { ...payload, ...additionalPayload } 720 | } 721 | var alexaErrorResponse = new AlexaResponse({ 722 | "name": "ErrorResponse", 723 | "payload": payload 724 | }); 725 | return sendResponse(alexaErrorResponse.get()); 726 | } 727 | 728 | function AlexaException(name, message, additionalPayload) { 729 | log('Creating handled Alexa exception...'); 730 | // The error name should be one of the Alexa.ErrorResponse interface types: 731 | // https://developer.amazon.com/docs/device-apis/alexa-errorresponse.html 732 | var error = new Error(); 733 | this.stack = error.stack; 734 | this.name = name; 735 | this.message = message; 736 | this.AlexaError = true; 737 | this.additionalPayload = checkValue(additionalPayload, undefined); 738 | } 739 | 740 | function checkValue(value, defaultValue) { 741 | 742 | if (value === undefined || value === {} || value === "") { 743 | return defaultValue; 744 | } 745 | return value; 746 | } --------------------------------------------------------------------------------