Windows 8 的 UPnP 代码
如何在 Windows 8 上使用 UPnP
介绍
此代码可在 UPnP 扬声器上播放音频。如果您有 DLNA 扬声器(“Digital Lifestyle Networking Alliance”),它们基于 UPnP,此代码也可在它们上面工作。
与其遵循本文,不如采用一个关键的替代技术,该技术更好——使用 Windows 8 对“播放到”(此链接指向 Windows SDK 样本代码,用于 PlayTo 合同+API)的新支持。PlayTo 更简单、经过更充分的测试,并且比自己实现所有 UPnP 内容更可取。它只需要几行代码。您的应用商店应用的用户将能够使用“设备”超级按钮将您的应用流式传输视频或音频到他们的电视、扬声器或相框,前提是设备制造商提供了 Windows 8 认证驱动程序。
(请注意,认证驱动程序仅适用于应用商店应用;桌面应用不需要。还有一个较旧的技术,使用一个名为UPNP.dll 的 COM 库,可追溯到 XP,但它不允许用于应用商店应用)。
我编写此代码是因为我的设备尚未获得 Windows 8 认证驱动程序,并且因为我想了解 UPnP。更广泛地说,当您使用 PlayTo 时,此代码可能有助于您理解幕后工作的 UPnP 基础。
目录
- 本文档描述了原始 UPnP 协议,即设备之间涉及的 UDP 和 TCP 消息。
- 接下来,它展示了显式实现那些原始 UDP 和 TCP 通信的代码,包括设备发现(SSDP)和设备控制(UPnP)。
- 接下来,它展示了如何使用 HttpClient 和 VB 的 XML 字面量,以实现更高级别的抽象,而不是进行原始 TCP 编程。
- 接下来,它展示了如何使用 WinRT 设备枚举 API,而不是手动实现 SSDP。这是一种更清晰的做事方式,可在 Windows 平板电脑上运行,并可用于应用商店,但需要 Windows 8。
代码有两种形式:.NET45 代码(可在任何 .NET45 计算机上运行,但不能在应用商店中使用)和 WinRT 代码(可在 Windows 平板电脑上运行,可用于应用商店,需要 Windows 8)。
使用 UPnP 设备进行测试
以下是三个 UPnP 设备,您可以在它们上面测试此代码:
- 我出去买了一个 Sony SA-NS300 无线扬声器。这是一个 UPnP 设备(但截至 2012 年 9 月尚未获得 Windows 8 认证驱动程序)。
- 我下载了UPnP 技术开发工具。其中包含一个名为MediaRenderer.exe 的程序,您可以在上面进行测试(但它也尚未获得 Windows 8 认证)。
- 我在同一网络上设置了另一台计算机,在其上运行 Windows Media Player,并执行了“流”>“允许远程控制”。(这必须在另一台计算机上进行:它在同一计算机上不起作用)。此技术也在 Windows SDK 的“播放到示例”中有描述。它获得了 Windows 8 认证。
这是我的笔记本电脑同时无线向所有三个设备发送音频的图片!
WinRT 枚举中的配对:WinRT 设备枚举 API 只会报告“已配对”的设备。在家庭/私有网络上,设备将自动配对,但在公共网络上,您必须手动配对(开始屏幕 > 控制面板 > 设备 > 添加新设备)。也许这个“配对”功能是为了阻止您在公共网络上意外广播不该广播的内容?我的 SSDP 代码缺少此功能。
手动 SSDP 搜索的局限性:我的手动 SSDP 发现代码似乎找不到 WinRT 能够枚举或在网络邻居中显示的那么多设备。Windows 可能会枚举通过 SSDP 以外的多种发现协议发现的设备。
UPnP 协议
UPnP 在 www.upnp.org 上有出色的文档。
- UPnP 设备架构 - 解释了共享 UPnP 协议的工作原理,包括服务发现。这是所有 UPnP 协议的通用信息。其余文档特定于媒体播放。
- UPnP AV 架构 - 解释了媒体渲染的特定情况,从 DMS(数字媒体源)到 DMR(数字媒体渲染器)。
- ConnectionManager:1 - 文档记录了
ConnectionManager
接口的 API,用于确定支持哪些协议。 - AVTransport:1 - 文档记录了
AVTransport
接口的 API,用于告知 DMR 要播放的媒体源,并启动/停止它。
当媒体通过 UPnP 播放时,涉及三个方:
控制点 - 此方告诉其他方何时播放以及播放什么。
数字媒体渲染器 (DMR) - 扬声器/电视。它们可以被告知要播放的 URL,并且可以被告知开始/停止。
数字媒体源 (DMS) - 媒体的来源,即它提供 DMR 请求的 URL。
您可以想象很多种工作方式。也许您的计算机是控制点,DMS 是您阁楼服务器上的某个存档,而 DMR 是您的电视机。或者,您的计算机可能是 DMS,控制点是平板电脑、手机或遥控器,而 DMR 是您的 Wi-Fi 扬声器。
在本文中,我们将编写一个控制点和 DMS 的控制台应用程序。实际上,这是一个轻量级 DMS,它仅通过 HTTP 提供单个媒体:它不支持 DMS 通常应具备的完整搜索功能等。
UPnP 协议
UPnP 设备通过一种称为 **SSDP** 的协议相互查找,该协议在 UPnP 设备架构文档中有记载。它指定了新设备如何通知现有监听器它们何时加入网络。它还指定了一种更简单的形式,我们在此处描述,您可以通过它找出当前存在的所有设备。
>>> 出站多播 UDP 消息发送到 239.255.255.250:1900],使用 CRLF 行终止符和 UTF8 编码。请注意,消息末尾有一个空行。MX 参数告诉设备在最多 1 秒的随机时间间隔内响应,因此我们至少应该收听那么长时间。此数据包的格式以及响应,都包含在 UPnP 设备架构文档中。
M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
ST:urn:schemas-upnp-org:device:MediaRenderer:1
MAN: "ssdp:discover"
MX: 1
<<< 接收来自每个设备的 UDP 响应,由它们从它们的 ip 地址和任意端口发送,由我们绑定 UDP 套接字的任意 IP+端口接收。响应也使用 CRLF 和 UTF8,并在末尾包含一个空行(但由于它适合一个数据包,所以这无关紧要)。
HTTP/1.1 200 OK
ST: urn:schemas-upnp-org:device:MediaRenderer:1
CACHE-CONTROL: max-age=900
EXT:
USN: uuid:93997fb5-25f6-4385-8356-c705f0e7e0d9::urn:schemas-upnp-org:device:MediaRenderer:1
SERVER: Windows NT/5.0, UPnP/1.0
LOCATION: http://192.168.0.128:35142/
Content-Length: 0
>>> 出站 TCP 消息,针对每个设备,执行 HTTP-GET 以检索设备 LOCATION: 响应中指定的 URI。在这种情况下,我们创建一个 TCP 套接字,将其连接到 192.168.0.128:35142,并发送以下消息(同样使用 CRLF、UTF8 和末尾的空行)。
GET / HTTP/1.1
<<< 同一 TCP 套接字上的响应。与所有 HTTP 响应一样,头部以 UTF8 读取,并以空行终止,然后是必须以指定编码解释的“Content-Length”字节。(顺便说一句,HttpClient.GetStringAsync
和 HttpContent.ReadAsStringAsync
中存在一个错误,当字符集用引号括起来时,它们会引发异常,如这里所示。)在此消息中,请注意 UDN(唯一设备名称)以及它支持的三个服务的 ControlURLs。此发现响应消息的格式在 UPnP 架构文档中。
HTTP/1.1 200 OK
CONTENT-TYPE: text/xml;charset="utf-8"
X-AV-Server-Info: av="5.0"; cn="Sony Corporation"; mn="SA-NS300"; mv="1.00"
X-AV-Physical-Unit-Info: pa="SA-NS300"
CONTENT-LENGTH: 3169
<?xml version="1.0"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion><major>1</major><minor>0</minor></specVersion>
<device>
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
<UDN>uuid:5f9ec1b3-ed59-1900-4530-0007f521ebd6</UDN>
<friendlyName>SA-NS300</friendlyName>
<presentationURL>http://192.168.0.122/</presentationURL>
<manufacturer>Sony Corporation</manufacturer>
<manufacturerURL>http://www.sony.net/</manufacturerURL>
<modelDescription>Network Speaker</modelDescription>
<modelName>SA-NS300</modelName>
<iconList>
<icon><mimetype>image/jpeg</mimetype><width>48</width><height>48</height>
<depth>24</depth><url>/speaker 48.jpg</url></icon>
<icon><mimetype>image/jpeg</mimetype><width>120</width><height>120</height>
<depth>24</depth><url>/speaker 120.jpg</url></icon>
<icon><mimetype>image/png</mimetype><width>48</width><height>48</height>
<depth>24</depth><url>/speaker 48.png</url></icon>
<icon><mimetype>image/png</mimetype><width>120</width><height>120</height>
<depth>24</depth><url>/speaker 120.png</url></icon>
</iconList>
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
<serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
<SCPDURL>/RenderingControl/desc.xml</SCPDURL>
<controlURL>/RenderingControl/ctrl</controlURL>
<eventSubURL>/RenderingControl/evt</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
<SCPDURL>/ConnectionManager/desc.xml</SCPDURL>
<controlURL>/ConnectionManager/ctrl</controlURL>
<eventSubURL>/ConnectionManager/evt</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
<serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
<SCPDURL>/AVTransport/desc.xml</SCPDURL>
<controlURL>/AVTransport/ctrl</controlURL>
<eventSubURL>/AVTransport/evt</eventSubURL>
</service>
</serviceList>
</device>
</root>
协议的下一阶段是与设备通过其控制接口进行交互。对于此特定设备,一个媒体渲染器,整体协议在“UPnP AV 架构”文档中有所记载。首先,我们选择查询设备支持哪些媒体格式。
>>> 出站 TCP 消息,在设备的 ConnectionManagerUrl
上执行“GetProtocolInfo SOAP 操作”,我们之前已检索到该 URL。如果您愿意,可以在之前使用的同一 TCP 套接字上执行此操作,前提是该套接字仍然打开且位于同一主机上。HTTP/1.1 套接字默认保持打开状态一段时间;HTTP/1.0 套接字默认关闭,除非它们具有 Keep-Alive 标头,在这种情况下,它们将保持打开状态一段时间。在本例中,我们选择关闭上述 TCP 套接字,并打开另一个连接到 192.168.0.122:8080(使用任何本地 IP/端口),并发送以下消息。一如既往,CRLF、UTF8 和末尾的空行,Content-Length 是正文的字节大小(未经编码);“UPnP 架构”文档详细介绍了 UPnP 中 SOAP 消息所需的所有内容。请注意,SOAP 操作指定了两次,一次在 SOAPAction 标头中(带有其 schema),另一次在 <u:GetprotocolInfo>
中(同样带有其 schema)。
POST /ConnectionManager/ctrl HTTP/1.1
Host: 192.168.0.122:8080
Content-Length: 299
Content-Type: text/xml; charset="utf-8"
SOAPAction: "urn:schemas-upnp-org:service:ConnectionManager:1#GetProtocolInfo"
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetProtocolInfo xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
</u:GetProtocolInfo>
</s:Body>
</s:Envelope>
同一 TCP 套接字上的 <<< 响应。如 UPnP 架构文档所述,每个可能的动作都有一些 IN 参数(这些参数将在出站消息中提供,但 GetProtocolInfo
没有任何参数)。还有 OUT 参数,它们在响应中提供。在本例中,有两个 out 参数 Source 和 Sink。它们的含义和格式记录在“ConnectionManager:1”文档的 2.5.2“协议概念”中。在这种情况下,设备告诉我们它能够播放哪些类型的媒体源。它可以发出 HTTP-GET 请求,通过任何网络,并接受 MIME 类型“audio/L16;rate=44100;channels=1”或“...channels=2”。还有大约二十种其他 MIME 类型(我截断了),包括 MP3 音频。
HTTP/1.1 200 OK
CONTENT-LENGTH: 4114
CONTENT-TYPE: text/xml; charset="utf-8"
X-AV-Server-Info: av="5.0"; cn="Sony Corporation"; mn="SA-NS300"; mv="1.00"
X-AV-Physical-Unit-Info: pa="SA-NS300"
EXT:
SERVER: KnOS/3.2 UPnP/1.0 DMP/3.5
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s = "http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle = "http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
<Source></Source>
<Sink>http-get:*:audio/L16;rate=44100;channels=1:
DLNA.ORG_PN=LPCM;DLNA.ORG_FLAGS=9D300000000000000000000000000000,
http-get:*:audio/L16;rate=44100;channels=2:DLNA.ORG_PN=LPCM;
DLNA.ORG_FLAGS=9D300000000000000000000000000000...[truncated]...</Sink>
</u:GetProtocolInfoResponse>
</s:Body>
</s:Envelope>
>>> 发送 SetAVTransportURI 的 TCP 消息到我们之前获取并使用的设备 AVTransport
URL。我怎么知道要向哪个设备发送哪些操作,何时发送,以及按什么顺序?这一切都在“UPnP AV 架构”文档中有描述。在本例中,我使用 SetAVTransportURI
来告诉设备它将要从中请求音频的 URI。(希望音频的 MIME 类型是设备可以接受的!)我可以选择任何 URI。例如,我可以选择互联网上 MP3 的 URI,然后扬声器就会准备播放它。但我选择了“http://192.168.0.128:62890/dummy.l16”,这是一个我在我的控制台应用程序本身设置了一个迷你 HTTP 服务器的 IP+端口上定义的 URI。这样,扬声器将直接从我的应用程序获取音频。SetAVTransportURI
操作和响应的含义,以及它所有的 IN 和 OUT 参数,都在“AVTransport:1”文档中解释。
POST /AVTransport/ctrl HTTP/1.1
Host: 192.168.0.122:8080
Content-Length: 449
Content-Type: text/xml; charset="utf-8"
SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI"
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<CurrentURI>http://192.168.0.128:62890/dummy.l16</CurrentURI>
<CurrentURIMetaData></CurrentURIMetaData>
</u:SetAVTransportURI>
</s:Body>
</s:Envelope>
同一 TCP 套接字上的 <<< 响应。此操作没有 OUT 参数,因此响应也不包含任何参数。
HTTP/1.1 200 OK
CONTENT-LENGTH: 332
CONTENT-TYPE: text/xml; charset="utf-8"
X-AV-Server-Info: av="5.0"; cn="Sony Corporation"; mn="SA-NS300"; mv="1.00"
X-AV-Physical-Unit-Info: pa="SA-NS300"
EXT:
SERVER: KnOS/3.2 UPnP/1.0 DMP/3.5
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:SetAVTransportURIResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
</u:SetAVTransportURIResponse>
</s:Body>
</s:Envelope>
>>> 发送 Play 的 TCP 消息到设备 AVTransport
URL 执行“Play”SOAP 操作,与之前检索和使用的相同。同样,有可能重新使用与之前请求相同的 TCP 套接字,但我选择了每次创建一个新套接字(或使用更强大的库来抽象所有这些)。同样,此 Play
操作的含义和参数记录在“AVTransport:1”文档中。
POST /AVTransport/ctrl HTTP/1.1
Host: 192.168.0.122:8080
Content-Length: 329
Content-Type: text/xml; charset="utf-8"
SOAPAction: "urn:schemas-upnp-org:service:AVTransport:1#Play"
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<Speed>1</Speed>
</u:Play>
</s:Body>
</s:Envelope>
<<< 响应。响应 Play
消息后,设备开始从我们告诉它使用的 URI 请求数据并开始播放!我们很快就会讲到这一点。同时,它会进行确认。
HTTP/1.1 200 OK
CONTENT-LENGTH: 306
CONTENT-TYPE: text/xml; charset="utf-8"
X-AV-Server-Info: av="5.0"; cn="Sony Corporation"; mn="SA-NS300"; mv="1.00"
X-AV-Physical-Unit-Info: pa="SA-NS300"
EXT:
SERVER: KnOS/3.2 UPnP/1.0 DMP/3.5
<?xml version="1.0" encoding="utf-8"?>
<s:Envelope
xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:PlayResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
</u:PlayResponse>
</s:Body>
</s:Envelope>
<<< 使用 http-get 发出的请求来自扬声器。我们告诉扬声器使用“http://192.168.0.128:62890/dummy.l16”作为它们的媒体源。因此,它们遵循此协议,打开到此 IP 和端口的 TCP 连接,并对指定路径发出 GET 请求。在我的控制台应用程序中,我实现了一个 TCP 服务器,它在此 IP+端口上监听请求,因此我可以确切地看到扬声器发出了什么请求。这是一个标准的 HTTP 请求,因此它是 UTF8 编码,使用 CRLF,我们必须持续读取请求直到收到末尾的空行。看到这些头部很有意思!我们发现 Sony 使用 WinAmp 代码来实现其扬声器。
GET /dummy.l16 HTTP/1.1
Host: 192.168.0.128:36743
Icy-MetaData: 1
Connection: close
transferMode.dlna.org: Streaming
User-Agent: WinampMPEG/2.8
Accept: */*
X-AV-Client-Info: av="5.0"; cn="Sony Corporation"; mn="SA-NS300"; mv="1.00"
X-AV-Physical-Unit-Info: pa="SA-NS300"
>>> 来自我们 TCP 服务器的响应发送给扬声器。这是对 HTTP-GET 消息的正常响应:它有头部(UTF8 编码,CRLF,以空行结束)。之后,我们发送响应的正文——也就是说,我们将原始 PCM 音频通过 TCP 套接字发送。我们自己在 Content-Header 中指定的 MIME 类型告诉扬声器它们正在接收什么;规范可在http://tools.ietf.org/html/rfc2586找到。这是 PCM 数据,两个通道:第一个 16 位样本来自左声道,然后是第一个 16 位样本来自右声道,然后是第二个 16 位样本来自左声道,依此类推。每个样本都是一个 16 位二进制补码数字。我认为它是“网络字节顺序”,最高有效字节在前,但我会混淆。我们可以像喜欢的那样,在这个 TCP 流上继续发送数据。我们可以以某种速率发送。有些客户端会尝试尽可能多地预先缓存。其他客户端只会接收最多几秒钟的数据(以应对随机的网络延迟)。
HTTP/1.1 200 OK
Content-Type: audio/L16;rate=44100;channels=2
....raw PCM data, in audio/L16 format......
....Trallalla..BOOM..deeay...............
.........
...
.
.
附加代码示例中还有其他有用的操作——GetTransportInfo
、GetPositionInfo
、Stop
。它们都遵循与此处相同的模式。
实现协议的服务器端
现在,我们必须实现该协议。我将“反向”解释实现——首先我将展示提供媒体的迷你 HTTP 服务器的实现,然后我将展示协议的其余部分。
服务器将监听传入的 TCP 请求。我们必须决定在哪个网络适配器上监听,即使用什么本地 IP 地址。我在这里硬编码了它,但在附加代码中,我们实际上是在 SSDP 发现阶段确定最佳本地 IP 地址。
Dim LocalIp = "192.168.0.128"
.NET 代码:接下来,用于设置接受连接的服务器 TCP 监听器的基础结构。这是使用 .NET 4.5 并在任何安装了 .NET45 的计算机上运行(例如,控制台应用程序)的一个版本。它使用 data.Start(1)
来只排队最多 1 个待处理请求。最后一行给出了我们将要告诉 AVTransport
从哪个 URI 进行播放的“dataLocation
”。
data.Start(1)
Dim dataTask = Task.Run(
Async Function()
While True
Using dataConnection = Await data.AcceptTcpClientAsync(),
dataStream = dataConnection.GetStream()
Try
Await DoUpnpDataDialogAsync(dataStream, dataStream, progress)
Exit While
Catch ex As IO.IOException When ex.HResult = -2146232800
' Why would a DigitalMediaRenderer open a data connection only to
' close it again immediately and reopen another one? I don't know,
' but some do so we offer them a second opportunity.
End Try
End Using
End While
End Function)
Dim dataLocation = New Uri("http://" & data.Server.LocalEndPoint.ToString() & "/dummy.l16")
WinRT 代码:上面的代码不允许在应用商店应用中使用。您应该改用以下内容:
Using data As New Windows.Networking.Sockets.StreamSocketListener()
AddHandler data.ConnectionReceived,
Async Sub(sender As Windows.Networking.Sockets.StreamSocketListener, _
args As Windows.Networking.Sockets.StreamSocketListenerConnectionReceivedEventArgs)
Using args.Socket
Using data_reader = IO.WindowsRuntimeStreamExtensions.AsStreamForRead(
args.Socket.InputStream),
data_writer = IO.WindowsRuntimeStreamExtensions.AsStreamForWrite(
args.Socket.OutputStream)
Try
Await DoUpnpDataDialogAsync(data_writer, data_reader, progress)
Catch ex As Exception When ex.HResult = -2147014842
End Try
End Using
End Using
End Sub
' CAPABILITY: privateNetworkClientServer
Await data.BindServiceNameAsync("")
Dim dataLocation = New Uri("http://" & device.LocalUri.Host & _
":" & data.Information.LocalPort & "/stream.l16")
...
End Using
DoUpnpDataDialogAsync。上面的代码仅设置套接字。服务器的核心在此函数中:
Async Function DoUpnpDataDialogAsync(writer As IO.Stream, reader As IO.Stream,
progress As IProgress(Of String)) As Task
Dim request = ""
Using sreader As New IO.StreamReader(reader, Text.Encoding.UTF8, False, 1024, True)
While True
Dim header = Await sreader.ReadLineAsync()
If String.IsNullOrWhiteSpace(header) Then Exit While
request &= header & vbCrLf
End While
End Using
Dim response = "HTTP/1.1 200 OK" & vbCrLf &
"Content-Type: audio/L16;rate=44100;channels=2" & vbCrLf & vbCrLf
Dim responsebuf = Text.Encoding.UTF8.GetBytes(response)
Await writer.WriteAsync(responsebuf, 0, responsebuf.Length)
Dim MaryHadALittleLamb = {247, 220, 196, 220, 247, 247, 247, 220, 220, 220, 247, 294, 294}
Dim phase = 0.0
Dim buf = New Byte(4096 * 4 - 1) {}
For imusic = 0 To MaryHadALittleLamb.Length * 8 - 1
Dim freq = If(imusic Mod 8 = 0, 0, MaryHadALittleLamb(imusic \ 8))
For i = 0 To buf.Length \ 4 - 1
phase += freq / 44100 * 2 * Math.PI
Dim amplitude = If(freq = 0, 0, Math.Sin(phase) * (Short.MaxValue - 1))
Dim bb = BitConverter.GetBytes(CShort(amplitude))
buf(i * 4 + 0) = bb(1) ' left channel, MSB
buf(i * 4 + 1) = bb(0) ' left channel, LSB
buf(i * 4 + 2) = bb(1) ' right channel, MSB
buf(i * 4 + 3) = bb(0) ' right channel, LSB
Next
Await writer.WriteAsync(buf, 0, buf.Length)
Next
Await writer.FlushAsync()
End Function
实现协议的客户端
在本文中,我将首先展示实现该协议的低级代码。之后,我将展示一些实现相同目的的更高级的包装器。
我这样写这篇文章是因为我相信,作为开发者,从根本上理解协议如何工作始终很重要。没有哪个网络抽象/包装器足够干净,可以让您忽略其底层。
WinRT SSDP 代码:我首先提供此代码的 WinRT 版本,因为 WinRT 网络 API 比 .NET 更干净。代码非常直接。它发送多播消息,并等待最多 1200 毫秒的响应。它收集收到的所有响应。(这里存在一个竞态条件,应该修复:如果响应同时到达,MessageReceived 处理程序可能会在不同线程上同时被调用)。
Dim remoteIp As New Windows.Networking.HostName("239.255.255.250"), remotePort = "1900"
Dim reqbuf = AsBuffer(CreateSsdpRequest(remoteIp.RawName() & ":" & remotePort)).AsBuffer()
Dim locs As New HashSet(Of Tuple(Of Uri, Uri))
Using socket As New Windows.Networking.Sockets.DatagramSocket()
AddHandler socket.MessageReceived,
Sub(sender, e)
If e.LocalAddress.IPInformation.NetworkAdapter.IanaInterfaceType = 24 Then Return
' don't show anything from the loopback adapter
Using reader = e.GetDataReader()
Dim responsebuf = New Byte(CInt(reader.UnconsumedBufferLength - 1)) {}
reader.ReadBytes(responsebuf)
Dim location = ParseSsdpResponse(responsebuf, responsebuf.Length)
locs.Add(Tuple.Create(location, New Uri("http://" & e.LocalAddress.RawName)))
End Using
End Sub
' CAPABILITY: PrivateNetworks
Await socket.BindEndpointAsync(Nothing, "")
socket.Control.OutboundUnicastHopLimit = 1
socket.JoinMulticastGroup(remoteIp)
' There's no WinRT equivalent of ReuseAddress, but it seems not to be needed
Using stream = Await socket.GetOutputStreamAsync(remoteIp, remotePort)
Await stream.WriteAsync(reqbuf)
End Using
Await Task.Delay(1200)
End Using
.NET SSDP 代码:此代码比 WinRT 等效代码稍微丑陋一些。据我所知,它必须显式调用它正在监听的网络接口……
Dim remoteEp = New Net.IPEndPoint(Net.IPAddress.Parse("239.255.255.250"), 1900)
Dim locs As New HashSet(Of Tuple(Of Uri, Uri))
Dim localIps =
From network In Net.NetworkInformation.NetworkInterface.GetAllNetworkInterfaces()
Where network.OperationalStatus = Net.NetworkInformation.OperationalStatus.Up
Where network.NetworkInterfaceType <> Net.NetworkInformation.NetworkInterfaceType.Loopback
Let localAddr = (From uaddr In network.GetIPProperties.UnicastAddresses _
Where uaddr.Address.AddressFamily = Net.Sockets.AddressFamily.InterNetwork).FirstOrDefault
Where Not localAddr Is Nothing
Select localAddr.Address
For Each localIp In localIps
Using socket As New Net.Sockets.Socket(Net.Sockets.AddressFamily.InterNetwork,
Net.Sockets.SocketType.Dgram,
Net.Sockets.ProtocolType.Udp)
socket.SetSocketOption(Net.Sockets.SocketOptionLevel.Socket,
Net.Sockets.SocketOptionName.ReuseAddress, True)
socket.SetSocketOption(Net.Sockets.SocketOptionLevel.IP,
Net.Sockets.SocketOptionName.AddMembership,
New Net.Sockets.MulticastOption(remoteEp.Address))
socket.SetSocketOption(Net.Sockets.SocketOptionLevel.IP,
Net.Sockets.SocketOptionName.MulticastTimeToLive, 1)
socket.Bind(New Net.IPEndPoint(localIp, 0))
Dim receiverTask = Task.Run(
Sub()
Dim responsebuf = New Byte(51200) {}
Do
Try
Dim ep As Net.EndPoint = New Net.IPEndPoint(Net.IPAddress.Any, 0)
Dim len = socket.ReceiveFrom(responsebuf, ep)
Dim location = ParseSsdpResponse(responsebuf, len)
locs.Add(Tuple.Create(location, New Uri("http://" & localIp.ToString)))
Catch ex As Net.Sockets.SocketException When ex.ErrorCode = 10004
Return ' WSACancelBlockingCall, when the socket is closed
End Try
Loop
End Sub)
Dim request = CreateSsdpRequest(remoteEp.ToString())
socket.SendTo(request, remoteEp)
Await Task.Delay(1200)
socket.Close()
Await receiverTask
End Using
Next
SSDP 数据包的辅助函数:一个函数用于构造 SSDP 请求数据包,一个函数用于解析响应并提取出那个关键的 LOCATION: 头部。
Function CreateSsdpRequest(authority As String) As Byte()
Dim request = "M-SEARCH * HTTP/1.1" & vbCrLf &
"HOST: " & authority & vbCrLf &
"ST:urn:schemas-upnp-org:device:MediaRenderer:1" & vbCrLf &
"MAN: ""ssdp:discover""" & vbCrLf &
"MX: 1" & vbCrLf &
"" & vbCrLf
Return Text.Encoding.UTF8.GetBytes(request)
End Function
Function ParseSsdpResponse(responsebuf As Byte(), len As Integer) As Uri
Dim response = Text.Encoding.UTF8.GetString(responsebuf, 0, len)
Return (From line In response.Split({vbCr(0), vbLf(0)})
Where line.ToLowerInvariant().StartsWith("location:")
Select New Uri(line.Substring(9).Trim())).FirstOrDefault
End Function
检索服务描述:到目前为止,我们有一个 HashSet
,其中包含可以了解设备信息的 Location: URI,以及我们找到每个设备的 LocalIp。现在我们需要检索这些 URI,并解析它们以发现 ConnectionManager
和 AVTransport
控制 URI:
Imports <xmlns:ud="urn:schemas-upnp-org:device-1-0">
Dim desc_request = MakeRawGetRequest(deviceLocation)
Dim desc_response = Await GetXmlAsync(desc_request, progress)
Dim desc_friendlyName = desc_response.<ud:root>.<ud:device>.<ud:friendlyName>.Value
Dim desc_services = desc_response.<ud:root>.<ud:device>.<ud:serviceList>.<ud:service>
Dim connectionManagerUri =
(From service In desc_services
Where service.<ud:serviceType>.Value = "urn:schemas-upnp-org:service:ConnectionManager:1"
Select New Uri(deviceLocation, service.<ud:controlURL>.Value)).FirstOrDefault
Dim avTransportUri =
(From service In desc_services
Where service.<ud:serviceType>.Value = "urn:schemas-upnp-org:service:AVTransport:1"
Select New Uri(deviceLocation, service.<ud:controlURL>.Value)).FirstOrDefault
对 ConnectionManager 和 AVTransport 执行 SOAP 操作。协议的其余部分现在都非常顺利了。
Imports <xmlns:uc="urn:schemas-upnp-org:service:ConnectionManager:1">
Imports <xmlns:ut="urn:schemas-upnp-org:service:AVTransport:1">
Dim getprotocol_request = MakeSoapRequest(connectionManagerUri, <uc:GetProtocolInfo/>, {})
Dim getprotocol_response = Await GetSoapAsync(getprotocol_request, progress)
Dim mimeTypes = getprotocol_response.<Sink>.Value.Split({","c})
Dim seturi_request = MakeSoapRequest(avTransportUri, <ut:SetAVTransportURI/>,
{"InstanceID", "0", "CurrentURI", _
dataLocation.ToString(), "CurrentURIMetaData", ""})
Dim seturi_response = Await GetSoapAsync(seturi_request, progress)
Dim play_request = MakeRawSoapRequest(avTransportUri, <ut:Play/>,
{"InstanceID", "0", "Speed", "1"})
Dim play_response = Await GetSoapAsync(play_request, progress)
用于发起各种 TCP 请求的辅助函数
Function MakeRawGetRequest(requestUri As Uri) As Tuple(Of Uri, Byte())
Dim s = "GET " & requestUri.PathAndQuery & " HTTP/1.1" & vbCrLf &
"Host: " & requestUri.Host & ":" & requestUri.Port & vbCrLf & vbCrLf
Return Tuple.Create(requestUri, Text.Encoding.UTF8.GetBytes(s))
End Function
Function MakeRawSoapRequest(requestUri As Uri, soapAction As XElement,
args As String()) As Tuple(Of Uri, Byte())
Dim soapSchema = soapAction.Name.NamespaceName
Dim soapVerb = soapAction.Name.LocalName
Dim argpairs As New List(Of Tuple(Of String, String))
For i = 0 To args.Length - 1 Step 2
argpairs.Add(Tuple.Create(args(i), args(i + 1)))
Next
Dim s = "POST " & requestUri.PathAndQuery & " HTTP/1.1" & vbCrLf &
"Host: " & requestUri.Authority & vbCrLf &
"Content-Length: ?" & vbCrLf &
"Content-Type: text/xml; charset=""utf-8""" & vbCrLf &
"SOAPAction: """ & soapSchema & "#" & soapVerb & """" & vbCrLf &
"" & vbCrLf &
"<?xml version=""1.0""?>" & vbCrLf &
"<s:Envelope xmlns:s=""http://schemas.xmlsoap.org/soap/" & _
"envelope/"" s:encodingStyle=""http://schemas.xmlsoap.org/soap/encoding/"">" & vbCrLf &
" <s:Body>" & vbCrLf &
" <u:" & soapVerb & " xmlns:u=""" & soapSchema & """>" & vbCrLf &
String.Join(vbCrLf, (From arg In argpairs Select " <" & _
arg.Item1 & ">" & arg.Item2 & "</" & arg.Item1 & ">").Concat({""})) &
" </u:" & soapVerb & ">" & vbCrLf &
" </s:Body>" & vbCrLf &
"</s:Envelope>" & vbCrLf
'
Dim len = Text.Encoding.UTF8.GetByteCount(s.Substring(s.IndexOf("<?xml")))
s = s.Replace("Content-Length: ?", "Content-Length: " & len)
Return tuple.Create(requestUri, Text.Encoding.UTF8.GetBytes(s))
End Function
Async Function GetXmlAsync(request As Tuple(Of Uri, Byte())) As Task(Of XDocument)
Dim requestUri = request.Item1, requestBody = request.Item2
Using socket As New Net.Sockets.TcpClient(requestUri.DnsSafeHost, requestUri.Port), stream = socket.GetStream()
If Not progress Is Nothing Then progress.Report("--------->" & _
vbCrLf & Text.Encoding.UTF8.GetString(requestBody))
Await stream.WriteAsync(requestBody, 0, requestBody.Length)
Await stream.FlushAsync()
'
Dim headers = "", body = ""
Using sreader As New IO.StreamReader(stream, Text.Encoding.UTF8, False, 1024, True)
Dim len = 0
While True
Dim header = Await sreader.ReadLineAsync()
If String.IsNullOrWhiteSpace(header) Then Exit While
If header.ToLower().StartsWith("content-length:") Then len = CInt(header.Substring(15).Trim())
headers &= header & vbCrLf
End While
Dim buf = New Char(1024) {}
While Text.Encoding.UTF8.GetByteCount(body) < len
Dim red = Await sreader.ReadAsync(buf, 0, 1024)
body &= New String(buf, 0, red)
End While
End Using
'
If Not headers.StartsWith("HTTP/1.1 200 OK") Then
Throw New Net.Http.HttpRequestException(headers & vbCrLf & body)
End If
Return XDocument.Parse(body)
End Using
End Function
Async Function GetSoapAsync(request As Tuple(Of Uri, Byte())) As Task(Of XElement)
Dim requestLines = Text.Encoding.UTF8.GetString(request.Item2).Split({vbCrLf})
Dim soapAction = (From s In requestLines Where _
s.ToLower().StartsWith("soapaction:")).FirstOrDefault.Substring(11).Trim(" "c, """"c)
Dim soapResponse As Xml.Linq.XName = "{" & soapAction.Replace("#", "}") & _
"Response" ' e.g. {schema}actionResponse
'
Dim xml = Await GetXmlAsync(http, request)
Dim body = xml.<soap:Envelope>.<soap:Body>.Elements(soapResponse).FirstOrDefault
If body Is Nothing Then Throw New Net.Http.HttpRequestException("no soap body")
Return body
End Function
更高级别的抽象:使用 HttpClient 和 XML 字面量
上面的代码助手使用原始字符串和字节构造了 HTTP 和 SOAP 请求。这显然是一个糟糕的解决方案——笨拙且容易出错。最好使用 HttpClient
和 VB 的 XML 字面量支持:
Function MakeGetRequest(requestUri As Uri) As Net.Http.HttpRequestMessage
Return New Net.Http.HttpRequestMessage(Net.Http.HttpMethod.Get, requestUri)
End Function
Function MakeSoapRequest(requestUri As Uri, soapAction As XElement,
args As String()) As Net.Http.HttpRequestMessage
Dim m As New Net.Http.HttpRequestMessage(Net.Http.HttpMethod.Post, requestUri)
m.Headers.Add("SOAPAction", """" & soapAction.Name.NamespaceName & _
"#" & soapAction.Name.LocalName & """")
Dim c = <?xml version="1.0"?>
<soap:Envelope soap:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<soap:Body>
<<%= soapAction.Name %>>
<%= Iterator Function()
For i = 0 To args.Length - 1 Step 2
Yield <<%= args(i) %>><%= args(i + 1) %></>
Next
End Function() %>
</>
</soap:Body>
</soap:Envelope>
m.Content = New Net.Http.StringContent(c.Declaration.ToString() & vbCrLf & _
c.ToString() & vbCrLf, Text.Encoding.UTF8, "text/xml")
Return m
End Function
<Runtime.CompilerServices.Extension>
Async Function GetXmlAsync(http As Net.Http.HttpClient,
request As Net.Http.HttpRequestMessage) As Task(Of XDocument)
Using response = Await http.SendAsync(request)
response.EnsureSuccessStatusCode()
' workaround for bug in HttpClient:
If Not response.Content.Headers.ContentType.CharSet Is Nothing Then
response.Content.Headers.ContentType.CharSet = _
response.Content.Headers.ContentType.CharSet.Trim(""""c)
Dim body = Await response.Content.ReadAsStringAsync()
Return XDocument.Parse(body)
End Using
End Function
<Runtime.CompilerServices.Extension>
Async Function GetSoapAsync(http As Net.Http.HttpClient,
request As Net.Http.HttpRequestMessage) As Task(Of XElement)
Dim soapAction = request.Headers.GetValues("SOAPAction").FirstOrDefault.Trim(""""c) ' e.g. schema#action
Dim soapResponse As Xml.Linq.XName = _
"{" & soapAction.Replace("#", "}") & _
"Response" ' e.g. {schema}actionResponse
'
Dim xml = Await GetXmlAsync(http, request)
Dim body = xml.<soap:Envelope>.<soap:Body>.Elements(soapResponse).FirstOrDefault
If body Is Nothing Then Throw New Net.Http.HttpRequestException("no soap body")
Return body
End Function
更高级别的抽象:使用 WinRT 设备枚举
实际上,Windows 8 内置了 SSDP 发现功能。我们可以删除我们现有的 SSDP 代码,并用“WinRT 设备枚举”替换它。我在另一篇文章中更全面地描述了 win8 设备枚举:https://codeproject.org.cn/Articles/458550/Device-enumeration-in-Windows-8。
WinRT 不按设备类型进行探测。回想一下,我们发送了一个 SSDP 探测,针对所有具有设备类型“urn:schemas-upnp-org:device:MediaRenderer:1”的设备。而 Windows 会探测所有设备——但不再允许我们按设备类型查询其结果。
我们要做的是依赖于所有 MediaRenderer:1 设备都具有 RenderingControl
服务和 ConnectionManager
接口,以及(可选)AVTransport
接口的事实。所以我们将查找任何具有这些接口的设备。
这引发了一个有趣的哲学问题——是否存在某些设备其类型不是 MediaRenderer:1,但它们暴露了 ConnectionManager 和 AVTransport 接口?我们的代码应该能与它们一起工作吗?它们会做什么?我不知道!
WinRT 不按服务类型字符串进行探测。另外,我们自己的 SSDP 实现会查找类型为“urn:schemas-upnp-org:service:ConnectionManager:1”的服务。UPnP 协议就是这样查找 ConnectionManager
服务的。Well,WinRT 设备枚举将它们称为接口而不是服务。而且它不允许您按字符串查找它们。相反,您必须按 GUID 查找它们。我在下面写出了 GUID。我是如何发现 GUID 的?没有好的方法。您只需插入一些您知道具有正确服务类型的实际物理设备,测试 WinRT 设备枚举 API,并发现它们报告的 DeviceInterface::System.Devices.InterfaceClassGuid
属性的 GUID 是什么。
Dim RenderingControlInterfaceClass = New Guid("8660e926-ff3d-580c-959e-8b8af44d7cde")
Dim ConnectionManagerInterfaceClass = New Guid("ae9eb9c4-8819-51d8-879d-9a42ffb89d4e")
Dim AVTransportInterfaceClass = New Guid("4c38e836-6a2f-5949-9406-1788ea20d1d5")
WinRT 查找控制 URL 的方法。在我们自己的 SSDP 实现中,我们查找了 XML 描述的 service.<ud:controlURL>
元素。这为 ConnectionManager
和 AVTransport
服务提供了 control-URL。在 WinRT 中,controlURL
属性被隐藏了。我通过查找 Windows SDK 头文件中的 PKEYs,并通过实验确定了这个特定的 PKEY 指的是设备controlURL
属性:
Dim PKEY_PNPX_ServiceControlUrl = "{656A3BB3-ECC0-43FD-8477-4AE0404A96CD},16388"
用于 UPnP 的 WinRT 设备枚举代码。那么,这就是您在 WinRT 中用于发现 UPnP 设备控制 URL 的代码:
im RenderingControls =
Await Windows.Devices.Enumeration.Pnp.PnpObject.FindAllAsync(
Windows.Devices.Enumeration.Pnp.PnpObjectType.DeviceInterface,
{"System.Devices.DeviceInstanceId", "System.Devices.InterfaceClassGuid", "System.Devices.ContainerId"},
"System.Devices.InterfaceClassGuid:=""{" & RenderingControlInterfaceClass.ToString() & "}""")
For Each device In RenderingControls
If Not device.Properties.ContainsKey("System.Devices.DeviceInstanceId") Then Continue For
If Not device.Properties.ContainsKey("System.Devices.ContainerId") Then Continue For
Dim id = CStr(device.Properties("System.Devices.DeviceInstanceId"))
Dim containerId = CType(device.Properties("System.Devices.ContainerId"), Guid)
Dim ConnectionManagerInterface =
(Await Windows.Devices.Enumeration.Pnp.PnpObject.FindAllAsync(
Windows.Devices.Enumeration.Pnp.PnpObjectType.DeviceInterface,
{"System.Devices.DeviceInstanceId", _
"System.Devices.InterfaceClassGuid", PKEY_PNPX_ServiceControlUrl},
"System.Devices.DeviceInstanceId:=""" & id & _
""" AND System.Devices.InterfaceClassGuid:=""{" & _
ConnectionManagerInterfaceClass.ToString() & "}""")).FirstOrDefault
If ConnectionManagerInterface Is Nothing Then Continue For
If Not ConnectionManagerInterface.Properties.ContainsKey(PKEY_PNPX_ServiceControlUrl) Then Continue For
Dim connectionManagerUrl = New Uri(CStr(ConnectionManagerInterface.Properties(PKEY_PNPX_ServiceControlUrl)))
Dim AVTransportInterface =
(Await Windows.Devices.Enumeration.Pnp.PnpObject.FindAllAsync(
Windows.Devices.Enumeration.Pnp.PnpObjectType.DeviceInterface,
{"System.Devices.DeviceInstanceId", _
"System.Devices.InterfaceClassGuid", PKEY_PNPX_ServiceControlUrl},
"System.Devices.DeviceInstanceId:=""" & id & _
""" AND System.Devices.InterfaceClassGuid:=""{" & _
AVTransportInterfaceClass.ToString() & "}""")).FirstOrDefault
If Not AVTransportInterface Is Nothing AndAlso Not _
AVTransportInterface.Properties.ContainsKey(PKEY_PNPX_ServiceControlUrl) Then
AVTransportInterface = Nothing
Dim avTransportUrl = If(AVTransportInterface Is Nothing, Nothing, _
New Uri(CStr(AVTransportInterface.Properties(PKEY_PNPX_ServiceControlUrl))))
Dim Container = Await Windows.Devices.Enumeration.Pnp.PnpObject.CreateFromIdAsync(
Windows.Devices.Enumeration.Pnp.PnpObjectType.DeviceContainer,
containerId.ToString(),
{"System.Devices.Connected", "System.Devices.FriendlyName"})
If Container Is Nothing Then Continue For
If Not Container.Properties.ContainsKey("System.Devices.Connected") Then Continue For
If Not Container.Properties.ContainsKey("System.Devices.FriendlyName") Then Continue For
Dim connected = CBool(Container.Properties("System.Devices.Connected"))
Dim friendlyName = CStr(Container.Properties("System.Devices.FriendlyName"))
If Not connected Then Continue For
...
Next
WinRT 代码用于发现已发现设备的本地 IP。还有最后一个任务。我们需要知道本地 IP,即设备将通过其连接到我们的 IP,以便我们能够构建正确的 URL 以供设备从中获取。在我们手动 SSDP 代码中,我们之所以知道,是因为我们知道从设备接收到 UDP 数据包的 IP 地址。但在 WinRT 设备枚举代码中,我们需要一个替代方法。
Try
Dim localUri As Uri
Using c As New Net.Sockets.TcpClient(connectionManagerUrl.DnsSafeHost,
connectionManagerUrl.Port)
Dim addr = CType(c.Client.LocalEndPoint, Net.IPEndPoint).Address
Dim localHost = addr.ToString()
If addr.AddressFamily = Net.Sockets.AddressFamily.InterNetworkV6 Then
localHost = "[" & localHost & "]"
End If
localUri = New Uri("http://" & localHost)
End Using
Catch ex As Net.Sockets.SocketException
' oh well, I suppose we can't connect - sometimes the "connected" flag is incorrect
End Try
关于代码的说明
这些代码都不健壮。我试图编写最少量的代码来解释所涉及的 API。其目的是您应该阅读本文档来学习 API,然后在您自己的应用程序基础设施中使用它们。
免责声明:尽管我在微软从事 VB/C# 语言团队的工作,但本文档仅是基于公开信息和实验的个人业余努力——它不属于我的专业领域,已在我的业余时间撰写,微软和我本人均不对其准确性作任何声明……
除了在此代码中广泛使用“Await
”和“Async
”之外。我曾是微软 VB/C# 语言团队的一员,他们设计了这些关键字,并且我实现了它们在 VB 和 C# 编译器中的很大一部分。我为它们感到非常自豪,很高兴看到它们使实现这些协议变得如此容易,并且此代码以微软推荐的最佳实践方式使用了它们!