Integrating the Amazon Echo to an AMX or other Home Control System

08. August 2017 Programming 2
Integrating the Amazon Echo to an AMX or other Home Control System

I originally wanted to write a detailed guide on how to integrate Alexa with an AMX system after posting a video showing my proof of concept. However I hacked this together a LONG time ago and while it still works, there are MUCH better ways to integrate Alexa with control systems given new features that have been provided for Alexa developers. This method is quick and DIRTY but since I keep getting emails about it, I’ve decided to post some quick instructions. Do what you like with the information.

Step 1 – Alexa Config

Get an Amazon Developer account, log into your console and go to “Alexa”.  You are going to create a skill and there will be seven sections that look like this…

 

Let’s go through the sections.

Skill Information

Skill Type – Should be “Custom”.

Language – Self explanatory.

Application Id – Take note of this.

Name – What your skill is named.

Invocation Name – What you holler at the tube to make it go.

Global Fields – All set to “No”.

Interaction Model

This is what is a little funky.  We aren’t really doing anything server-side.  We are just passing everything through.  Therefore, everybody should have the information below if you are going to do this the “dirty” way.  I was turned onto this method by a post from Matt Farley who’s Jarvis system is far more impressive than my AMX hack.

Intent Schema:

{
 "intents": [
 {
 "slots": [
 {
 "name": "command",
 "type": "LITERAL"
 }
 ],
 "intent": "DoCommand"
 }
 ]
}

Sample Utterances:

DoCommand {script parameters|command}

That is seriously it from the Alexa side as far as code goes.

Configuration

This is the start of the kludgey part in way of AMX specific information.

Endpoint:

Set to HTTPS.  We will be sending Alexa information to our own “Server” which is the AMX controller (kind of). You can also program your own Lambda code to do this server-side.  But that is not covered here.

Geographical Location:

For me it is North America.

Address:

https://yourhomeaddress:yourportnumber

Note that the only method is HTTPS or Lambda.  You cannot use simple HTTP.  Because of this, we either need to interpret HTTPS on the AMX controller (not EASILY possible because the AMX already hosts HTTPS for its internal pages), or proxy the traffic.  Yes, AMX can CONNECT over SSL through code.  However, we need to act as a host.  To get around this, I use a program called STunnel on my Linux box.  You can easily run this on a Raspberry Pi to basically act as a bridge from HTTPS to HTTP.  So the transmission goes like this…

ALEXA –> HOME SERVER RUNNING STUNNEL –> AMX

AMX –> HOME SERVER RUNNING STUNNEL –> ALEXA

Yes, it does work, and it works quite well!

Account Linking – No

Permissions – All Unchecked

SSL Certificate

You want to choose “I will upload a self-signed certificate” and then you are going to paste the certificate in X.509 format.  Follow the instructions on this page to do that.  This is the certificate that will be created and used by the STunnel computer.

Test

This is as far as we are going to go.  We are only using this for personal use and not publishing the app so we can leave it in Test mode.  Handy utilities are on this page as well.

Step 2 – STunnel

You’ll want to have the following configuration in your /etc/stunnel/stunnel.conf file.

cert = /etc/stunnel/stunnel.pem
pid = /var/run/stunnel.pid

sslVersion = TLSv1

[ssl]
client = no
accept = SERVER_IP:PORT //This is the address of Pi or device running STunnel (localhost)
connect = AMX_IP:PORT

cert will point to the private key that you copy and pasted into the Alexa SSL Certificate section.

Step 3 – Netlinx

The communication to and from the Alexa Service is handled over JSON.  It would be much easier to do this over Java if you are familiar with Duet but if not (like me), you’ll need to manually create the HTTP headers and such.  Here is some sample code to get you started. It’s sloppy and was mostly just thrown together from experimentation.  Have at it.

Define the IP Device

DEFINE_DEVICE
dvEchoServer = 0:3:0 //Amazon Echo

Define Some Constants

DEFINE_CONSTANT
INTEGER nEchoPort = 8443 //Internal Port for AMX controller from STunnel

Start the Server

DEFINE_START
IP_SERVER_OPEN(dvEchoServer.PORT,nEchoPort,IP_TCP)

Creat a Handler for the Data Event of Incoming Text from STunnel

DATA_EVENT[dvEchoServer]{ 
     ONLINE:{ 
     } 
     OFFLINE:{ 
          WAIT 10{ 
               IP_SERVER_OPEN(dvEchoServer.PORT,nEchoPort,IP_TCP) 
          } 
     } 
     STRING:{ 
          cEchoBuffer = "cEchoBuffer,DATA.TEXT"
     }
}

