Tuesday, 12 March 2013

Sky IP Speech Control with Android

Just after Christmas I asked my eldest daughter what geekery she wanted me to do next.  She suggested being able to control the TV by speaking to it.  Sounded like a challenge to me....

Overall, a pretty easy problem to solve.  If you've got about £1,500 to spare you can buy a Samsung Smart TV that comes with voice (and gesture control).  That's not pocket money prices and simply buying something that does the whole job is not the Geek Dad way.

A colleague told me about how he can control his Sky box with his iPad over his home WiFi network.  Sounds like an opportunity.   My old Sky box gave up the ghost later last year and was replaced with a shiny new black Sky+ HD box.  This has an ethernet port on the back of it and, by purchasing a Sky Wireless Controller I could use my wife's iPad as a remote control and also use the Sky On Demand service.

A quick Google search for "sky ipad protocol" led me to this blog:
http://www.gladdy.co.uk/blog/2012/08/18/sky-dnla-upnp-the-ipad-app-and-the-ip-control-protocol/

This chap has pretty much done all the hard work to decode the DLNA/ UPnP protocol used by the Sky box and has published a full code listing.  One thing he describes is how the Sky box constantly broadcasts SSDP messages to your LAN so I set up Wireshark to have a look at this. Here's an example:


So this seems to show the Sky box saying, "I am here, I have a file called description37.xml that describes what I can do".  A quick Python script to download description37.xml gets you an XML file that points to several other resources like "player_avt.xml", "player_cm.xml", "player_rc.xml" that seem to describe some SOAP services that the Sky box supports.

Rather than re-inventing the wheel I decided to use the brilliant code that Liam Gladdy published.  The Python script didn't work on my PC Python installation (missing packages) but did work on my Raspberry Pi.   Here's a screenshot of it in action:


So the script listens for the SSDP broadcasts, identifies the IP address for the Sky box and allows you to send SOAP commands to the Sky box.  It works a treat and it's a great way to annoy my wife, (keeper of the Sky remote).

The one thing the script didn't seem to let me do was to change channel on the Sky box, (essential to meet my daughter's challenge).  Another quick Google led me to this forum topic:
http://www.gossamer-threads.com/lists/mythtv/users/525915

These chaps have worked out that the "SetAVTransportURI" operation is used to change the channel on the Sky box and they've published some Python code showing how to do it. So using this, and reverse engineering the code for pause, play and stop from the Liam Gladdy code I was able to write a Python script with functions for pause, play, stop and change channel.

Here's a snippet, a function that sends a pause SOAP command:

# Do a command to pause the playback
#
def PauseSkyBox():
    XML="""<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:Pause xmlns:u="urn:schemas-nds-com:service:SkyPlay:2">
<InstanceID>0</InstanceID>
</u:Pause>
</s:Body>
</s:Envelope>"""

    headers=  { "Content-Length": "%d" % len(XML), "SOAPACTION": "\"urn:schemas-nds-com:service:SkyPlay:2#Pause\"", "Content-Type": "text/xml; char-set=utf-8"  }

    try:
conn = httplib.HTTPConnection("%s:49153" % skyAddress)
conn.request("POST",  "/SkyPlay2", "",  headers)
conn.send(XML)
response = conn.getresponse().read()
        print response
        conn.close()

dom = parseString(response)

    except Exception as inst:
sys.exit("Error doing a Pause command from %s: %s" % (skyAddress, inst))


The function simply creates the XML command as a string, creates a HTTP header, sends the command and awaits a response.

Using SL4A and Android speech recognition (see here for a previous example) it was easy to write a Python script to recognise speech commands and call functions to send a SOAP command to the Sky box. The code implements pause, play, stop, fast-forward (play with a different speed), rewind (play with negative speed) and changing channels.  Observations:
  • The Android speech recognition algorithm doesn't work for me with the word "pause".  It translates to "polls" or "horse".  Must be my geeky accent.   I use the word "still" instead.
  • I'm not 100% happy with the long "elif"  statement for each of the fast forward and rewind translations.  With a bit of thought I could do this better I'm sure.
  • The code relies upon a text file that contains a mapping from a decimal channel number to a hex equivalent and a channel name.  Download your own from one of the postings on the http://www.gossamer-threads.com/lists/mythtv/users/525915 forum.  I found my channel mappings to be different to those in the file so you'll have to play with this a bit.  I put this in the root directory of my SD card on my handset and then used the chdir ('/mnt/sdcard') command.
