├── .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 | };
--------------------------------------------------------------------------------