├── .gitignore ├── README.md ├── client.js ├── index.html ├── package-lock.json ├── package.json ├── server.js ├── specs ├── const.js ├── description.js └── eventService.js ├── src ├── device.js ├── deviceResponses.js ├── handler.js ├── index.jsx └── style.css ├── web.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node,intellij+all,visualstudiocode 3 | 4 | ### Intellij+all ### 5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 7 | 8 | # User-specific stuff: 9 | .idea/**/workspace.xml 10 | .idea/**/tasks.xml 11 | .idea/dictionaries 12 | 13 | # Sensitive or high-churn files: 14 | .idea/**/dataSources/ 15 | .idea/**/dataSources.ids 16 | .idea/**/dataSources.xml 17 | .idea/**/dataSources.local.xml 18 | .idea/**/sqlDataSources.xml 19 | .idea/**/dynamic.xml 20 | .idea/**/uiDesigner.xml 21 | 22 | # Gradle: 23 | .idea/**/gradle.xml 24 | .idea/**/libraries 25 | 26 | # CMake 27 | cmake-build-debug/ 28 | 29 | # Mongo Explorer plugin: 30 | .idea/**/mongoSettings.xml 31 | 32 | ## File-based project format: 33 | *.iws 34 | 35 | ## Plugin-specific files: 36 | 37 | # IntelliJ 38 | /out/ 39 | 40 | # mpeltonen/sbt-idea plugin 41 | .idea_modules/ 42 | 43 | # JIRA plugin 44 | atlassian-ide-plugin.xml 45 | 46 | # Cursive Clojure plugin 47 | .idea/replstate.xml 48 | 49 | # Ruby plugin and RubyMine 50 | /.rakeTasks 51 | 52 | # Crashlytics plugin (for Android Studio and IntelliJ) 53 | com_crashlytics_export_strings.xml 54 | crashlytics.properties 55 | crashlytics-build.properties 56 | fabric.properties 57 | 58 | ### Intellij+all Patch ### 59 | # Ignores the whole idea folder 60 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 61 | 62 | .idea/ 63 | 64 | ### Node ### 65 | # Logs 66 | logs 67 | *.log 68 | npm-debug.log* 69 | yarn-debug.log* 70 | yarn-error.log* 71 | 72 | # Runtime data 73 | pids 74 | *.pid 75 | *.seed 76 | *.pid.lock 77 | 78 | # Directory for instrumented libs generated by jscoverage/JSCover 79 | lib-cov 80 | 81 | # Coverage directory used by tools like istanbul 82 | coverage 83 | 84 | # nyc test coverage 85 | .nyc_output 86 | 87 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 88 | .grunt 89 | 90 | # Bower dependency directory (https://bower.io/) 91 | bower_components 92 | 93 | # node-waf configuration 94 | .lock-wscript 95 | 96 | # Compiled binary addons (http://nodejs.org/api/addons.html) 97 | build/Release 98 | 99 | # Dependency directories 100 | node_modules/ 101 | jspm_packages/ 102 | 103 | # Typescript v1 declaration files 104 | typings/ 105 | 106 | # Optional npm cache directory 107 | .npm 108 | 109 | # Optional eslint cache 110 | .eslintcache 111 | 112 | # Optional REPL history 113 | .node_repl_history 114 | 115 | # Output of 'npm pack' 116 | *.tgz 117 | 118 | # Yarn Integrity file 119 | .yarn-integrity 120 | 121 | # dotenv environment variables file 122 | .env 123 | 124 | 125 | ### VisualStudioCode ### 126 | .vscode/* 127 | !.vscode/settings.json 128 | !.vscode/tasks.json 129 | !.vscode/launch.json 130 | !.vscode/extensions.json 131 | .history 132 | 133 | static/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Amazon Alexa compatible device emulator 2 | 3 | This is a simple proof of concept software to enable local interaction between an Amazon Alexa device (such as Echo, Echo Dot, etc.), and a web server. 4 | The number of commands Alexa can run on this server is determined by the emulated device type. 5 | 6 | For instance, the default device emulated is a Belkin Switch which has 2 states: on and off. 7 | 8 | This allow us to call the API from within Alexa and turn on/off our emulated switch. 9 | 10 | It is important to point out that **no Alexa skill is necessary**. 11 | 12 | ## Setup 13 | 14 | - Clone the repository ([github.com/fechy/alexa-upnp-device-emulator.git](https://github.com/fechy/alexa-upnp-device-emulator.git)) 15 | - Run `npm install` 16 | 17 | ## Scripts 18 | 19 | There is 2 essential scripts running: `server.js` and `web.js`, and a third non-essential script `client.js`. 20 | 21 | - **Server.js**: 22 | This is the uPnP server listening and answering to the `ssdp:discovery` events sent by other devices looking for devices. 23 | After Alexa has successfully detected the device, you can safelly close this script. I'll recommend closing it after Alexa discover the device. Other devices in your network might keep hitting your server looking for new devices unnecessarily. 24 | 25 | - **Web.js**: 26 | The API providing the endpoints necessary for answering Alexa's requests. 27 | 28 | - **Client.js**: 29 | Simple script to search for compatible uPnP devices on the network and printing them on the console. This is useful to know whats in our network and how we can interact with it. 30 | 31 | ## Usage 32 | 33 | Simply run on 2 different console windows, one with the server: 34 | 35 | ```bash 36 | npm run server 37 | ``` 38 | 39 | If you want to know what's the script doing, run like this: 40 | 41 | ```bash 42 | npm run server-debug 43 | ``` 44 | 45 | ... and other window with the web API and a simple UI: 46 | 47 | ```bash 48 | npm run start 49 | ``` 50 | 51 | This will enable a simple UI located in `localhost:8080` showing the status of the emulated switch. 52 | 53 | Once you have both scripts running, run "discover devices" on Alexa, either by the Alexa app or telling alexa to "discover devices". 54 | 55 | Once the device is dicovered (The name can be changed on `specs/const.js`; default is "emulated switch"). Now you should be able to turn on/off the device and the calls should be outputted in the API's console and displayed on the UI. 56 | 57 | ## Throubleshooting 58 | 59 | Since this script relies on having port **1900** open, you should allow `node` to send and receive from this port. If the system is not asking you whether you'll like to give permission, you should manually allow it on your firewall. 60 | 61 | ## Limitations 62 | 63 | Currently it only works emulating a Belkin switch and the actions are limited to toggle on and off. -------------------------------------------------------------------------------- /client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple script to scan the network looking for uPnP devices 3 | */ 4 | const Client = require('node-ssdp').Client; 5 | const client = new Client(); 6 | 7 | client.on('notify', function () { 8 | console.log('Got a notification.') 9 | }) 10 | 11 | client.on('response', function inResponse(headers, code, rinfo) { 12 | console.log('Got a response to an m-search:\n%d\n%s\n%s', code, JSON.stringify(headers, null, ' '), JSON.stringify(rinfo, null, ' ')) 13 | }); 14 | 15 | client.search('ssdp:all'); 16 | 17 | // Or maybe if you want to scour for everything after 10 seconds 18 | setInterval(function() { 19 | client.search('ssdp:all'); 20 | }, 10000); 21 | 22 | process.on('exit', function(){ 23 | client.stop(); 24 | }); -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Hello Alexa 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alexa-upnp-device-emulator", 3 | "version": "0.0.1", 4 | "private": true, 5 | "description": "Simple Alexa device emulator", 6 | "main": "web.js", 7 | "scripts": { 8 | "server": "node server.js", 9 | "server-debug": "DEBUG=node-ssdp:server node server.js", 10 | "client": "node client.js", 11 | "start": "webpack --mode=development && node web.js", 12 | "api": "node web.js" 13 | }, 14 | "author": "Fernando Giovanini", 15 | "license": "ISC", 16 | "dependencies": { 17 | "body-parser": "^1.19.0", 18 | "css-loader": "^3.2.0", 19 | "dgram": "^1.0.1", 20 | "express": "^4.17.1", 21 | "node-ssdp": "^4.0.0", 22 | "node-upnp-ssdp": "^0.1.1", 23 | "npm": "^6.14.6", 24 | "react": "^16.11.0", 25 | "react-dom": "^16.11.0", 26 | "socket.io": "^2.3.0", 27 | "style-loader": "^1.0.0" 28 | }, 29 | "devDependencies": { 30 | "babel-core": "6.26.3", 31 | "babel-loader": "7.1.5", 32 | "babel-preset-es2015": "^6.24.1", 33 | "babel-preset-react": "^6.24.1", 34 | "classnames": "^2.2.6", 35 | "webpack": "^4.41.2", 36 | "webpack-cli": "^3.3.10", 37 | "webpack-dev-server": "^3.9.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const { name, uuid, descriptionFile } = require('./specs/const'); 2 | const Server = require('node-ssdp').Server; 3 | 4 | const ipAddress = require('ip').address(); 5 | const serverPort = process.env.PORT || 8080; 6 | 7 | const server = new Server({ 8 | udn: `uuid:${uuid}`, 9 | location: `http://${ipAddress}:${serverPort}/${descriptionFile}`, 10 | ssdpPort: 1900, 11 | sourcePort: 1900 12 | }); 13 | 14 | // server.addUSN('upnp:rootdevice'); 15 | server.addUSN('urn:Belkin:device:**'); 16 | // server.addUSN('ssdp:all'); 17 | server.addUSN('ssdp:discovery'); 18 | 19 | // server.on('notify', (...props) => console.log('Notify', props)); 20 | // server.on('m-search', (...props) => console.log('m-search', props)); 21 | // server.on('advertise-alive', (...props) => console.log('advertise-alive', props)); 22 | // server.on('advertise-bye', (...props) => console.log('advertise-bye', props)); 23 | 24 | // start the server 25 | server.start(); 26 | 27 | process.on('exit', function() { 28 | server.stop() // advertise shutting down and stop listening 29 | }); -------------------------------------------------------------------------------- /specs/const.js: -------------------------------------------------------------------------------- 1 | const NAME = 'emulated switch'; 2 | const UUID = 'f40c2981-7329-40b7-8b04-27f187aecfb5'; 3 | const descriptionFile = 'description.xml'; 4 | const eventServiceFile = 'eventservice.xml'; 5 | 6 | module.exports = { 7 | name: NAME, 8 | uuid: UUID, 9 | descriptionFile: descriptionFile, 10 | eventServiceFile: eventServiceFile 11 | }; -------------------------------------------------------------------------------- /specs/description.js: -------------------------------------------------------------------------------- 1 | const { eventServiceFile } = require('./const'); 2 | 3 | const deviceType = 'urn:Belkin:device:controllee:1'; 4 | const modelNumber = '3.1415'; 5 | const modelName = 'Emulated Thing'; 6 | 7 | const descriptionXML = (name, uuid) => ( 8 | ` 9 | 10 | 11 | ${deviceType} 12 | ${name} 13 | Belkin International Inc. 14 | ${modelName} 15 | ${modelNumber} 16 | uuid:Socket-1_0-${uuid} 17 | 18 | 19 | urn:Belkin:service:basicevent:1 20 | urn:Belkin:serviceId:basicevent1 21 | /upnp/control/basicevent1 22 | /upnp/event/basicevent1 23 | /${eventServiceFile} 24 | 25 | 26 | 27 | ` 28 | ); 29 | 30 | module.exports = descriptionXML; -------------------------------------------------------------------------------- /specs/eventService.js: -------------------------------------------------------------------------------- 1 | const eventServiceXML = () => ( 2 | ` 3 | 4 | 5 | SetBinaryState 6 | 7 | 8 | 9 | BinaryState 10 | BinaryState 11 | in 12 | 13 | 14 | 15 | 16 | 17 | 18 | BinaryState 19 | Boolean 20 | 0 21 | 22 | 23 | level 24 | string 25 | 0 26 | 27 | 28 | 29 | ` 30 | ); 31 | 32 | module.exports = eventServiceXML; -------------------------------------------------------------------------------- /src/device.js: -------------------------------------------------------------------------------- 1 | class Device { 2 | 3 | constructor(name, uuid) { 4 | this.name = name; 5 | this.uuid = uuid; 6 | 7 | this.state = { 8 | value: 0 9 | }; 10 | } 11 | 12 | setState (newState) { 13 | this.state.value = newState; 14 | } 15 | 16 | getState () { 17 | return this.state.value; 18 | } 19 | 20 | toggleState () { 21 | const state = this.getState(); 22 | this.setState(state == 1 ? 0 : 1); 23 | return this.getState(); 24 | } 25 | } 26 | 27 | module.exports = Device; -------------------------------------------------------------------------------- /src/deviceResponses.js: -------------------------------------------------------------------------------- 1 | const setBinaryStateResponse = (value) => ( 2 | ` 3 | 4 | 5 | 6 | ${value} 7 | 8 | 9 | ` 10 | ); 11 | 12 | const getBinaryStateResponse = (value) => ( 13 | ` 14 | 15 | 16 | 17 | ${value} 18 | 19 | 20 | ` 21 | ); 22 | 23 | module.exports = { 24 | getBinaryStateResponse, 25 | setBinaryStateResponse 26 | }; -------------------------------------------------------------------------------- /src/handler.js: -------------------------------------------------------------------------------- 1 | class Handler 2 | { 3 | constructor(device) { 4 | this.device = device; 5 | } 6 | 7 | handle (action) { 8 | switch(action) { 9 | case 'GetBinaryState': 10 | return this.device.getState(); 11 | case 'SetBinaryState': 12 | return this.device.toggleState(); 13 | default: 14 | console.log(`Invalid action: "${action}"`); 15 | return null; 16 | } 17 | } 18 | } 19 | 20 | module.exports = Handler; -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import classnames from 'classnames'; 4 | 5 | const socket = io(); 6 | 7 | import './style.css'; 8 | 9 | class App extends React.Component 10 | { 11 | constructor(props) { 12 | super(props); 13 | 14 | this.state = { 15 | state: false 16 | }; 17 | 18 | this._handleRequest = this._handleRequest.bind(this); 19 | this._handleStateChange = this._handleStateChange.bind(this); 20 | 21 | socket.on('request-service', (newCallState) => this._handleRequest(newCallState)); 22 | socket.on('request-setup', (newCallState) => this._handleRequest(newCallState)); 23 | socket.on('request-action', (newCallState) => this._handleStateChange(newCallState)); 24 | } 25 | 26 | _handleRequest(value) { 27 | console.log(value); 28 | } 29 | 30 | _handleStateChange(value) { 31 | if (value.action === 'SetBinaryState' || value.action === 'GetBinaryState') { 32 | this.setState({ state: value.result == 1 ? true : false }); 33 | } 34 | } 35 | 36 | render() { 37 | const status = this.state.state ? "on" : "off"; 38 | return ( 39 |
40 |
41 |
42 |