Here's a full code listing:


# Sky remote controller
# References http://www.gladdy.co.uk/blog/2012/08/18/sky-dnla-upnp-the-ipad-app-and-the-ip-control-protocol/
# http://www.gossamer-threads.com/lists/mythtv/users/525915

#!/usr/bin/python

#Import statements 
import  httplib,  sys, os
from  xml.dom.minidom import parseString
import android
from os import chdir

#Constants
skyAddress = "192.168.0.6"        # IP Address of Sky+HD Box
skyChannelsFile = "sky_channel_guide.txt" # Location of Channel Data

# Parse through tab delmited channel listing and return HEX Channel No

def findSkyChannel(channelFind):
    
    try:
channelsFile = open (skyChannelsFile, "r")
for line in channelsFile:
   rawdata = line.split('\t')
   if rawdata[0] == channelFind:
channelsFile.close()
return (rawdata[3], rawdata[1])
    
channelsFile.close()
return ("0", "Not Found")

    except Exception as inst:
sys.exit("Error in findSkyChannel: %s" % inst)

# Change channel on SkyHD Box via IP given HEX Channel ID
#
def changeSkyChannel(channelId):
    
    XML="""<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:SetAVTransportURI xmlns:u="urn:schemas-nds-com:service:SkyPlay:2">
<InstanceID>0</InstanceID>
<CurrentURI>xsi://%s</CurrentURI>
<CurrentURIMetaData>NOT_IMPLEMENTED</CurrentURIMetaData>
</u:SetAVTransportURI>
</s:Body>
</s:Envelope>""" % channelId

    headers = { "Content-Length": "%d" % len(XML), "SOAPACTION": "\"urn:schemas-nds-com:service:SkyPlay:2#SetAVTransportURI\"", "Content-Type": "text/xml; char-set=utf-8"  }

    try:
conn = httplib.HTTPConnection("%s:49153" % skyAddress)
conn.request("POST",  "/SkyPlay2", "",  headers)
conn.send(XML)
response=conn.getresponse()
data = response.read()
conn.close()

#### Debug Purposes
#os.path.exists(SkyTempFile) and os.remove(SkyTempFile)
#print data
        #os.chdir 'c:\temp'
        #f = open("skytempfile.txt",'w')
#f.write(data)
#f.close()
####
    except Exception as inst:
sys.exit("Error changing channel: %s" % inst)
pass

# Get Current Channel from skyHD Box
#
def getSkyChannel():
    XML="""<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetMediaInfo xmlns:u="urn:schemas-nds-com:service:SkyPlay:2">
<InstanceID>0</InstanceID>
</u:GetMediaInfo>
</s:Body>
</s:Envelope>"""

    headers=  { "Content-Length": "%d" % len(XML), "SOAPACTION": "\"urn:schemas-nds-com:service:SkyPlay:2#GetMediaInfo\"", "Content-Type": "text/xml; char-set=utf-8"  }

    try:
conn = httplib.HTTPConnection("%s:49153" % skyAddress)
conn.request("POST",  "/SkyPlay2", "",  headers)
conn.send(XML)
response = conn.getresponse().read()

dom = parseString(response)
return (dom.getElementsByTagName('CurrentURI')[0].firstChild.data.split('//')[1])

    except Exception as inst:
sys.exit("Error getting current channel from %s: %s" % (skyAddress, inst))

# Do a command to pause the playback
#
def PauseSkyBox():
    XML="""<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:Pause xmlns:u="urn:schemas-nds-com:service:SkyPlay:2">
<InstanceID>0</InstanceID>
</u:Pause>
</s:Body>
</s:Envelope>"""

    headers=  { "Content-Length": "%d" % len(XML), "SOAPACTION": "\"urn:schemas-nds-com:service:SkyPlay:2#Pause\"", "Content-Type": "text/xml; char-set=utf-8"  }

    try:
conn = httplib.HTTPConnection("%s:49153" % skyAddress)
conn.request("POST",  "/SkyPlay2", "",  headers)
conn.send(XML)
response = conn.getresponse().read()
        print response
        conn.close()

dom = parseString(response)
#Can't get this dom stuff to work fpr pause.  Don't think it's required though for doing a pause!!!
        #print dom
        #return (dom.getElementsByTagName('CurrentURI')[0].firstChild.data.split('//')[1])

    except Exception as inst:
sys.exit("Error doing a Pause command from %s: %s" % (skyAddress, inst))

# Do a command to stop the playback
#
def StopSkyBox():
    XML="""<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:Stop xmlns:u="urn:schemas-nds-com:service:SkyPlay:2">
<InstanceID>0</InstanceID>
</u:Stop>
</s:Body>
</s:Envelope>"""

    headers=  { "Content-Length": "%d" % len(XML), "SOAPACTION": "\"urn:schemas-nds-com:service:SkyPlay:2#Stop\"", "Content-Type": "text/xml; char-set=utf-8"  }

    try:
conn = httplib.HTTPConnection("%s:49153" % skyAddress)
conn.request("POST",  "/SkyPlay2", "",  headers)
conn.send(XML)
response = conn.getresponse().read()
        print response
        conn.close()

dom = parseString(response)
#Can't get this dom stuff to work fpr pause.  Don't think it's required though for doing a pause!!!
        #print dom
        #return (dom.getElementsByTagName('CurrentURI')[0].firstChild.data.split('//')[1])

    except Exception as inst:
sys.exit("Error doing a Stop command from %s: %s" % (skyAddress, inst))


# Do a command to play, fast forward or rewind
#
def PlaySkyBox(PlaySpeed):
    XML="""<?xml version="1.0" encoding="utf-8"?>
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:Play xmlns:u="urn:schemas-nds-com:service:SkyPlay:2">
<InstanceID>0</InstanceID>
<Speed>%s</Speed>
</u:Play>
</s:Body>
</s:Envelope>""" % PlaySpeed

    headers=  { "Content-Length": "%d" % len(XML), "SOAPACTION": "\"urn:schemas-nds-com:service:SkyPlay:2#Play\"", "Content-Type": "text/xml; char-set=utf-8"  }

    try:
conn = httplib.HTTPConnection("%s:49153" % skyAddress)
conn.request("POST",  "/SkyPlay2", "",  headers)
conn.send(XML)
response = conn.getresponse().read()
        print response
#dom = parseString(response)
#return (dom.getElementsByTagName('CurrentURI')[0].firstChild.data.split('//')[1])

    except Exception as inst:
sys.exit("Error doing a Play command from %s: %s" % (skyAddress, inst))

###The main body of code

#Set up out droid object
droid = android.Android()

#Print some funky stuff
print "###################################################"
print "# SL4A Sky remote                                 #"
print "###################################################"

#Change the directory to where the description file is stored.
chdir ('/mnt/sdcard')



#Boolean to control the loop
KeepLooping = True

while KeepLooping == True:
  
  #Get the speech
  speech = droid.recognizeSpeech("Talk Now",None,None)
  print "You said: " + speech[1]
  
  #Big If statement
  if speech[1] == "still":
    PauseSkyBox()
  elif speech[1] == "play":
    PlaySkyBox("1")
  elif speech[1] == "stop":
    StopSkyBox()
  elif speech[1] == "2":     #Next commands do fast forward
    PlaySkyBox("2")        
  elif speech[1] == "6":
    PlaySkyBox("6")
  elif speech[1] == "12":
    PlaySkyBox("12")
  elif speech[1] == "30":
    PlaySkyBox("30")
  elif speech[1] == "minus 2":   #Next commands do re-wind
    PlaySkyBox("-2")
  elif speech[1] == "minus 6":
    PlaySkyBox("-6")
  elif speech[1] == "minus 12":
    PlaySkyBox("-12")
  elif speech[1] == "minus 30":
    PlaySkyBox("-30")
  #Now test to see if it's a channel number.  So three characters and a number
  elif len(speech[1]) == 3 and speech[1].isdigit:  
    #Get the channel details (in hex) from the description file
    channelId, channelName = findSkyChannel(str(speech[1]))
    #See if we had nothing returned
    if channelId == "0":
print "Unable to find HEX channel No"
    elif channelId == getSkyChannel():
print "Sky+HD is already tuned to %s (%s - %s).  No Need to Change" % (channelName, speech[1], channelId)
    else:   #Actually change the channel
      changeSkyChannel(channelId)
      print "Sky+HD Channel Changed to %s (%s - %s)" % (channelName, speech[1], channelId)
  elif speech[1] == "exit":
    KeepLooping = False
  else:
    print "Command not known!"  

Message from Emily, eldest daughter of the Geek Dad:

hi do you like this boring blog?