Udostępnij za pośrednictwem


UPNP on Windows Phone 7.5 Part 2: Invoke

With UPnP discovery worked out, the next feature is to turn the discovery results into a Device, find a Service on that device, and to make calls on that service, ie Invoke some Actions. I'll start with the code from my test app, to give you an idea of where we are heading. I wanted to choose an Action that as many readers as possible could try, so I chose a method from ContentDirectory which is available from MediaServer, on any PC in your network once Media Sharing is enabled. The test code searches the network for media servers, and for each device it enquires for a ContentDirectory service. If it finds one, it does a call to find the top level of the "media tree" from that service. This works against Windows Media Sharing as well as my Sonos gear, plus anything DLNA-related that may be on your nextwork.

Note that this code requires the C# Async CTP for Phone to be installed in order to compile and run, get if from here. Once installed copy the AsyncCtpLibrary_Phone.dll to your project and add it as a Reference. Note that the recommendation is for asynchronous methods to end with the word "Async", but I chose to add an underscore in there to make it totally obvious which methods are async. Feel free to totally ignore my convention. You will also need to add a using from System.Threading.Tasks to your cs files to use it.

So here is the code from the test app:

 private void buttonMedia_Click(object sender, RoutedEventArgs e)
{
    // Find all Media Servers
    string item = "urn:schemas-upnp-org:device:MediaServer:1";
 
    // Disable button while scanning
    buttonMedia.IsEnabled = false;
 
    ScanNetwork(item, (newservice) =>
        {
            if (newservice != null)
            {
                TestMediaBrowseAsync(newservice);
            }
            else
            {
                // Finished scan, so re-enable button
                buttonMedia.IsEnabled = true;
            }
        });
}
 
private void ScanNetwork(string item, Action<string> FoundCallback)
{
    UPnP.Scanner finder = new UPnP.Scanner();
 
    finder.FindDevices(item, 3, (findresult) =>
    {
        Dispatcher.BeginInvoke(() =>
        {
            if (findresult == null)
            {
                FoundCallback(null);
            }
            else
            {
                // Got a response
                var newservice = UPnP.Scanner.GetSSDPLocation(findresult);
                if (IsNewLocation(newservice))
                {
                    FoundCallback(newservice);
                }
            }
        });
    });
}
 
async void TestMediaBrowseAsync(string uri)
{
    var device = await UPnP.Device.Create_Async(uri);
    var contentdir = device.FindService("urn:upnp-org:serviceId:ContentDirectory");
    if (contentdir != null)
    {
        ContentDirectory cd = new ContentDirectory(contentdir);
        var cdresult = await cd.Browse("0", "BrowseMetadata", "*", (uint)0, (uint)1, "");
 
        Debug.WriteLine("Found device: " + device.FriendlyName);
        if (cdresult.Error == null)
        {
            Debug.WriteLine("Number={0}, Total={1}, UpdateID={2}, Result={3}\n",
                cdresult.NumberReturned,
                cdresult.TotalMatches,
                cdresult.UpdateID,
                cdresult.Result);
        }
        else
        {
            Debug.WriteLine("ERROR: {0} on BrowseMetadata call", cdresult.Error.Message);
        }
    }
}
 
private List<string> RootDevicesSoFar = new List<string>();
 
// Have we seen this before?
private bool IsNewLocation(string location)
{
    if (location == null)
        return false;
 
    if (!RootDevicesSoFar.Contains(location))
    {
        RootDevicesSoFar.Add(location);
        return true;
    }
    else
    {
        return false;
    }
}

Note that if run on the emulator it will NOT find the media server on the same machine for me, I have to run this code on an actual device to find shared media. (I can find Sonos gear just fine from the emulator). I have no explanation for this. YMMV as they say.

Now we'll jump back to the bottom of the stack: Part 1s function returned us the raw results of an SSDP discovery, so from that we need to find the http: address of the description file:

 // SSDP response handler, returns location of xml file, or null
public static string GetSSDPLocation(string response)
{
    var dict = ParseSSDPResponse(response);
    if (dict != null && dict.ContainsKey("location"))
        return dict["location"];
    else
        return null;
}
 
