基于DLNA实现iOS、Android投屏

由于我司需求,需要在iOS和安卓客户端实现DLNA投屏和控制。经过一番折腾,决定由我来研究DLNA。说起来又兴奋又紧张,兴奋希望自己能够弄出来然后跟安卓组讲解原理,紧张是因为怕自己能力不足做不出来。

DLNA网上的资料比较笼统不好入门,官方资料直接是每个1000多页的10几个PDF文档,根本无从下手。相关开源项目有名的有Platinum UPnP,但是由于它是基于C++实现的,相关文档并不全面。iOS相关开源项目都三四年没更新的,找来找去只好自己去啃自己去实现了。还好买到一本不错的书《智能家庭网络:技术、标准与应用实践》。通过近俩星期的研究,搞懂了DLNA核心协议UPnP基本逻辑,实现了投屏和控制功能的Demo。

下面就整理一下实现基本概念,实现过程和一些坑。

如果要直接看实现过程,请看以下三篇文章:

基础概念

DLNA

DLNA的全称是DIGITAL LIVING NETWORK ALLIANCE(数字生活网络联盟), 其宗旨是Enjoy your music, photos and videos, anywhere anytime, DLNA(Digital Living Network Alliance) 由索尼、英特尔、微软等发起成立、旨在解决个人PC,消费电器,移动设备在内的无线网络和有线网络的互联互通,使得数字媒体和内容服务的无限制的共享和增长成为可能,目前成员公司已达280多家。

DLNA标准包括多项协议及标准,其中最重要的部分是UPnP。对于我们目前的需求UPnP就能满足全部要求。

UPnP

通用即插即用(英语:Universal Plug and Play,简称UPnP)是由“通用即插即用论坛”(UPnP™ Forum)推广的一套网络协议。该协议的目标是使家庭网络(数据共享、通信和娱乐)和公司网络中的各种设备能够相互无缝连接,并简化相关网络的实现。UPnP通过定义和发布基于开放、因特网通讯网协议标准的UPnP设备控制协议来实现这一目标。

UPnP这个概念是从即插即用(Plug-and-play)派生而来的,即插即用是一种热拔插技术。

协议栈

UPnP设备体系结构包含了设备之间、控制点之间、设备和控制点之间的通信。完整的UPnP由设备寻址、设备发现、设备描述、设备控制、事件通知和基于Html的描述界面几部分构成。

UPnP协议栈

扫描二维码关注公众号,回复: 12186692 查看本文章
  1. UPnP是一个多层协议构成的框架体系,每一层都以相邻的下层为基础,同时又是相邻上层的基础。直至达到应用层为止。该图中的最下面是就是IP和TCP,共两层,负责设备的IP地址。
  2. 三层是HTTP、HTTPU、HTTPMU,这一层,属于传送协议层。传送的是内容都经过“封装”后,存放在特定的XML文件中的。对应的SSDP、GENA、SOAP指的是保存在XML文件中的数据格式。到这一层,已经解决了UPnP设备的IP地址和传送信息问题。
  3. 第四层是UPnP设备体系定义,仅仅是一个抽象的、公用的设备模型。任何UPnP设备都必须使用这一层。
  4. 第五层是UPnP论坛的各个专业委员会的设备定义层,在这个论坛中,不同电器设备由不同的专业委员会定义,例如:电视委员会只负责定义网络电视设备部分,空调器委员会只负责定义网络空调设备部分,依此类推。所有的不同类型的设备都被定义成一个专门的架构或者模板,供建立设备的时候使用。可以推知,进入这一层,设备已经被指定了明确用途。当然,这些都必须遵守标准化的规范。从目前看,UPnP已经可以支持大部分的设备:从电脑、电脑外设,移动设备和家用消费类电子设备等等,无所不包,随着这个体系的普及,将可能有更多的厂家承认这一标准,最终,可能演化为公认的行业标准。
  5. 最上层,也就是应用层,由UPnP设备制造厂商定义的部分。这一层的信息是由设备制造厂商来“填充” 的,这部分一般有设备厂商提供的、对设备控制和操作的底层代码,然后,就是名称序列号呀,厂商信息之类的东西。

设备

设备是提供服务的网路实体,是一个逻辑概念,一个屋里设备可以包含一个或者多个逻辑设备。例如一台PC可以有两个逻辑设备———视频播放器和图片浏览器。

服务

服务是UPnP中最小的可控单元,它包括一系列可控制而动作和一组记录该服务目前情况的状态。服务是依赖于设备存在的。

控制点

控制UPnP设备工作的网络终端,主要功能包括获取设备描述和相关服务列表;获取感兴趣的服务描述;发出控制消息控制设备动作;向感兴趣的服务发出订阅消息,以便当服务状态改变时,自动获得时间通知。

UPnP组件

一些术语

  • UUID

UUID含义是通用唯一识别码(Universally Unique Identifier),其目的是让分布式系统中的所有元素,都有唯一的辨识资讯,而不需要透过中央控制端来做辨识资讯的指定。其格式为xxxxxxxx-xxxx-xxxx-xxxxxxxxxxxxxxxx(8-4-4-16),分别为当前日期和时间,时钟序列,全局唯一的IEEE机器识别号,如果有网卡,从网卡mac地址获得,没有网卡以其他方式获得。

  • UDN

单一设备名(Unique Device Name),基于UUID,表示一个设备。在不同的时间,对于同一个设备此值应该是唯一的。

  • URI

Web上可用的每种资源 - HTML文档、图像、视频片段、程序等 - 由一个通用资源标志符(Universal Resource Identifier,简称”URI”)进行定位。 URI一般由三部分组成:访问资源的命名机制;存放资源的主机名;资源自身的名称,由路径表示。考虑下面的URI,它表示了当前的HTML 4.0规范:http://www.webmonkey.com.cn/html/html40/它表示一个可通过HTTP协议访问的资源,位于主机www.webmonkey.com.cn上,通过路径/html/html40访问。

  • URL

URL是URI命名机制的一个子集,URL是Uniform Resource Location的缩写,译为“统一资源定位符”。通俗地说,URL是Internet上用来描述信息资源的字符串,主要用在各种www客户程序和服务器程序上。采用URL可以用一种统一的格式来描述各种信息资源,包括文件、服务器的地址和目录等。

  • URN

URN:URL的一种更新形式,统一资源名称(URN,Uniform Resource Name)。唯一标识一个实体的标识符,但是不能给出实体的位置。标识持久性Internet资源。URN可以提供一种机制,用于查找和检索定义特定命名空间的架构文件。尽管普通的URL可以提供类似的功能,但是在这方面,URN 更加强大并且更容易管理,因为 URN 可以引用多个 URL。

实现

工作机制

UPnP设备的发现和控制分为6个步骤:寻址、发现、描述、控制、事件及展现。

UPnP工作机制

这三点分别在后面的三篇文章中进行介绍。

整体流程

整体工作流程如下:

UPnP整体工作流程

参考

基于DLNA实现iOS,Android投屏:SSDP发现设备

SSDP能够在局域网能简单地发现设备提供的服务。SSDP有两种发现方式:主动通知和搜索响应方式。

寻址

UPnP 技术是架构在 IP 网络之上。因此拥有一个网络中唯一的 IP 地址是 UPnP 设备正常工作的基础。UPnP 设备首先查看网络中是否有 DHCP 服务器,如果有,那么使用 DHCP 分配的 IP 即可;如果没有,则需要使用LLA技术来为自己找适合的IP地址。