SWITCH ME {this.state.state ? "OFF" : "ON"}!

43 |
44 | {status} 45 |
46 |

Go to your Alexa app and switch

47 |
48 |
49 |
50 | ) 51 | } 52 | } 53 | 54 | ReactDOM.render(, document.getElementById('root')); -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial, Helvetica, sans-serif; 3 | } 4 | 5 | .container { 6 | display: flex; 7 | flex-direction: column; 8 | flex-flow: row wrap; 9 | justify-content: space-around; 10 | } 11 | 12 | .item { 13 | align-self: auto; 14 | text-align: center; 15 | } 16 | 17 | .status { 18 | font-size: 4em; 19 | text-transform: uppercase; 20 | line-height: 150px; 21 | margin: 0 auto; 22 | width: 150px; 23 | height: 150px; 24 | border-radius: 50%; 25 | border: 10px solid #000; 26 | } 27 | 28 | .on { 29 | background-color: gold; 30 | } 31 | 32 | .off { 33 | background-color: #F00; 34 | } -------------------------------------------------------------------------------- /web.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const bodyParser = require('body-parser') 3 | const express = require('express'); 4 | const app = express(); 5 | const port = process.env.PORT || 8080; // If changed, it should also be changed in the server 6 | 7 | // Project imports 8 | const { name, uuid, descriptionFile, eventServiceFile } = require('./specs/const'); 9 | const Device = require('./src/device'); 10 | const Handler = require('./src/handler'); 11 | 12 | // Create a new Handler and a new Device 13 | const device = new Device(name, uuid); 14 | const requestHandler = new Handler(device); 15 | 16 | const descriptionXML = require('./specs/description'); 17 | const eventServiceXML = require('./specs/eventService'); 18 | const { getBinaryStateResponse, setBinaryStateResponse } = require('./src/deviceResponses'); 19 | 20 | /** 21 | * Simple Soap Action parser 22 | * @param {String} soapaction 23 | */ 24 | const getSoapAction = function (soapaction) { 25 | const parts = soapaction.split("#"); 26 | if (parts.length < 2) { 27 | return soapaction; 28 | } 29 | 30 | return parts[1].replace('"', ''); 31 | } 32 | 33 | const server = app.listen(port, (err) => { 34 | if (err) { 35 | return console.log('something bad happened', err); 36 | } 37 | 38 | console.log(`server is listening on ${port}`); 39 | }); 40 | 41 | const io = require('socket.io')(server); 42 | 43 | // Set socket.io listeners. 44 | io.on('connection', (socket) => { 45 | // console.log('user connected'); 46 | socket.on('disconnect', () => { 47 | // console.log('user disconnected'); 48 | }); 49 | }); 50 | 51 | app.use(express.static('static')); 52 | 53 | // parse application/x-www-form-urlencoded 54 | app.use(bodyParser.urlencoded({ extended: false })); 55 | 56 | // parse application/json 57 | app.use(bodyParser.json()); 58 | 59 | // Web GUI 60 | app.get('/', (request, response) => { 61 | response.sendFile(__dirname + '/index.html'); 62 | }); 63 | 64 | // Calls to this endpoint will be made after contacting the server via M-SEARCH 65 | app.get(`/${descriptionFile}`, (request, response) => { 66 | console.log(request.originalUrl, request.headers, request.body); 67 | 68 | io.emit('request-setup', { host: request.get('host'), headers: request.headers }); 69 | 70 | response.status(200) 71 | .header("Content-Type", "text/xml") 72 | .send(descriptionXML(device.name, device.uuid)); 73 | }); 74 | 75 | // Currently without use 76 | app.get(`/${eventServiceFile}`, (request, response) => { 77 | console.log(request.originalUrl, request.headers, request.body); 78 | 79 | io.emit('request-service', { host: request.get('host'), headers: request.headers }); 80 | 81 | response.status(200) 82 | .header("Content-Type", "text/xml") 83 | .send(eventServiceXML()); 84 | }); 85 | 86 | // Alexa will call this enpoint with the soap action and we should return an envelope with the result 87 | app.post('/upnp/control/basicevent1', (request, response) => { 88 | const action = getSoapAction(request.headers.soapaction); 89 | const value = requestHandler.handle(action); 90 | 91 | const result = action == 'GetBinaryState' ? getBinaryStateResponse(value) : setBinaryStateResponse(value); 92 | 93 | console.log(request.originalUrl, request.headers, request.body); 94 | 95 | io.emit('request-action', { host: request.get('host'), headers: request.headers, action, result: value }); 96 | 97 | response.status(200) 98 | .header("Content-Type", "text/xml") 99 | .send(result); 100 | }); 101 | 102 | // Just for debug, to check whats being asked... 103 | // app.get('*', (request, response) => { 104 | // console.log(request.originalUrl, request.headers); 105 | // response.status(200).send(''); 106 | // }); 107 | 108 | // Just for debug, to check whats being asked... 109 | app.post('*', (request, response) => { 110 | console.log(request.originalUrl, request.body); 111 | response.status(200).send(''); 112 | }); -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: './src/index.jsx', 6 | output: { 7 | path: __dirname, 8 | filename: 'static/bundle.js' 9 | }, 10 | resolve: { 11 | extensions: ['*', '.js', '.jsx'] 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.jsx$|\.js$/, 17 | loader: 'babel-loader', 18 | exclude: /node_modules/, 19 | query: { 20 | presets: ['es2015', 'react'] 21 | } 22 | }, 23 | { 24 | test: /\.css$/, 25 | use: [ 'style-loader', 'css-loader' ], 26 | } 27 | ] 28 | }, 29 | }; --------------------------------------------------------------------------------