// Probably not exactly compliant with RFC 2616 but good enough for now
private static Dictionary<string, string> ParseSSDPResponse(string response)
{
    StringReader reader = new StringReader(response);
 
    string line = reader.ReadLine();
    if (line != "HTTP/1.1 200 OK")
        return null;
 
    Dictionary<string, string> result = new Dictionary<string, string>();
 
    for (; ; )
    {
        line = reader.ReadLine();
        if (line == null)
            break;
        if (line != "")
        {
            int colon = line.IndexOf(':');
            if (colon < 1)
            {
                return null;
            }
            string name = line.Substring(0, colon).Trim();

            string value = line.Substring(colon + 1).Trim();
            if (string.IsNullOrEmpty(name))
            {
                return null;
            }
            result[name.ToLowerInvariant()] = value;
        }
    }
    return result;
}

So here is my basic Device class:

 namespace UPnP
{
    public class Device
    {
        XElement Dom;
        Uri BaseUri;
        const string NS = "urn:schemas-upnp-org:device-1-0";
 
        public static async Task<Device> Create_Async(string uri)
        {
            var xml = await new WebClient().DownloadStringTaskAsync(uri);
            var dom = XElement.Parse(xml);
            if (dom.GetDefaultNamespace() != "urn:schemas-upnp-org:device-1-0")
            {
                Debug.WriteLine("Bad default namespace " + dom.GetDefaultNamespace());
                return null;
            }
 
            Device device = new Device(uri, dom);
            return device;
        }
 
        private Device(string uri, XElement dom)
        {
            this.Dom = dom;
            this.BaseUri = new Uri(uri, UriKind.Absolute);
        }
 
        public string FriendlyName
        {
            get
            {
                return (string)Dom.Descendants(XName.Get("friendlyName", NS)).FirstOrDefault();
            }
        }
 
        public string FindService(string urn)
        {
            var service = from item in Dom.Descendants(XName.Get("service", NS))
                          where (string)item.Element(XName.Get("serviceId", NS)) == urn
                          select new
                          {
                              ServiceType = (string)item.Element(XName.Get("serviceType", NS)),
                              ServiceId = (string)item.Element(XName.Get("serviceId", NS)),
                              SCP = (string)item.Element(XName.Get("SCPDURL", NS)),
                              ControlURL = (string)item.Element(XName.Get("controlURL", NS))
                          };
 
            // The ControlURL is relative to the base uri
            return service != null ? BaseUri.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped) + service.First().ControlURL : null;
        }
 
    }
 
}

The Create_Async method is the only public way to make a device: pass it a uri that points to the device's description xml file, and it will load it into an XML document then return a new Device object to the caller. The FindService method can then be used to get a specific service from the device. There isn't much else to this class except a helper property to get the FriendlyName of the device. (Please excuse my LINQ: it is probably not remotely the best way of doing this, but my LINQ level is such that if it gets any results, its good enough for me).

Next is to get a Service object from the uri that the Device returns to invoke Actions upon it. I modelled this behavior on the way WCF does this, with the idea that some automated tool runs against the service's xml file and generates helper classes for each action (and the necessary types). However I don't have such a tool ready yet, so I hand-rolled a couple of actions, and coded them such that an automated tool should have no problem generating them. UPNP Actions have input args and output args, so I chose to return the output args in a strongly-typed struct for each action, along with any error information, just like WCF. So here is the Service code, which won't necessarily make sense until you see the sample action later on:

 namespace UPnP
{
    public class Service
    {
        private string Address;
        private string ServiceType;
 
        // Every Action needs to have an instance of this information, which encapsulates all the data
        // necessary to make a call
        public struct ActionInfo
        {
            public string name;
            public string[] argnames;
            public int outargs;
        };
 
        // Every Action that has any OUT arguments needs to have a type inherited from this one, containing
        // each OUT argument as a member
        public class ActionResult
        {
            public Exception Error;             // Stores last error returned
 
            // Override this to convert an array of strings into the desired type
            public virtual void Fill(string[] rawdata)
            {
                Debug.Assert(rawdata.Length == 0);      // will fire if failed to override for non-empty results
            }
 
 
            // Helper methods
            //
            protected bool ParseBool(string item)
            {
                return item == "1";
            }
        };
 
        protected Service(string uri, string type)
        {
            this.Address = uri;
            this.ServiceType = type;
        }
 