另外,在 UPnP 运行过程中,UPnP 设备都需要周期性检测网络中是否有 DHCP 服务器存在,一旦发现有 DHCP 服务器,就必须终止使用 LLA 技术获取的 IP 地址,改用 DHCP 分配的 IP 地址。

发现

SSDP

SSDP:Simple Sever Discovery Protocol,简单服务发现协议,此协议为网络客户提供一种无需任何配置、管理和维护网络设备服务的机制。此协议采用基于通知和发现路由的多播发现方式实现。协议客户端在保留的多播地址:239.255.255.250:1900(IPV4)发现服务,(IPv6 是:FF0x::C)同时每个设备服务也在此地址上上监听服务发现请求。如果服务监听到的发现请求与此服务相匹配,此服务会使用单播方式响应。

常见的协议请求消息有两种类型,第一种是服务通知,设备和服务使用此类通知消息声明自己存在;第二种是查询请求,协议客户端用此请求查询某种类型的设备和服务。
iOS中使用GCDAsyncUdpSocket发送和接受SSDP请求、响应及通知,安卓也需要用类此框架来完成

所以我们发现设备也有两种方法

  1. 主动通知方式:当设备加入到网络中,向网络上所有控制点通知它所提供的服务,通知消息采用多播方式。
  2. 搜索——响应方式:当一个控制点加入到网络中,在网络搜索它感兴趣的所有设备和服务,搜索消息采用多播方式发送,而设备针对搜索的响应则是使用单播方式发送。

SSDP 设备类型及服务类型

设备类型 表示文字
UPnP_RootDevice upnp:rootdevice
UPnP_InternetGatewayDevice1 urn:schemas-upnp-org:device:InternetGatewayDevice:1
UPnP_WANConnectionDevice1 urn:schemas-upnp-org:device:WANConnectionDevice:1
UPnP_WANDevice1 urn:schemas-upnp-org:device:WANConnectionDevice:1
UPnP_WANCommonInterfaceConfig1 urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1
UPnP_WANIPConnection1 urn:schemas-upnp-org:device:WANConnectionDevice:1
UPnP_Layer3Forwarding1 urn:schemas-upnp-org:service:WANIPConnection:1
UPnP_WANConnectionDevice1 urn:schemas-upnp-org:service:Layer3Forwarding:1
服务类型 表示文字
UPnP_MediaServer1 urn:schemas-upnp-org:device:MediaServer:1
UPnP_MediaRenderer1 urn:schemas-upnp-org:device:MediaRenderer:1
UPnP_ContentDirectory1 urn:schemas-upnp-org:service:ContentDirectory:1
UPnP_RenderingControl1 urn:schemas-upnp-org:service:RenderingControl:1
UPnP_ConnectionManager1 urn:schemas-upnp-org:service:ConnectionManager:1
UPnP_AVTransport1 urn:schemas-upnp-org:service:AVTransport:1

主动通知方式

当设备添加到网络后,定期向(239.255.255.250:1900)发送SSDP通知消息宣告自己的设备和服务。

宣告消息分为 ssdp:alive(设备可用)ssdp:byebye(设备不可用)

ssdp:alive 消息

1
2
3
4
5
6
7
8
9
10
11
NOTIFY * HTTP/1.1           // 消息头
NT:                         // 在此消息中,NT头必须为服务的服务类型。(如:upnp:rootdevice)
HOST:                       // 设置为协议保留多播地址和端口,必须是:239.255.255.250:1900(IPv4)或FF0x::C(IPv6
NTS:                        // 表示通知消息的子类型,必须为ssdp:alive
LOCATION:                   // 包含根设备描述得URL地址  device 的webservice路径(如:http://127.0.0.1:2351/1.xml) 
CACHE-CONTROL:              // max-age指定通知消息存活时间,如果超过此时间间隔,控制点可以认为设备不存在 (如:max-age=1800)
SERVER:                     // 包含操作系统名,版本,产品名和产品版本信息( 如:Windows NT/5.0, UPnP/1.0)
USN:                        // 表示不同服务的统一服务名,它提供了一种标识出相同类型服务的能力。如:
                            // 根/启动设备 uuid:f7001351-cf4f-4edd-b3df-4b04792d0e8a::upnp:rootdevice
                            // 连接管理器  uuid:f7001351-cf4f-4edd-b3df-4b04792d0e8a::urn:schemas-upnp-org:service:ConnectionManager:1
                            // 内容管理器 uuid:f7001351-cf4f-4edd-b3df-4b04792d0e8a::urn:schemas-upnp-org:service:ContentDirectory:1

ssdp:byebye 消息

当设备即将从网络中退出时,设备需要对每一个未超期的 ssdp:alive 消息多播形式发送 ssdp:byebye 消息,其格式如下:

1
2
3
4
NOTIFY * HTTP/1.1       // 消息头
HOST:                   // 设置为协议保留多播地址和端口,必须是:239.255.255.250:1900(IPv4)或FF0x::C(IPv6
NTS:                    // 表示通知消息的子类型,必须为ssdp:byebye
USN:                    // 同上

搜索——响应方式

当控制点,如手机客户端,加入到网络中,可以通过多播搜索消息来寻找网络上感兴趣的设备。我写DLNA模块时候也用主动搜索方式来发现设备。主动搜索可以使用多播方式在整个网络上搜索设备和服务,也可以使用单播方式搜索特定主机上的设备和服务。

多播搜索消息

一般情况我们使用多播搜索消息来搜索所有设备即可。多播搜索消息如下:

1
2
3
4
5
6
7
8
9
10
M-SEARCH * HTTP/1.1             // 请求头 不可改变
MAN: "ssdp:discover"            // 设置协议查询的类型,必须是:ssdp:discover
MX: 5                           // 设置设备响应最长等待时间,设备响应在0和这个值之间随机选择响应延迟的值。这样可以为控制点响应平衡网络负载。
HOST: 239.255.255.250:1900      // 设置为协议保留多播地址和端口,必须是:239.255.255.250:1900(IPv4)或FF0x::C(IPv6
ST: upnp:rootdevice             // 设置服务查询的目标,它必须是下面的类型:
                                // ssdp:all  搜索所有设备和服务 
                                // upnp:rootdevice  仅搜索网络中的根设备 
                                // uuid:device-UUID  查询UUID标识的设备 
                                // urn:schemas-upnp-org:device:device-Type:version  查询device-Type字段指定的设备类型,设备类型和版本由UPNP组织定义。 
                                // urn:schemas-upnp-org:service:service-Type:version  查询service-Type字段指定的服务类型,服务类型和版本由UPNP组织定义。

如果需要实现投屏,则设备类型 STurn:schemas-upnp-org:service:AVTransport:1

多播搜索响应

多播搜索 M-SEARCH 响应与通知消息很类此,只是将NT字段作为ST字段。响应必须以一下格式发送:

1
2
3
4
5
6
7
8
9
10
HTTP/1.1 200 OK             // * 消息头
LOCATION:                   // * 包含根设备描述得URL地址  device 的webservice路径(如:http://127.0.0.1:2351/1.xml) 
CACHE-CONTROL:              // * max-age指定通知消息存活时间,如果超过此时间间隔,控制点可以认为设备不存在 (如:max-age=1800)
SERVER:                     // 包含操作系统名,版本,产品名和产品版本信息( 如:Windows NT/5.0, UPnP/1.0)
EXT:                        // 为了符合HTTP协议要求,并未使用。
BOOTID.UPNP.ORG:            // 可以不存在,初始值为时间戳,每当设备重启并加入到网络时+1,用于判断设备是否重启。也可以用于区分多宿主设备。
CONFIGID.UPNP.ORG:          // 可以不存在,由两部分组成的非负十六进制整数,由两部分组成,第一部分代表跟设备和其上的嵌入式设备,第二部分代表这些设备上的服务。
USN:                        // * 表示不同服务的统一服务名
ST:                         // * 服务的服务类型
DATE:                       // 响应生成时间

其中主要关注带有 * 的部分即可。这里还有一个大坑,有些设备返回来的字段名称可能包含有小写,如LOCATION和Location,需要做处理。
此外还需根据LOCATION保存设备的IP和端口地址。
响应例子如下:

1
2
3
4
5
6
7
8
HTTP/1.1 200 OK
Cache-control: max-age=1800
Usn: uuid:88024158-a0e8-2dd5-ffff-ffffc7831a22::urn:schemas-upnp-org:service:AVTransport:1
Location: http://192.168.1.243:46201/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/desc.xml
Server: Linux/3.10.33 UPnP/1.0 Teleal-Cling/1.0
Date: Tue, 01 Mar 2016 08:47:42 GMT+00:00
Ext: 
St: urn:schemas-upnp-org:service:AVTransport:1

描述

控制点发现设备之后仍然对设备知之甚少,仅能知道UPnP类型,UUID和设备描述URL。为了进一步了解设备和服务,需要获取并解析XML描述文件。
描述文件有两种类型:设备描述文档(DDD)服务描述文档(SDD)

设备描述文档

设备描述文档是对设备的基本信息描述,包括厂商制造商信息、设备信息、设备所包含服务基本信息等。

设备描述采用XML格式,可以通过HTTP GET请求获取。其链接为设备发现消息中的Location。如上述设备的描述文件获取请求为

1
2
GET http://192.168.1.243:46201/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/desc.xml HTTP/1.1
HOST: 192.168.1.243:46201

设备响应如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
HTTP/1.1 200 OK
Content-Length    : 3612
Content-type      : text/xml
Date              : Tue, 01 Mar 2016 10:00:36 GMT+00:00

<?xml version="1.0" encoding="UTF-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:qq="http://www.tencent.com">
    <specVersion>
        <major>1</major>
        <minor>0</minor>
    </specVersion>
    <device>
        <deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
        <UDN>uuid:88024158-a0e8-2dd5-ffff-ffffc7831a22</UDN>
        <friendlyName>客厅的小米盒子</friendlyName>
        <qq:X_QPlay_SoftwareCapability>QPlay:1</qq:X_QPlay_SoftwareCapability>
        <manufacturer>Xiaomi</manufacturer>
        <manufacturerURL>http://www.xiaomi.com/</manufacturerURL>
        <modelDescription>Xiaomi MediaRenderer</modelDescription>
        <modelName>Xiaomi MediaRenderer</modelName>
        <modelNumber>1</modelNumber>
        <modelURL>http://www.xiaomi.com/hezi</modelURL>
        <serialNumber>11262/180303452</serialNumber>
        <presentationURL>device_presentation_page.html</presentationURL>
        <UPC>123456789012</UPC>
        <dlna:X_DLNADOC xmlns:dlna="urn:schemas-dlna-org:device-1-0">DMR-1.50</dlna:X_DLNADOC>
        <dlna:X_DLNACAP xmlns:dlna="urn:schemas-dlna-org:device-1-0">,</dlna:X_DLNACAP>
        <iconList>
            <icon>
                <mimetype>image/png</mimetype>
                <width>128</width>
                <height>128</height>
                <depth>8</depth>
                <url>icon/icon128x128.png</url>
            </icon>
        </iconList>
        <serviceList>
            <service>
                <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
                <serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
                <controlURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/action</controlURL>
                <eventSubURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/event</eventSubURL>
                <SCPDURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/desc.xml</SCPDURL>
            </service>
            <service>
                <serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
                <serviceId>urn:upnp-org:serviceId:RenderingControl</serviceId>
                <controlURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/RenderingControl/action</controlURL>
                <eventSubURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/RenderingControl/event</eventSubURL>
                <SCPDURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/RenderingControl/desc.xml</SCPDURL>
            </service>
            <service>
                <serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
                <serviceId>urn:upnp-org:serviceId:ConnectionManager</serviceId>
                <controlURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/ConnectionManager/action</controlURL>
                <eventSubURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/ConnectionManager/event</eventSubURL>
                <SCPDURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/ConnectionManager/desc.xml</SCPDURL>
            </service>
            <service>
                <serviceType>urn:mi-com:service:RController:1</serviceType>
                <serviceId>urn:upnp-org:serviceId:RController</serviceId>
                <controlURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/RController/action</controlURL>
                <eventSubURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/RController/event</eventSubURL>
                <SCPDURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/RController/desc.xml</SCPDURL>
            </service>
        </serviceList>
        <av:X_RController_DeviceInfo xmlns:av="urn:mi-com:av">
            <av:X_RController_Version>1.0</av:X_RController_Version>
            <av:X_RController_ServiceList>
                <av:X_RController_Service>
                    <av:X_RController_ServiceType>controller</av:X_RController_ServiceType>
                    <av:X_RController_ActionList_URL>http://192.168.1.243:6095/</av:X_RController_ActionList_URL>
                </av:X_RController_Service>
                <av:X_RController_Service>
                    <av:X_RController_ServiceType>data</av:X_RController_ServiceType>
                    <av:X_RController_ActionList_URL>http://api.tv.duokanbox.com/bolt/3party/</av:X_RController_ActionList_URL>
                </av:X_RController_Service>
            </av:X_RController_ServiceList>
        </av:X_RController_DeviceInfo>
    </device>
</root>

其中响应消息体为XML格式的设备描述内容。信息结构比较明确,就不一一介绍了。解析该XML,保存设备的一些基本信息如 deviceTypefriendlyNameiconList 等。之后我们关注该设备提供的服务列表,投屏最关注的服务为 urn:schemas-upnp-org:service:AVTransport:1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?xml version="1.0" encoding="UTF-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0" xmlns:qq="http://www.tencent.com">
    <device>
        <serviceList>
            <service>
                <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
                <serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
                <controlURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/action</controlURL>
                <eventSubURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/event</eventSubURL>
                <SCPDURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/desc.xml</SCPDURL>
            </service>
        </serviceList>
    </device>
</root>

如只需要实现简单的投屏,则保存urn:schemas-upnp-org:service:AVTransport:1服务的上述信息即可。如需要进一步了解该服务,则需要获取并解析服务描述文档。

坑点1:有些设备 SCPDURLcontrolURLeventSubURL 开头包含 / ,有些设备不包含,拼接URL时需要注意。

服务描述文档

为了实现简单的投屏和控制(播放、暂停、停止、快进)操作并不需要解析服务描述文件。所有动作均为UPnP规范动作,具体动作请求参见基于DLNA实现iOS,Android投屏:SOAP控制设备