Define a function that we can use to respond to incoming Alexa requests

DEFINE_FUNCTION CHAR fEchoRespond(CHAR cResponse[500],CHAR cEndSession[5]){
    STACK_VAR CHAR cTempHTTP[1000]

    cTempHTTP = "'{"version" : "1.0","response" : {"outputSpeech" : {"type" : "PlainText","text" : "',cResponse,'"},"shouldEndSession" : ',cEndSession,'}}'"
 
    SEND_STRING dvEchoServer,"'HTTP/1.1 200 OK',$0D,$0A"
    SEND_STRING dvEchoServer,"'Connection: Close',$0D,$0A"
    SEND_STRING dvEchoServer,"'Content-Type: application/json;charset=UTF-8',$0D,$0A"
    SEND_STRING dvEchoServer,"'Content-Length: ',ITOA(LENGTH_STRING(cTempHttp)),$0D,$0A,$0D,$0A"
    SEND_STRING dvEchoServer,"cTempHttp"
}

Get Creative!

DEFINE_FUNCTION fParseEcho(){
	IF(FIND_STRING(cEchoBuffer,"'"command","value":"'",1)){
		STACK_VAR CHAR cEchoIncoming[6000]
		STACK_VAR CHAR cEchoCommand[2000]
		
		REMOVE_STRING(cEchoBuffer,"'"command","value":"'",1)
		cEchoCommand = REMOVE_STRING(cEchoBuffer,'"',1)
		SET_LENGTH_STRING(cEchoCommand,LENGTH_STRING(cEchoCommand)-1)
		cEchoCommand = UPPER_STRING(cEchoCommand)
		
		SEND_STRING vdvDebug,"cEchoCommand"
		
		SWITCH(nEchoTree){
			CASE 0:{ //Initial Command
				SELECT{
					ACTIVE(FIND_STRING(cEchoCommand,'COLD',1)):{
						IF([dvHVAC_1,221]){ //Heat Season
							SEND_LEVEL dvHVAC_1,3,(nHeatSet+2)
							fEchoRespond("'Max has raised the set point by 2 degrees.  The thermostat is currently set to ',ITOA(nHeatSet-118),'.'",'true')
						}
						ELSE IF([dvHVAC_1,220]){ //Cool Season
							SEND_LEVEL dvHVAC_1,4,(nColdSet+2)
							fEchoRespond("'Max has raised the set point by 2 degrees.  The thermostat is currently set to ',ITOA(nColdSet-118),'.'",'true')
						}
					}
					ACTIVE(FIND_STRING(cEchoCommand,'HOT',1)):{
						IF([dvHVAC_1,221]){ //Heat Season
							SEND_LEVEL dvHVAC_1,3,(nHeatSet-2)
							fEchoRespond("'Max has lowered the set point by 2 degrees.  The thermostat is currently set to ',ITOA(nHeatSet-122),'.'",'true')
						}
						ELSE IF([dvHVAC_1,220]){ //Cool Season
							SEND_LEVEL dvHVAC_1,4,(nColdSet-2)
							fEchoRespond("'Max has lowered the set point by 2 degrees.  The thermostat is currently set to ',ITOA(nColdSet-122),'.'",'true')
						}
					}
					ACTIVE(FIND_STRING(cEchoCommand,'SECURITY SYSTEM',1)):{
						IF(!nSecArmState){
							SET_PULSE_TIME(10)
							PULSE[dcRelay]
							SET_PULSE_TIME(5)
							fEchoRespond("'Max has armed the security system.  You have 90 seconds to exit.'",'true')
						}
						ELSE{
							fEchoRespond("'Your security system is already armed.'",'true')
						}
					}
					ACTIVE(FIND_STRING(cEchoCommand,'CAMERAS',1)):{
						DO_PUSH_TIMED(dvR4_1,nSourceButtons[6],1)
						fEchoRespond("'Max has switched the TV to the security cameras.'",'true')
					}
					ACTIVE(FIND_STRING(cEchoCommand,'ROKU',1)):{
						DO_PUSH_TIMED(dvR4_1,nSourceButtons[2],1)
						fEchoRespond("'Max has switched the TV to the Roku.'",'true')
					}
					ACTIVE(FIND_STRING(cEchoCommand,'BLU-RAY',1)):{
						DO_PUSH_TIMED(dvR4_1,nSourceButtons[7],1)
						fEchoRespond("'Max has switched the TV to the Blu-Ray.'",'true')
					}
					ACTIVE(FIND_STRING(cEchoCommand,'APPLE TV',1)):{
						DO_PUSH_TIMED(dvR4_1,nSourceButtons[9],1)
						fEchoRespond("'Max has switched the TV to the Apple TV.'",'true')
					}
					ACTIVE(FIND_STRING(cEchoCommand,'TV OFF',1)):{
						DO_PUSH_TIMED(dvR4_1,nGRSysOff,1)
						fEchoRespond("'Max has turned the TV off.'",'true')
					}
					ACTIVE(FIND_STRING(cEchoCommand,'CHANNEL',1)):{
						DO_PUSH_TIMED(dvR4_1,nSourceButtons[8],1)
						SELECT{
							ACTIVE(FIND_STRING(cEchoCommand,'NBC',1)):{
								fChangeChannel('04-1')
								fEchoRespond("'Max has tuned the TV to NBC'",'true')
							}
							ACTIVE(FIND_STRING(cEchoCommand,'N. B. C.',1)):{
								fChangeChannel('04-1')
								fEchoRespond("'Max has tuned the TV to NBC'",'true')
							}
							ACTIVE(FIND_STRING(cEchoCommand,'CBS',1)):{
								fChangeChannel('10-1')
								fEchoRespond("'Max has tuned the TV to CBS'",'true')
							}
							ACTIVE(FIND_STRING(cEchoCommand,'C. V. S.',1)):{
								fChangeChannel('10-1')
								fEchoRespond("'Max has tuned the TV to CBS'",'true')
							}
							ACTIVE(FIND_STRING(cEchoCommand,'C. B. S.',1)):{
								fChangeChannel('10-1')
								fEchoRespond("'Max has tuned the TV to CBS'",'true')
							}
							ACTIVE(FIND_STRING(cEchoCommand,'ABC',1)):{
								fChangeChannel('06-1')
								fEchoRespond("'Max has tuned the TV to ABC'",'true')
							}
							ACTIVE(FIND_STRING(cEchoCommand,'A. B. C.',1)):{
								fChangeChannel('06-1')
								fEchoRespond("'Max has tuned the TV to ABC'",'true')
							}
							ACTIVE(FIND_STRING(cEchoCommand,'PBS',1)):{
								fChangeChannel('34-1')
								fEchoRespond("'Max has tuned the TV to PBS'",'true')
							}
							ACTIVE(FIND_STRING(cEchoCommand,'P. B. S.',1)):{
								fChangeChannel('34-1')
								fEchoRespond("'Max has tuned the TV to PBS'",'true')
							}
							ACTIVE(FIND_STRING(cEchoCommand,'FOX',1)):{
								fChangeChannel('28-1')
								fEchoRespond("'Max has tuned the TV to Fox'",'true')
							}
							ACTIVE(1):{
								fEchoRespond("'Sorry.  Max did not understand the channel selection.'",'true')
							}
						}
						
					}
					ACTIVE(FIND_STRING(cEchoCommand,'LIGHTS',1)):{
						SELECT{
							ACTIVE(FIND_STRING(cEchoCommand,'FIREPLACE',1)):{
								SELECT{
									ACTIVE(FIND_STRING(cEchoCommand,'OFF',1)):{
										cVeraCMD = "'/data_request?id=action&output_format=xml&DeviceNum=6&serviceId=urn:upnp-org:serviceId:Dimming1&action=SetLoadLevelTarget&newLoadlevelTarget=0'"
										IP_CLIENT_OPEN(dvVera.PORT,cVeraIP,nVeraPort,IP_TCP)
									}
									ACTIVE(FIND_STRING(cEchoCommand,'TEN',1)):{
										cVeraCMD = "'/data_request?id=action&output_format=xml&DeviceNum=6&serviceId=urn:upnp-org:serviceId:Dimming1&action=SetLoadLevelTarget&newLoadlevelTarget=10'"
										IP_CLIENT_OPEN(dvVera.PORT,cVeraIP,nVeraPort,IP_TCP)
									}
									ACTIVE(FIND_STRING(cEchoCommand,'TWENTY',1)):{
										cVeraCMD = "'/data_request?id=action&output_format=xml&DeviceNum=6&serviceId=urn:upnp-org:serviceId:Dimming1&action=SetLoadLevelTarget&newLoadlevelTarget=20'"
										IP_CLIENT_OPEN(dvVera.PORT,cVeraIP,nVeraPort,IP_TCP)
									}
									ACTIVE(FIND_STRING(cEchoCommand,'THIRTY',1)):{
										cVeraCMD = "'/data_request?id=action&output_format=xml&DeviceNum=6&serviceId=urn:upnp-org:serviceId:Dimming1&action=SetLoadLevelTarget&newLoadlevelTarget=30'"
										IP_CLIENT_OPEN(dvVera.PORT,cVeraIP,nVeraPort,IP_TCP)
									}
									ACTIVE(FIND_STRING(cEchoCommand,'FORTY',1)):{
										cVeraCMD = "'/data_request?id=action&output_format=xml&DeviceNum=6&serviceId=urn:upnp-org:serviceId:Dimming1&action=SetLoadLevelTarget&newLoadlevelTarget=40'"
										IP_CLIENT_OPEN(dvVera.PORT,cVeraIP,nVeraPort,IP_TCP)
									}
									ACTIVE(FIND_STRING(cEchoCommand,'FIFTY',1)):{
										cVeraCMD = "'/data_request?id=action&output_format=xml&DeviceNum=6&serviceId=urn:upnp-org:serviceId:Dimming1&action=SetLoadLevelTarget&newLoadlevelTarget=50'"
										IP_CLIENT_OPEN(dvVera.PORT,cVeraIP,nVeraPort,IP_TCP)
									}
									ACTIVE(FIND_STRING(cEchoCommand,'SIXTY',1)):{
										cVeraCMD = "'/data_request?id=action&output_format=xml&DeviceNum=6&serviceId=urn:upnp-org:serviceId:Dimming1&action=SetLoadLevelTarget&newLoadlevelTarget=60'"
										IP_CLIENT_OPEN(dvVera.PORT,cVeraIP,nVeraPort,IP_TCP)
									}
									ACTIVE(FIND_STRING(cEchoCommand,'SEVENTY',1)):{
										cVeraCMD = "'/data_request?id=action&output_format=xml&DeviceNum=6&serviceId=urn:upnp-org:serviceId:Dimming1&action=SetLoadLevelTarget&newLoadlevelTarget=70'"
										IP_CLIENT_OPEN(dvVera.PORT,cVeraIP,nVeraPort,IP_TCP)
									}
									ACTIVE(FIND_STRING(cEchoCommand,'EIGHTY',1)):{
										cVeraCMD = "'/data_request?id=action&output_format=xml&DeviceNum=6&serviceId=urn:upnp-org:serviceId:Dimming1&action=SetLoadLevelTarget&newLoadlevelTarget=80'"
										IP_CLIENT_OPEN(dvVera.PORT,cVeraIP,nVeraPort,IP_TCP)
									}
									ACTIVE(FIND_STRING(cEchoCommand,'NINETY',1)):{
										cVeraCMD = "'/data_request?id=action&output_format=xml&DeviceNum=6&serviceId=urn:upnp-org:serviceId:Dimming1&action=SetLoadLevelTarget&newLoadlevelTarget=90'"
										IP_CLIENT_OPEN(dvVera.PORT,cVeraIP,nVeraPort,IP_TCP)
									}
									ACTIVE(FIND_STRING(cEchoCommand,'HUNDRED',1)):{
										cVeraCMD = "'/data_request?id=action&output_format=xml&DeviceNum=6&serviceId=urn:upnp-org:serviceId:Dimming1&action=SetLoadLevelTarget&newLoadlevelTarget=100'"
										IP_CLIENT_OPEN(dvVera.PORT,cVeraIP,nVeraPort,IP_TCP)
									}
									ACTIVE(FIND_STRING(cEchoCommand,'ON',1)):{
										cVeraCMD = "'/data_request?id=action&output_format=xml&DeviceNum=6&serviceId=urn:upnp-org:serviceId:Dimming1&action=SetLoadLevelTarget&newLoadlevelTarget=10'"
										IP_CLIENT_OPEN(dvVera.PORT,cVeraIP,nVeraPort,IP_TCP)
									}
								}
								fEchoRespond("'Max has set the lighting level.'",'true')
							}
							ACTIVE(1):{
								fEchoRespond("'Sorry.  Max did not understand the lighting zone.'",'true')
							}
						}
					}
					ACTIVE(FIND_STRING(cEchoCommand,'DIAGNOSTICS',1)):{
						fEchoREspond("'Security system ',cSecArmedState[nSecArmState+1],'. First Floor Temperature: ',ITOA(nCurrentTemp[1]),'degrees. Humidity: ',ITOA(nCurrentHumidity[1]),'percent. Lower Level Temperature: ',ITOA(nCurrentTemp[2]),'degrees. Humidity: ',ITOA(nCurrentHumidity[2]),'percent.'",'true')
					}
					ACTIVE(FIND_STRING(cEchoCommand,'RAISE THE VOLUME',1)):{
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						fEchoRespond("'How is that.'",'false')
						nEchoTree = 1
						WAIT 40 'echowait'{
							nEchoTree = 0
						}
					}
					ACTIVE(FIND_STRING(cEchoCommand,'HIGHER THE VOLUME',1)):{
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						fEchoRespond("'How is that.'",'false')
						nEchoTree = 1
						WAIT 40 'echowait'{
							nEchoTree = 0
						}
					}
					ACTIVE(FIND_STRING(cEchoCommand,'LOWER THE VOLUME',1)):{
						SEND_COMMAND dvReceiver,"'SP',25"
						SEND_COMMAND dvReceiver,"'SP',25"
						SEND_COMMAND dvReceiver,"'SP',25"
						SEND_COMMAND dvReceiver,"'SP',25"
						SEND_COMMAND dvReceiver,"'SP',25"
						SEND_COMMAND dvReceiver,"'SP',25"
						SEND_COMMAND dvReceiver,"'SP',25"
						fEchoRespond("'How is that.'",'false')
						nEchoTree = 1
						WAIT 40 'echowait'{
							nEchoTree = 0
						}
					}
					
					ACTIVE(1):{
						fEchoRespond("'Sorry.  Max does not understand that command.'",'true')
					}
				}
			}
			CASE 1:{ //Volume
				SELECT{
					ACTIVE(FIND_STRING(cEchoCommand,'LOUDER',1)):{
						CANCEL_WAIT 'echowait'
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						fEchoRespond("'How is that.'",'false')
						WAIT 40 'echowait'{
							nEchoTree = 0
						}
					}
					ACTIVE(FIND_STRING(cEchoCommand,'HIGHER',1)):{
						CANCEL_WAIT 'echowait'
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						SEND_COMMAND dvReceiver,"'SP',24"
						fEchoRespond("'How is that.'",'false')
						WAIT 40 'echowait'{
							nEchoTree = 0
						}
					}
					ACTIVE(FIND_STRING(cEchoCommand,'LOWER',1)):{
						CANCEL_WAIT 'echowait'
						SEND_COMMAND dvReceiver,"'SP',25"
						SEND_COMMAND dvReceiver,"'SP',25"
						SEND_COMMAND dvReceiver,"'SP',25"
						SEND_COMMAND dvReceiver,"'SP',25"
						SEND_COMMAND dvReceiver,"'SP',25"
						SEND_COMMAND dvReceiver,"'SP',25"
						SEND_COMMAND dvReceiver,"'SP',25"
						fEchoRespond("'How is that.'",'false')
						WAIT 40 'echowait'{
							nEchoTree = 0
						}
					}
					ACTIVE(FIND_STRING(cEchoCommand,'GOOD',1)):{
						fEchoRespond("'Great.'",'true')
						nEchoTree = 0
					}
				}
			}
		}
	}
}

You’ll see a lot of workarounds and fixes in there.  Most of this relates to my home system but I figured I’d leave it in there as example.  I know there are better ways to parse but hopefully this is enough to get you guys started. If you have any questions or comments, feel free to leave them in the comments.

Good luck!

Jack is a certified AMX and Crestron control systems programmer and systems engineer. He holds InfoComm CTS, CTS-I and CTS-D certifications and has had a career in the A/V industry for over 15 years. He lives in Columbus, Ohio with his family where he works for a systems integrator serving various commercial markets.

  • derrick

    Jack, thanks for much for posting this.

    Three questions. I believe you feel that having an intermediate Linux box running STunnel works best. Should I be able to buy a Raspberry PI and easily load these files on? Like you, I’m familiar with the C parts of Netlinx and not so much Java, etc.

    In configuring the skill, will the (https://yourhomeaddress:yourportnumber) contain the port I’m forwarding on my router to the Linux box?

    On the STunnel file, will I put the Netlinx processor IP and port here or is there another part where it finds it?
    client = no
    accept = SERVER_IP:PORT
    connect = AMX_IP:PORT

    Thanks again-

    Derrick

    • You an buy an RPi and load STunnel on it. Just install the package. My config file is an example. Yes, that is your home router and yes the AMX_IP is the Netlinx processor IP and the port number you are running the IP_SERVER on. Thanks.