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?

5 comments:

  1. I like your blog, I wanted to leave a comment to support you and wish you a good continuation. Wish you all the best for all your best efforts. Thanks !


    android application development

    ReplyDelete
  2. Hi, I wonder if you can help please? I'm a big fan of Demopad and can send transport (stop, play, pause, etc) commands to Sky by IP but I cannot get it to change channel despite far too many hours trying!? Any help you could give would be much appreciated :) Thanks, Chris

    ReplyDelete
  3. Hi Chris
    Just did a bit of revision on this as it's been a while since I put this together.

    Looking at the code, first I make sure I've got a 3 digit number returned from the speech recognition routine:

    elif len(speech[1]) == 3 and speech[1].isdigit:

    I then take the number and do a look-up to the file that does mapping between decimal channel numbers and the hex equivalent:

    channelId, channelName = findSkyChannel(str(speech[1]))

    The findSkyChannel routine simply opens the reference file, finds the decimal number and then looks up the hex, returned as channelID.

    This line calls a routine to check the current channel:

    elif channelId == getSkyChannel():

    Perhaps you could try and replicate the XML I build in getSkyChannel and see what you get back from your box. I'm not an expert in Demopad so can't advise how to replicate the the string that I build.

    Changing the channel is then done with:
    changeSkyChannel(channelId)

    ...again, I guess it's a case of trying to replicate the XML string that I build in the Python routine.

    One thing I spent a lot of time doing is modifying the decimal to hex mapping file. So I would put the Sky box on channel 001, run a getSkyChannel and log the hex value associated with the channel and update the mapping file.

    I can't remember doing any tricks to get the channel changing to work. It was just a case of getting the XML right and getting the mapping file right.

    Cheers
    Paul

    ReplyDelete
    Replies
    1. Hi Paul
      Thanks very much for the quick answer, much appreciated. I should perhaps explain more about the IP commands that prove to work in Demopad. I'm afraid I know very little about Python and as far as I know, Demopad issues these commands as http (post commands) to the IP address of my Sky box on my home network with port 49153 and command suffix \x0D\x0A. I don't know why the post command has the IP address 192.168.1.16 which is NOT my address but it doesn't seem to matter.
      For example, the code for pause is;
      POST /SkyPlay2 HTTP/1.1\x0D\x0AHost: 192.168.1.16:49153\x0D\x0AUser-Agent: SKY_skyplus\x0D\x0AContent-Length: 369\x0D\x0AAccept: */*\x0D\x0AContent-Type: text/xml; charset=utf-8\x0D\x0ASOAPACTION: "urn:schemas-nds-com:service:SkyPlay:2#Pause"\x0D\x0AAccept-Language: en-us\x0D\x0AAccept-Encoding: gzip, deflate\x0D\x0AConnection: keep-alive\x0D\x0A\x0D\x0A\x0D\x0A\x0D\x0A \x0D\x0A \x0D\x0A 0\x0D\x0A 1\x0D\x0A \x0D\x0A \x0D\x0A\x0D\x0A
      and this works, as do play, stop, ff and rew.
      I tried changing some parts based on your's and Liam's code and as recently suggested on the Demopad forum to send a change channel command as follows but no luck. I've tried different hex values for the channel including ff0 but again no joy.
      POST /SkyPlay HTTP/1.1\x0D\x0AHost: 192.168.1.16:49153\x0D\x0AUser-Agent: SKY_skyplus\x0D\x0AContent-Length: 369\x0D\x0AAccept: */*\x0D\x0AContent-Type: text/xml; charset=utf-8\x0D\x0ASOAPACTION: "urn:schemas-nds-com:service:SkyPlay:2#SetAVTransportURI"\x0D\x0AAccept-Language: en-us\x0D\x0AAccept-Encoding: gzip, deflate\x0D\x0AConnection: keep-alive\x0D\x0A\x0D\x0A\x0D\x0A\x0D\x0A \x0D\x0A \x0D\x0A 0\x0D\x0A xsi://fdd\x0D\x0A NOT_IMPLEMENTED\x0D\x0A \x0D\x0A \x0D\x0A\x0D\x0A
      Again, any thoughts would be much appreciated :)
      Cheers
      Chris

      Delete
  4. Amazing blog. This post is looking interesting. Thanks for sharing this with us TogetherReviewBlog

    ReplyDelete