服务描述文档是对服务功能的基本说明,包括服务上的动作及参数,还有状态变量和其数据类型、取值范围等。

和设备描述文档一致,服务描述文档也是采用XML语法,并遵守标准UPnP服务schema文件格式要求。获取上述服务SDD语法如下:

1
2
GET http://192.168.1.243:46201/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/desc.xml
HOST: 192.168.1.243:46201

设备响应如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
HTTP/1.1 200 OK
Content-Length    : 3612
Content-type      : text/xml
Date              : Tue, 01 Mar 2016 10:00:36 GMT+00:00
<!-- 省略了部分动作和状态变量 -->
<?xml version="1.0" encoding="UTF-8"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
    <specVersion>
        <major>1</major>
        <minor>0</minor>
    </specVersion>
    <actionList>
        <action>
            <name>Pause</name>
            <argumentList>
                <argument>
                    <name>InstanceID</name>
                    <direction>in</direction>
                    <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
                </argument>
            </argumentList>
        </action>
        <action>
            <name>Play</name>
            <argumentList>
                <argument>
                    <name>InstanceID</name>
                    <direction>in</direction>
                    <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
                </argument>
                <argument>
                    <name>Speed</name>
                    <direction>in</direction>
                    <relatedStateVariable>TransportPlaySpeed</relatedStateVariable>
                </argument>
            </argumentList>
        </action>
        <action>
            <name>Previous</name>
            <argumentList>
                <argument>
                    <name>InstanceID</name>
                    <direction>in</direction>
                    <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
                </argument>
            </argumentList>
        </action>
        <action>
            <name>SetAVTransportURI</name>
            <argumentList>
                <argument>
                    <name>InstanceID</name>
                    <direction>in</direction>
                    <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
                </argument>
                <argument>
                    <name>CurrentURI</name>
                    <direction>in</direction>
                    <relatedStateVariable>AVTransportURI</relatedStateVariable>
                </argument>
                <argument>
                    <name>CurrentURIMetaData</name>
                    <direction>in</direction>
                    <relatedStateVariable>AVTransportURIMetaData</relatedStateVariable>
                </argument>
            </argumentList>
        </action>
        ...
    </actionList>
    <serviceStateTable>
        <stateVariable sendEvents="no">
            <name>CurrentTrackURI</name>
            <dataType>string</dataType>
        </stateVariable>
        <stateVariable sendEvents="no">
            <name>CurrentMediaDuration</name>
            <dataType>string</dataType>
        </stateVariable>
        <stateVariable sendEvents="no">
            <name>AbsoluteCounterPosition</name>
            <dataType>i4</dataType>
        </stateVariable>
        <stateVariable sendEvents="no">
            <name>RelativeCounterPosition</name>
            <dataType>i4</dataType>
        </stateVariable>
        <stateVariable sendEvents="no">
            <name>A_ARG_TYPE_InstanceID</name>
            <dataType>ui4</dataType>
        </stateVariable>
        ...
    </serviceStateTable>
</scpd>
  • actionList 目前服务上所包含的动作列表。
  • actionList 目前服务上所包含的状态变量。

以Pause动作为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?xml version="1.0" encoding="UTF-8"?>
<scpd xmlns="urn:schemas-upnp-org:service-1-0">
    <actionList>
        <action>
            <!-- 动作名称 -->
            <name>Pause</name>
            <!-- 参数列表 -->
            <argumentList>
                <argument>
                    <!-- 参数名称 -->
                    <name>InstanceID</name>
                    <!-- 输出或输出-->
                    <direction>in</direction>
                    <!-- 声明参数有关的状态变量 -->
                    <relatedStateVariable>A_ARG_TYPE_InstanceID</relatedStateVariable>
                </argument>
            </argumentList>
        </action>
        ...
    </actionList>
    <serviceStateTable>
    <!-- 状态变量 -->
        <stateVariable>
            <!-- 是否发送事件消息,如果为yes则该状态变量发生变化时生成事件消息。 -->
            <stateVariable sendEvents="no">
            <!-- 状态变量名称 -->
            <name>A_ARG_TYPE_InstanceID</name>
            <!-- 状态数据类型 -->
            <dataType>ui4</dataType>
        </stateVariable>
        ...
    </serviceStateTable>
</scpd>

为了实现简单的投屏和控制(播放、暂停、停止、快进)操作并不需要解析服务描述文件。所有动作均为UPnP规范动作,具体动作请求参见基于DLNA实现iOS,Android投屏:SOAP控制设备

基于DLNA实现iOS,Android投屏:SOAP控制设备

UPdP网络中,控制点和服务之间使用简单对象访问协议(Simple Object Access Protocol,SOAP)

根据基于DLNA实现iOS,Android投屏:SSDP发现设备收到设备描述文档(DDD)和服务描述文档(SDD),通过解析DDD获取 <controlURL> 控制点可以知道该设备上某个服务的控制点地址。再通过解析 DDD 中 <action> 中的 <name><argumentList> 获取该服务动作的动作名称,参数要求。控制点向 controlURL 发出服务调用信息,表明动作名称和相应参数来调用相应的服务。

SOAP简单对象访问协议

控制点和服务之间使用简单对象访问协议(Simple Object Access Protocol,SOAP)的格式。SOAP 的底层协议一般也是HTTP。在 UPnP 中,把 SOAP 控制/响应信息分成 3 种: UPnP Action Request、UPnP Action Response-Success 和 UPnP Action Response-Error。SOAP 和 SSDP 不一样,所使用的 HTTP 消息是有 Body 内容,Body 部分可以写想要调用的动作,叫做 Action invocation,可能还要传递参数,如想播放一个网络上的视频,就要把视频的URL传过去;服务收到后要 response ,回答能不能执行调用,如果出错则返回一个错误代码。

动作调用(UPnP Action Request)

使用POST方法发送控制消息的格式如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
POST <control URL> HTTP/1.0
Host: hostname:portNumber
Content-Lenght: byte in body
Content-Type: text/xml; charset="utf-8"
SOAPACTION: "urn:schemas-upnp-org:service:serviceType:v#actionName"

<!--必有字段-->
<?xml version="1.0" encoding="utf-8"?>
<!--SOAP必有字段-->
<s:Envelope s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/" xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
    <s:Body>
        <!--Body内部分根据不同动作不同-->
        <!--动作名称-->
        <u:actionName xmlns:u="urn:schemas-upnp-org:service:serviceType:v">
            <!--输入参数名称和值-->
            <argumentName>in arg values</argumentName>
             <!--若有多个参数则需要提供-->
        </u:actionName>
    </s:Body>
</s:Envelope>
  • control URL: 基于DLNA实现iOS,Android投屏:SSDP发现设备 中提到的 设备描述文件urn:upnp-org:serviceId:AVTransport 服务的 <controlURL>
  • HOST: 上述服务器的根地址和端口号。
  • actionName: 需要调用动作的名称,对应相应服务的 服务描述文件<SCPDURL> 中的 <action><name> 字段。
  • argumentName: 输入参数名称,对应相应服务的 服务描述文件<SCPDURL> 中的 <action> <argument> <name> 字段。
  • in arg values: 输入参数值,具体的可以通过 ,可以通过 服务描述文件<SCPDURL> <action> <relatedStateVariable> 提到的状态变量来得知值得类型。
  • urn:schemas-upnp-org:service:serviceType:v:对应该 设备描述文件 相应服务的 <serviceType 字段。