        public async Task<ActionResult> Action_Async(ActionInfo actionInfo, object[] args, ActionResult actionResult)
        {
            actionResult.Error = null;
 
            try
            {
                StringBuilder soap = new StringBuilder("<u:" + actionInfo.name + " xmlns:u=\"" + ServiceType + "\">");
                int currentarg = 0;
 
                if (args != null)
                {
                    foreach (var arg in args)
                    {
                        soap.AppendFormat("<{0}>{1}</{0}>", actionInfo.argnames[currentarg++], FormatArg(arg));
                    }
                }
                soap.AppendFormat("</u:{0}>", actionInfo.name);
 
                var result = await SOAPRequest_Async(new Uri(Address), soap.ToString(), actionInfo.name);
 
                // TODO handle errors from services here
 
                if (actionInfo.outargs != 0)
                {
                    // Children of u:<VERB>Response are the result we want
                    XName xn = XName.Get(actionInfo.name + "Response", ServiceType);
 
                    var items = from item in result.Descendants(xn).Descendants()
                                select (string)item.Value;
 
                    // Check that we have at least the expected number of results
                    if (items.Count() < actionInfo.outargs)
                        throw new ArgumentException();
 
                    actionResult.Fill(items.ToArray());
                }
            }
            catch (Exception ex)
            {
                actionResult.Error = ex;
            }
 
            return actionResult;
        }
 
        private async Task<XElement> SOAPRequest_Async(Uri target, string soap, string action)
        {
            string req = "<?xml version=\"1.0\" encoding=\"utf-8\"?>" +
            "<s:Envelope xmlns:s=\"https://schemas.xmlsoap.org/soap/envelope/\" s:encodingStyle=\"https://schemas.xmlsoap.org/soap/encoding/\">" +
            "<s:Body>" +
            soap +
            "</s:Body>" +
            "</s:Envelope>";
            HttpWebRequest r = (HttpWebRequest)HttpWebRequest.Create(target);
 
            // We only ever need to do this over non-cellular networks
            r.SetNetworkPreference(NetworkSelectionCharacteristics.NonCellular);
            r.Method = "POST";
            byte[] b = Encoding.UTF8.GetBytes(req);
            r.Headers["SOAPACTION"] = "\"" + ServiceType + "#" + action + "\"";
            r.ContentType = "text/xml; charset=\"utf-8\"";
            var stream = await r.GetRequestStreamAsync();
            stream.Write(b, 0, b.Length);
            stream.Close();                 // else will get "Not Supported"
 
            WebResponse resp;
            try
            {
                resp = await r.GetResponseAsync();
            }
            catch (System.Net.WebException ex)
            {
                throw ConvertException(ex);
            }
 
            var st = resp.GetResponseStream();
            st.Seek(0, SeekOrigin.Begin);
            return XElement.Load(st);
        }
 
        public class UPnPException : Exception
        {
            public int ErrorCode
            {
                get;
                private set;
            }
 
            public override string Message
            {
                get
                {
                    return "UPnP Error " + ErrorCode.ToString();
                }
            }
            internal UPnPException(int error)
            {
                ErrorCode = error;
            }
        }
 
        // If an exception is really a UPNP error, convert it
        private Exception ConvertException(WebException ex)
        {
            WebResponse resp = ex.Response;
            string error = ex.Message;
 
            if (resp != null)
            {
                using (Stream respstream = resp.GetResponseStream())
                {
                    respstream.Seek(0, SeekOrigin.Begin);
                    TextReader reader = new StreamReader(respstream, Encoding.UTF8);
                    error = reader.ReadToEnd();
                    // This can be a UPnP error
                    // TODO actually parse the XML
                    int errstart = error.IndexOf("<errorCode>");
                    int errend = error.IndexOf("</errorCode>");
                    if ((errstart != -1) && (errend != -1))
                    {
                        error = "UPnP ERROR:" + error.Substring(errstart + 11, errend - errstart - 11);
                        return new UPnPException(int.Parse(error.Substring(errstart + 11, errend - errstart - 11)));
                    }
                }
            }
            return ex;
        }
 
        private string FormatArg(object obj)
        {
            string result;
 
            if (obj == null)
                result = "";
            else if (obj is string)
                result = UPnP.Utility.XmlEscape(obj.ToString());
            else if (obj is bool)
                result = (bool)(obj) ? "1" : "0";
            else
                result = Convert.ChangeType(obj, obj.GetType(), CultureInfo.InvariantCulture).ToString();
 
            return result;
        }
 
    }
 
}