动作响应(UPnP Action Response-Succes)

收到控制点发来的动作调用请求后,设备上的服务必须执行动作调用。,并在 30s 内响应。如果需要超过 30s 才能完成执行的动作,则可以先返回一个应答消息,等动作执行完成再利用事件机制返回动作响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
HTTP/1.0 200 OK                             // 响应成功响应头
Content-Type: text/xml; charset="utf-8"
Date: Tue, 01 Mar 2016 10:00:36 GMT+00:00
Content-Length: byte in body

<?xml version="1.0" encoding="utf-8" standalone="no"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
    <s:Body>
        <!--之前部分为固定字段-->
        <!--之前部分为固定字段-->
        <u:actionNameResponse xmlns:u="urn:schemas-upnp-org:service:serviceType:v">
            <!--输出变量名称和值-->
            <arugumentName>out arg value</arugumentName>
            <!--若有多个输出变量则继续写,没有可以不存在输出变量-->
        </u:actionNameResponse>
    </s:Body>
</s:Envelope>
  • actionNameResponse: 响应的动作名称
  • arugumentName: 当动作带有输出变量时必选,输出变量名称
  • out arg values: 输出变量名称值

动作错误响应(UPnP Action Response-Succes)

如果处理动作过程中出现错误,则返回一个一下格式的错误响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
HTTP/1.0 500 Internal Server Error          // 响应成功响应头
Content-Type: text/xml; charset="utf-8"
Date: Tue, 01 Mar 2016 10:00:36 GMT+00:00
Content-Length: byte in body

<?xml version="1.0" encoding="utf-8" standalone="no"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
    <s:Body>
        <u:Fault>
            <!--之前部分为固定字段-->
            <faultcode>s:Client</faultcode>
            <faultstring>UPnPError</faultstring>
            <detail>
                <UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
                    <errorCode>402</errorCode>
                    <errorDescription>Invalid or Missing Args</errorDescription>
                </UPnPError>
            </detail>
        </u:actionNameResponse>
    </s:Body>
</s:Envelope>
  • faultcode: SOAP规定使用元素,调用动作遇到的错误类型,一般为s:Client。
  • faultstring: SOAP规定使用元素,值必须为 UPnPError。
  • detail: SOAP规定使用元素,错误的详细描述信息。
  • UPnPError: UPnP规定元素。
  • errorCode: UPnP规定元素,整数。详见下表。
  • errorDescription: UPnP规定元素,简短错误描述。
errorCode errorDescription 描述
401 Invalid Action 这个服务中没有该名称的动作
402 Invalid Args 参数数据错误 not enough in args, too many in arg, no in arg by that name, one or more in args 之一
403 Out of Sycs 不同步
501 Action Failed 可能在当前服务状态下返回,以避免调用此动作
600 ~ 699 TBD 一般动作错误,由 UPnP 论坛技术委员会定义
700 ~ 799 TBD 面向标准动作的特定错误,由 UPnP 论坛工作委员会定义
800 ~ 899 TBD 面向非标准动作的特定错误,由 UPnP 厂商会定义

投屏基本命令及其响应

所有命令以发向 基于DLNA实现iOS,Android投屏:SSDP发现设备 发现的设备。除了网址以外,其余部分均不需要修改。

所有动作请求使用 POST 请求发送,并且请求Header均如下所示,其中:

  • control URL: 基于DLNA实现iOS,Android投屏:SSDP发现设备 中提到的 设备描述文件urn:upnp-org:serviceId:AVTransport 服务的 <controlURL>
  • HOST: 上述服务器的根地址和端口号。
  • urn:schemas-upnp-org:service:serviceType:v:对应相应设备的 设备描述文件 相应服务的 <serviceType 字段。
  • actionName: 需要调用动作的名称,对应相应服务的 服务描述文件<SCPDURL> 中的 <action><name> 字段。
1
2
3
4
5
POST /dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/action HTTP/1.0
Host: 192.168.1.243:46201
Content-Length: byte in body
Content-Type: text/xml; charset="utf-8"
SOAPACTION: "urn:schemas-upnp-org:service:serviceType:v#actionName"

下面请求和响应均忽略Header,参数列表中列出Header的SOAPACTION值

设置播放资源URI

动作请求

设置当前播放视频动作统一名称为 SetAVTransportURI 。 需要传递参数有

  • InstanceID:设置当前播放时期时为 0 即可。
  • CurrentURI: 播放资源URI
  • CurrentURIMetaData: 媒体meta数据,可以为空
  • Header_SOAPACTION: “urn:upnp-org:serviceId:AVTransport#SetAVTransportURI”

有些设备传递播放URI后就能直接播放,有些设备设置URI后需要发送播放命令,可以在接收到 SetAVTransportURIResponse 响应后调用播放动作来解决。

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<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-upnp-org:service:AVTransport:1">
            <InstanceID>0</InstanceID>
            <CurrentURI>http://125.39.35.130/mp4files/4100000003406F25/clips.vorwaerts-gmbh.de/big_buck_bunny.mp4</CurrentURI>
            <CurrentURIMetaData />
        </u:SetAVTransportURI>
    </s:Body>
</s:Envelope>

响应

1
2
3
4
5
6
<?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:SetAVTransportURIResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
    </s:Body>
</s:Envelope>

播放

动作请求

播放视频动作统一名称为 Play 。 需要传递参数有

  • InstanceID:设置当前播放时期时为 0 即可。
  • Speed:播放速度,默认传 1 。
  • Header_SOAPACTION: “urn:upnp-org:serviceId:AVTransport#Pause”
1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<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>

响应

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<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" />
    </s:Body>
</s:Envelope>

暂停

动作请求

暂停视频动作统一名称为 Pause 。 需要传递参数有

  • InstanceID:设置当前播放时期时为 0 即可。
  • Header_SOAPACTION: “urn:upnp-org:serviceId:AVTransport#Pause”
1
2
3
4
5
6
7
8
<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>

响应

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<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" />
    </s:Body>
</s:Envelope>

获取播放进度

动作请求

获取播放进度动作统一名称为 GetPositionInfo 。 需要传递参数有

  • InstanceID:设置当前播放时期时为 0 即可。
  • MediaDuration: 可以为空。
  • Header_SOAPACTION: “urn:upnp-org:serviceId:AVTransport#MediaDuration”
1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
    <s:Body>
        <u:GetPositionInfo xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
            <InstanceID>0</InstanceID>
            <MediaDuration />
        </u:GetPositionInfo>
    </s:Body>
</s:Envelope>

响应

获取播放进度响应中包含了比较多的信息,其中我们主要关心的有一下三个:

  • TrackDuration: 目前播放视频时长
  • RelTime: 真实播放时长
  • AbsTime: 相对播放时长

注:目前为止还没发现 RelTime AbsTime 和不一样的情况,选用 RelTime 就ok。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
    <s:Body>
        <u:GetPositionInfoResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
            <Track>0</Track>
            <TrackDuration>00:04:32</TrackDuration>
            <TrackMetaData />
            <TrackURI />
            <RelTime>00:00:07</RelTime>
            <AbsTime>00:00:07</AbsTime>
            <RelCount>2147483647</RelCount>
            <AbsCount>2147483647</AbsCount>
        </u:GetPositionInfoResponse>
    </s:Body>
</s:Envelope>

跳转至特定进度或视频

动作请求

跳转到特定的进度或者特定的视频(多个视频播放情况),需要调用 Seek 动作,传递参数有:

  • InstanceID: 一般为 0 。
  • Unit:REL_TIME(跳转到某个进度)或 TRACK_NR(跳转到某个视频)。
  • Target: 目标值,可以是 00:02:21 格式的进度或者整数的 TRACK_NR。
  • Header_SOAPACTION: “urn:upnp-org:serviceId:AVTransport#Seek”
1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
    <s:Body>
        <u:Seek xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
            <InstanceID>0</InstanceID>
            <Unit>REL_TIME</Unit>
            <Target>00:02:21</Target>
        </u:Seek>
    </s:Body>
</s:Envelope>

响应

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
    <s:Body>
        <u:SeekResponse xmlns:u="urn:schemas-upnp-org:service:AVTransport:1" />
    </s:Body>
</s:Envelope>

iOS实现

需要用到库

  1. AEXML - 轻量 XML 库,用于构造和解析XML

构造动作XML

首先利用 AEXML 构造动作 XML 部分。由于所有动作结构相似,写了个构造方法

1
2
3
4
5
6
7
8
9
10
11
12
13
private func prepareXMLFileWithCommand(command:AEXMLElement) -> String {
    // 创建 AEXMLDocument 实例
    let soapRequest = AEXMLDocument()
    // 设置XML外层
    let attributes = [
        "xmlns:s" : "http://schemas.xmlsoap.org/soap/envelope/","s:encodingStyle" : "http://schemas.xmlsoap.org/soap/encoding/"]
    let envelope = soapRequest.addChild(name: "s:Envelope", attributes: attributes)
    let body = envelope.addChild(name: "s:Body")

    // 把 command 添加到 XML 中间
    body.addChild(command)
    return soapRequest.xmlString
}

根据不同动作构造 XML ,比如 传递URI播放动作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
投屏

- parameter URI: 视频URL
*/
func SetAVTransportURI(URI:String) {
    let command = AEXMLElement("u:SetAVTransportURI",attributes: ["xmlns:u" : "urn:schemas-upnp-org:service:AVTransport:1"])
    command.addChild(name: "InstanceID", value: "0")
    command.addChild(name: "CurrentURI", value: URI)
    command.addChild(name: "CurrentURIMetaData")
    let xml = self.prepareXMLFileWithCommand(command)

    self.sendRequestWithData(xml,action: "SetAVTransportURI")
}

/**
播放视频
*/
func Play() {
    let command = AEXMLElement("u:Play",attributes: ["xmlns:u" : "urn:schemas-upnp-org:service:AVTransport:1"])
    command.addChild(name: "InstanceID", value: "0")
    command.addChild(name: "Speed", value: "1")
    let xml = self.prepareXMLFileWithCommand(command)

    self.sendRequestWithData(xml,action: "Play")
}

发送动作请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private func sendRequestWithData(xml:String, action:String) {
    let request = NSMutableURLRequest(URL: NSURL(string: controlURL)!)
    // 使用 POST 请求发送动作
    request.HTTPMethod = "POST"
    request.addValue("text/xml", forHTTPHeaderField: "Content-Type")
    // 添加SOAPAction动作名称
    request.addValue("\(service.serviceId)#\(action)", forHTTPHeaderField: "SOAPAction")
    request.HTTPBody = xml.dataUsingEncoding(NSUTF8StringEncoding)

    let task = NSURLSession.sharedSession().dataTaskWithRequest(request) { data, response, error in
        guard error == nil && data != nil else {
            print("error=\(error)")
            return
        }

        // 检查是否正确响应
        if let httpStatus = response as? NSHTTPURLResponse where httpStatus.statusCode != 200 {            
            print("statusCode should be 200, but is \(httpStatus.statusCode)")
            print("response = \(NSString(data: data!, encoding: NSUTF8StringEncoding)))")
        }

        // 解析响应
        self.parseRequestResponseData(data!)

    }
    task.resume()
}

解析响应

解析请求响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private func parseRequestResponseData(data:NSData) {
    do {
        let xmlDoc = try AEXMLDocument(xmlData: data)

        if let response = xmlDoc.root["s:Body"].first?.children.first {
            switch response.name {
            case "u:SetAVTransportURIResponse":
                print("设置URI成功")
                //获取播放长度
            case "u:GetPositionInfoResponse":
                // 进度需要进一步解析。如realTime = response["RelTime"].value
                print("已获取播放进度")
            case "u:PlayResponse":
                print("已播放")
            case "u:PauseResponse":
                print("已暂停")
            case "u:StopResponse":
                print("已停止")
            default :
                print("未定义响应  - \(xmlDoc.xmlString)")
            }
        } else {
            print("返回不符合规范 - XML:\(xmlDoc.xmlString)")
        }
    }
    catch {
        return
    }
}

 

基于DLNA实现iOS,Android投屏:订阅事件通知

服务运行时,可能改变有些状态信息变量的值,这是需要及时地更新给控制点。因此控制点可以通过订阅操作,让服务通过发送事件消息来发布更新。

事件消息包括一个或多个状态变量以及他们的当前数值。这些消息也是采用 XML 格式,遵循通用事件通知体系 GENA 规定。

服务运行过程中,该服务的 服务描述文件SDD状态变量 <stateVariable> 发生了变化并且该变量的 <sendEvents> 属性为 yes 时,将会产生一个事件(Event)消息。如该状态变量的 <multicast> 属性为 yes ,则该服务把这个事件消息向整个网进行多播(Multicast)。如果为 no 或者不存在这个属性,则通过单播(Unicast)给订阅者发送消息。

单播事件消息的订阅及推送是遵循通用事件通知结构(General Event Notification Architecture,GENA)协议。协议中,控制点通常是个订阅者(Subscriber),它向服务提供者(通常是某个设备上的服务)发送订阅消息(SUBSCRIBE),建立订阅关系,然后可以继续更新订阅消息(Renewal),或者最后退订消息(Cancel)。另外,UPnP对GENA进行了一些扩展,如在事件消息中增加了一个key,来表示事件的顺序。

事件订阅和通知过程如下。
UPnP事件订阅流程

订阅

事件订阅说白了就是给某个服务的 订阅 URL<eventSubURL> 发送一条包含 回调 URL<Callback URL>订阅期限 <duration> 的订阅请求。

设备描述文档 DDD 中描述 AVTransport 服务的片段例,默认其 HOST: 192.168.1.243:46201

1
2
3
4
5
6
7
<service>
    <serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
    <serviceId>urn:upnp-org:serviceId:AVTransport</serviceId>
    <controlURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/action</controlURL>
    <eventSubURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/event</eventSubURL>
    <SCPDURL>/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/desc.xml</SCPDURL>
</service>

订阅请求

上述服务的订阅请求如下,其中注意点就是 回调URL CALLBACK 必须带有 <> 否则回调不成功。为了接受回调还需要手机上运行一个 HTTP Server,具体实现请看下一部分。

1
2
3
4
5
6
SUBSCRIBE /dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/event HTTP/1.1
HOST: 192.168.1.243:46201
USER-AGENT: iOS/9.2.1 UPnP/1.1 SCDLNA/1.0
CALLBACK: <http://192.168.1.100:5000/dlna/callback>
NT: upnp:event
TIMEOUT: Second-3600    // 订阅期限