So now for the first Action, which is the Browse method on the ContentDirectory service, which you can read all about in the UPnP spec. It has six input args and four output args:

     public class ContentDirectory : UPnP.Service
    {
        public ContentDirectory(string uri)
            : base(uri, "urn:schemas-upnp-org:service:ContentDirectory:1")
        {
        }
 
        private static UPnP.Service.ActionInfo Browse_Info = new UPnP.Service.ActionInfo()
        {
            name = "Browse",
            argnames = new string[] { "ObjectID", "BrowseFlag", "Filter", "StartingIndex", "RequestedCount", "SortCriteria" },
            outargs = 4,
        };
        public class Browse_Result : UPnP.Service.ActionResult
        {
            public string Result;
            public uint NumberReturned;
            public uint TotalMatches;
            public uint UpdateID;
 
            public override void Fill(string[] rawdata)
            {
                Result = rawdata[0];
                NumberReturned = uint.Parse(rawdata[1]);
                TotalMatches = uint.Parse(rawdata[2]);
                UpdateID = uint.Parse(rawdata[3]);
            }
        }
        public async Task<Browse_Result> Browse(string ObjectID, string BrowseFlag, string Filter, uint StartingIndex, uint RequestedCount, string SortCriteria)
        {
            return await base.Action_Async(Browse_Info, new object[] { ObjectID, BrowseFlag, Filter, StartingIndex, RequestedCount, SortCriteria }, new Browse_Result()) as Browse_Result;
        }
    }
}

The Browse method bundles up the input args into an array of objects and passes them to the base class. The result is packaged into a Browse_Result class containing the output arguments, all with the same names as declared in the service's xml. To round this off here is a little utility method used by the soap encoder:

 namespace UPnP
{
    public class Utility
    {
        // Emulate https://msdn.microsoft.com/en-us/library/system.security.securityelement.escape.aspx
        // SecurityElement.Escape
        public static string XmlEscape(string input)
        {
            return input.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;").Replace("\'", "&apos;");
        }
    }
}

There's quite a lot of code here, following user request all the source and a simple sample are on Codeplex now.

Comments

  • Anonymous
    January 17, 2012
    I would like to see this on Codeplex. I try to use it but the class "Scanner" is missing on your listing.

  • Anonymous
    January 18, 2012
    Sorry werner, the Scanner class is a slight reworked version of my SSDP code from an earlier post. There is a semantic change in that it makes the calback with a null argument when the san is complete. A couple more requests and I'll put it on CodePlex.

  • Anonymous
    January 22, 2012
    Yes it would be great to have it on CodePlex! This is just what I was looking for :-) Good work

  • Anonymous
    February 07, 2012
    Yaw, nice work so far! Please have it on CodePlex, thanks :)

  • Anonymous
    February 09, 2012
    Please stick this on codeplex! It's exactly what I've been trying to do, but could never quite get it right

  • Anonymous
    February 13, 2012
    I'd like to see this on codeplex! great article as well, thanks.

  • Anonymous
    February 15, 2012
    Agreed, would be really handy to have this on CodePlex

  • Anonymous
    February 15, 2012
    Hello I would like to see this code on CodePlex too. Nice work!

  • Anonymous
    February 21, 2012
    Fantastic article !! Yes please post on CLodePlex :)

  • Anonymous
    March 01, 2012
    This is Cool, this could enable a sideshow client for wp7 like market.android.com/details  

  • Anonymous
    March 02, 2012
    Come on, Andy, post the thing on CodePlex already!

  • Anonymous
    March 03, 2012
    Ditto, please get this up on Codeplex to avoid some serious copy/paste! :) Much appreciated!

  • Anonymous
    April 02, 2012
    I'll join the crowd and +1 about getting this public on codeplex, with a tiny sample in order to see the different parts in motion :)

  • Anonymous
    April 10, 2012
    All source for this and the earlier post are now on Codeplex: http://wpupnp.codeplex.com/ - enjoy!

  • Anonymous
    September 16, 2012
    Hi Andy! i have downloaded the codes from Codeplex, tried opening it in VS2012 but was prompted with a log in, would you please advise on how i can connect myself so that i can try the uPnP you have created.