订阅响应

成功响应

如果订阅成功,则服务 30s 内返回如下的响应。其中 SID 为订阅标识符,必须以uuid开头。订阅成功后需要保存,后续续订和取消订阅均需要提供该标识符。此外还需要保存订阅期限 TIMEOUT: Second-3600

1
2
3
4
5
6
HTTP/1.1 200 OK
Server: Linux/3.10.33 UPnP/1.0 IQIYIDLNA/iqiyidlna/NewDLNA/1.0
SID: uuid:f392-a153-571c-e10b
Content-Type: text/html; charset="utf-8"
TIMEOUT: Second-3600
Date: Thu, 03 Mar 2016 19:01:42 GMT

订阅失败

若订阅失败,发布者必须返回一个订阅失败响应。格式如下:

1
2
3
4
5
HTTP/1.1 error code errordescrioption
Server: OS/Version UPnP/1.1 product/version
SID: uuid:subscibe-UUID
Content-Length: 0
Date: Thu, 03 Mar 2016 19:01:42 GMT

iOS实现

用Swift实现的订阅请求如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func subscribe() {
    let url =  "192.168.1.243:46201" +  "/dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/event"
    let request = NSMutableURLRequest(URL: NSURL(string: url)!)
    request.HTTPMethod = "SUBSCRIBE"
    request.addValue("iOS/9.2.1 UPnP/1.1 SCDLNA/1.0", forHTTPHeaderField: "User-Agent")
    // 必须加上<>,不要问我为什么,不然没法订阅成功
    request.addValue("<http://192.168.1.100:5000/dlna/callback>", forHTTPHeaderField: "CALLBACK")
    request.addValue("upnp:event", forHTTPHeaderField: "NT")
    request.addValue("Second-3600", forHTTPHeaderField: "TIMEOUT")

    let task = NSURLSession.sharedSession().dataTaskWithRequest(request) { data, response, error in
        guard error == nil && data != nil else {
            print("error=\(error)")
            return
        }
        // 检查订阅是否失败
        if let httpStatus = response as? NSHTTPURLResponse where httpStatus.statusCode != 200 {
            print("Subscribe Filed With Error Code:\(httpStatus.statusCode)")
            print("response = \(response)")
            return
        }
        // 若订阅成功,则保存SID
        if let response = response as? NSHTTPURLResponse {
            self.lastSubscribeSID = response.allHeaderFields["SID"] as? String ?? ""
        }    
    }
    task.resume()
}

续订

如果需要续订某个服务,则必须在订阅期限过期前,将续订消息发往服务器进行续订。

续订请求

1
2
3
4
SUBSCRIBE /dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/event HTTP/1.1
HOST: 192.168.1.243:46201
SID: uuid:subscibe-UUID
TIMEOUT: Second-3600    // 订阅期限

取消订阅

不需要在关注特定服务的事件时,需要向服务器发送取消订阅消息。

取消订阅请求

1
2
3
UNSUBSCRIBE /dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/event HTTP/1.1
HOST: 192.168.1.243:46201
SID: uuid:subscibe-UUID

单播事件消息

当服务器上的状态变量发生变数时,通过单播给订阅者发送通知。单播通过 HTTP 协议发送。需要在本地运行一个 HTTP Server 来接受请求。接收事件消息成功后,只需要简单返回一个 HTTP/1.1 200 OK 作为回应即刻。

坑:有些设备返回的xml中 < > 被转义,导致解析时候出错。所以需要先反转义,然后再解析。
单播消息格式如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
NOTIFY /dlna/callback HTTP/1.0
Host: 192.168.1.100:5000
Content-Length: 325
Content-Type: text/xml; charset="utf-8"
User-Agent: Neptune/1.1.3, 6
SID: uuid:ac6dce5a-6047-7862-fd41-e5596960f57a  // 订阅标识符
NTS: upnp:propchange                            // GENA规定,必须是 upnp:propchange 
NT: upnp:event                                  // GENA规定,必须是 upnp:event 
SEQ: 4                                          // 事件编号,初始值为0。

<?xml version="1.0" encoding="UTF-8"?>
<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
    <e:property>
        <!-- 消息内容 -->
        <variableName>new values</variableName>
    </e:property>
</e:propertyset>

播放消息

忽略头部的停止播放消息

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="UTF-8"?>
<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
    <e:property>
        <LastChange>
            <Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/">
                <InstanceID val="0">
                    <TransportState val="PLAYING"/>
                </InstanceID>
            </Event>
        </LastChange>
    </e:property>
</e:propertyset>

停止播放消息

忽略头部的停止播放消息

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="UTF-8"?>
<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
    <e:property>
        <LastChange>
            <Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/">
                <InstanceID val="0">
                    <TransportState val="STOPPED"/>
                </InstanceID>
            </Event>
        </LastChange>
    </e:property>
</e:propertyset>

iOS实现

iOS实现我用到了一下开源库

  1. GCDWebServer - 轻量 iOS/OSX GCD的服务器框架
  2. AEXML - 轻量 XML 解析库

创建 HTTP Server

首先需要利用 GCDWebServer 创建一个 HTTP server 接受事件消息回调。具体代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private func startWebServer() {
    let webServer = GCDWebServer()  

    // 为回调消息添加处理回调事件
    webServer.addHandlerForMethod("NOTIFY", pathRegex: "/dlna/callback", requestClass: GCDWebServerDataRequest.self) {
        (request) -> GCDWebServerResponse! in
        
        // 转换 request 类型为 GCDWebServerDataRequest,然后读取请求 body
        if let re = request as? GCDWebServerDataRequest {
            if re.hasBody() {
                // 如果请求有 body 部分,则开始解析。
                self.parseNotifMassage(re.data)
            }
        }
        return GCDWebServerDataResponse(HTML:"<html><body><p>Hello World</p></body></html>")
    }
    
    webServer.startWithPort(8899, bonjourName: nil)
}

创建 webServer 后,可以通过 webServer.serverURL 获取 serverURL 。 这时把 "<\(webServer.serverURL)dlna/callback>" 作为回调 URL 。按照前文给出代码进行订阅就可以收到事件消息了。

解析消息

接收到通知消息后,利用 GCDWebServer 解析 XML,获取具体的动作。目前只对播放状态做了处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
private func parseNotifMassage(data:NSData) {
    do {
        // 这里有个坑,有些设备返回的xml中<>被转义,导致解析时候出错。所以需要先反转义,然后再解析。
        // reTransfer()是我写的简单的 String 扩展,具体看最后
        let string = (NSString(data: data, encoding: NSUTF8StringEncoding) as! String).reTransfer()
        let xmlData = string.dataUsingEncoding(NSUTF8StringEncoding)!
        // 把 XML 转换成
        let xml = try AEXMLDocument(xmlData: xmlData)
        
        let status = xml.root["e:property"]["LastChange"]["Event"]["InstanceID"]["TransportState"].attributes
        if !status.isEmpty {
            switch status.first!.1.uppercaseString {
            case "TRANSITIONING":
                print("正在传输")
            case "PLAYING":
                print("播放")
            case "PAUSED_PLAYBACK":
                print("暂停播放")
            case "STOPPED":
                print("停止播放")            
            default :
                print("未定义动作 - \(status.first!.1)")
            }
        } else {
            print("未定义XML - \(xml.xmlString)")
        }
    }
        
    catch {
        print(error)
        return
    }
}

extension String {
    func reTransfer() -> String {
        let re1 = self.stringByReplacingOccurrencesOfString("&gt;", withString: ">")
        let re2 = re1.stringByReplacingOccurrencesOfString("&lt;", withString: "<")
        return re2
    }
}

iOS 实现基于 DLNA 的本机图片,视频投屏

DLNA 投网络上的媒体文件已经在前几篇实现过了。现在记录一下把本地图片和视频文件投到 DLNA 设备。

基础知识

DLNA

关于 DLNA 的基础知识请看一下四篇文章:

GCDWebServer

GCDWebServer 是一个现代化的轻量级的基 于HTTP 1.1 的 GCD server,它主要用于嵌入 OS X & iOS apps。GCDWebServer 在我们的实现中扮演 HTTP Server 的作用。使用前确保你已阅读一下两篇:

实现思路

目前我有两种思路

  1. 实现完整的 DLNA Media Server,提供媒体目录和存储。
  2. 跑一个 HTTP Server,产生文件 URL,然后把 URL 投到 DLNA 设备,相当于投网络视频。

其中方案1是 DLNA 标准的做法,方案2相当于是简化版的 DMS( DLNA Media Server)。考虑到我的 DLNA 实现全都是自己写的,我选择了方案2。

注:

  • 相册 - iOS 系统相册
  • DLNAMediaHelper - 我写的一个 Helper 单例,用于保存随机产生的 url 和对应的 PHAsset 资源、获取某个具体 URL 对应的媒体文件数据或者路径。
  • DLNAManager - DLNA 投屏部分是实现类,用于处理 SSDP发现设备,发送 SOAP 命令以及接受订阅。
  • DLNA 设备 - 目标 DLNA 设备
  • Webserver - 本机(iphone)HTTP Server

具体实现过程

相册获取 PHAsset

从相册获取媒体需要熟悉 PhotoKit,具体不阐述了。此处我选择了能够获取图片和视频的第三方开源框架 CTAssetsPickerController

产生 URL,保存字典

其实这一步没什么难度,首先根据时间戳产生一个 URL,并把这个 URL 作为 key, asset 作为 value 存到一个字典内,用于后期处理请求即可。具体实现看下面。

投 URL 到 DLNA 设备

详见 基于 DLNA 实现 iOS,Android 投屏:SOAP 控制设备

获取 PHAsset 对应的资源文件

PHAsset 代表照片库中的一个资源,不是真正的原始数据。所以我们还需要使用 PHAsset 获取对应资源。具体的获取方法我均放在 DLNAMediaManager 类中。
DLNAMediaManager 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
class DLNAMediaManager {
    static let shared = DLNAMediaManager()
    
    /// url:asset关系字典
    var mediaList: [String:PHAsset] = [:]
    
    /// 处理媒体资源请求的 webserver 的 URL,我这是直接用的是我的 DLNAManager 里面的 Server 
    var serverURL: String {
        get {
            return DLNAManager.sharedManager.webServer.serverURL.URLString
        }
    }
    
    /**
     产生 url 方法
     
     - parameter forAsset: 目标 asset
     
     - returns: 产生的响应 url
     */
    func generateURL(forAsset :PHAsset) -> String {
        var url = ""
        if forAsset.mediaType ==  .Video {
            url = serverURL + "videos/" + UIUTil.gettimestampForNow().md5() + ".mov"
        } else {
            url = serverURL + "images/" + UIUTil.gettimestampForNow().md5() + ".jpg"
        }
        
        mediaList[url] = forAsset
        return url
    }
    
    /**
     获取 Image 文件的 NSData,只需要在响应时候去获取再返回即可
     
     - parameter url:      资源对应的请求url
     - parameter callBack: 资源获取完成回调
     */
    func fetchImageData(url:String, callBack:((data:NSData?)->Void)) {
        if let asset = imageList[url] {
            let options = PHImageRequestOptions()
            options.synchronous = false
            options.deliveryMode = .HighQualityFormat
            options.networkAccessAllowed = true
            PHImageManager.defaultManager().requestImageForAsset(asset,
                                                                 targetSize: PHImageManagerMaximumSize,
                                                                 contentMode: .Default,
                                                                 options: options)
            { (result, info) -> Void in
                if let image = result, data = UIImageJPEGRepresentation(image, 1.0) {
                    callBack(data:data)
                } else {
                    callBack(data: nil)
                }
            }
        } else {
            callBack(data: nil)
        }
    }
    
    /**
     获取视频文件文件路径
     
     - parameter url:      资源对应请求url
     - parameter callBack: 资源获取完成回调
     */
    func fetchVideoFile(url:String, callBack:((fileURL:String?)->Void)) {
        
        if let asset = imageList[url] {
            let imageManager = PHImageManager.defaultManager()
            let videoRequestOptions = PHVideoRequestOptions()
            
            videoRequestOptions.deliveryMode = .Automatic
            videoRequestOptions.version = .Current
            videoRequestOptions.networkAccessAllowed = true
            
            imageManager.requestAVAssetForVideo(asset,
                                                options: videoRequestOptions,
                                                resultHandler:
                { (avAsset, avAudioMix, info) -> Void in
                
                if let nextURLAsset = avAsset as? AVURLAsset,
                    filepath = nextURLAsset.URL.path {
                    callBack(fileURL: filepath)
                } else {
                    callBack(fileURL: nil)
                    }
            })
        } else {
            return callBack(fileURL: nil)
        }
    }
}

Webserver 接受请求

使用 GCD,具体处理方法如下,请注意其中图片和视频文件处理方法略有不同。关于 GCDWebServer 具体细节请见 GCDWebServer Readme

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
func startWebServer() {
   /**
    *  若不符合其中两个请求方式,则返回404
    */
   let MediaNotFoundResponse = GCDWebServerResponse(statusCode:404)
   
   /**
    *  图片文件响应,图片文件返回 Data Response
    */
   webServer.addHandlerForMethod("GET", pathRegex: "/images/", requestClass: GCDWebServerRequest.self) { (request, completionBlock) in
       let url = request.URL.URLString
       DLNAImageHelper.fetchImageData(url, callBack: { (data) in
           if let data = data {
               let response = GCDWebServerDataResponse(data: data, contentType: "image/jpeg")
               completionBlock(response)
           } else {
               completionBlock(MediaNotFoundResponse)
           }
       })
   }
   
   /**
    *  Video 文件响应,Video 文件数据很大,必须使用FileResonse
    *
    *  注意 `byteRange: request.byteRange` 这里,如果不这么处理 DLNA 设备无法播放媒体文件。
    *
    */
   webServer.addHandlerForMethod("GET", pathRegex: "/videos/", requestClass: GCDWebServerRequest.self) { (request, completionBlock) in
       let url = request.URL.URLString
       DLNAImageHelper.fetchVideoFile(url, callBack: { (fileURL) in
           if let file = fileURL {
               let response = GCDWebServerFileResponse(file: file, byteRange: request.byteRange)
               completionBlock(response)
           } else {
               completionBlock(MediaNotFoundResponse)
           }
       })
   }

   webServer.startWithPort(8899, bonjourName: nil)
}

参考

猜你喜欢

转载自blog.csdn.net/MYBOYER/article/details/107810916
今日推荐