iOS-DLNA(UPnP-GENA)

1,157 阅读4分钟

简介

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

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

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

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

过程如下 image.png

由于涉及到了需要服务接受事件消息回调,因此我们需要使用框架[GCDWebServer]进行创建本地的HTTP server

订阅

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

请求信息如下

SUBSCRIBE /dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/event HTTP/1.1
HOST: xxx.xxx.xx.xx:xxxxx
USER-AGENT: iOS/15.0 UPnP/1.1 SCDLNA/1.0
CALLBACK: <http://xxxx.xxxx.x.xxx:xxxx/dlna/callback>
NT: upnp:event
TIMEOUT: Second-3600    // 订阅期限

请求路径为设备描述文档中<service></service>标签对中的<eventSubURL>
请求域名为SSDP协议发现的设备信息中的LOCATION

  • SUBSCRIBEHTTPMethod
  • CALLBACK:通知回调地址
  • NT:固定为upnp:event

响应

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

/// 成功
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

/// 失败
HTTP/1.1 error code errordescrioption
Server: OS/Version UPnP/1.1 product/version
SID: uuid:subscibe-UUID
Content-Length: 0

核心代码如下

/// 注意前提是webServer已经创建成功,serverURL地址已经存在
/// 订阅指定服务的状态响应通知
- (void)subscribeEventNotificationForService:(CLUPnPDevice * _Nonnull)service response:(void (^ _Nullable)(NSString * _Nullable subscribeID, NSURLResponse * _Nullable response, NSError * _Nullable error))responseBlock {
    NSString *url = nil;
    NSString *eventSubURL = service.AVTransport.eventSubURL;
    if ([eventSubURL hasPrefix:@"/"]) {
        url = [NSString stringWithFormat:@"%@%@", service.URLHeader, eventSubURL];
    } else {
        url = [NSString stringWithFormat:@"%@/%@", service.URLHeader, eventSubURL];
    }
    NSString *str = self.webServer.serverURL.absoluteString;
    if ([str hasSuffix:@"/"]) {
        str = [str substringToIndex:str.length-1];
    }
    NSString *webServerURL = [NSString stringWithFormat:@"<%@%@>", str, SERVER_PATH];
    NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url.stringByRemovingPercentEncoding]];
    request.HTTPMethod = @"SUBSCRIBE";
    [request addValue:webServerURL forHTTPHeaderField:@"CALLBACK"];
    [request addValue:@"upnp:event" forHTTPHeaderField:@"NT"];
    [request addValue:@"Infinite" forHTTPHeaderField:@"TIMEOUT"];
    
    [[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
    NSString *sid = nil;
    if (error == nil) {
        NSHTTPURLResponse *resp = (NSHTTPURLResponse *)response;
        if (resp.statusCode == 200) {
            sid = resp.allHeaderFields[@"SID"] ? resp.allHeaderFields[@"SID"] : nil;
        }
    } 
    if (responseBlock) {
        responseBlock(nil, response, error);
    }
    }] resume];
}

续订、取消订阅

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

续订

SUBSCRIBE /dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/event HTTP/1.1
HOST: xxx.xxx.xx.xx:xxxxx
SID: uuid:subscibe-UUID
TIMEOUT: Second-3600  

取消订阅

UNSUBSCRIBE /dev/88024158-a0e8-2dd5-ffff-ffffc7831a22/svc/upnp-org/AVTransport/event HTTP/1.1
HOST: xxx.xxx.xx.xx:xxxxx
SID: uuid:subscibe-UUID

单播事件消息

当服务器上的状态变量发生变数时,通过单播给订阅者发送通知。单播通过HTTP协议发送。需要在本地运行一个HTTP Server来接受请求。

单播消息格式如下

/// 播放
<?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>

/// 停止
<?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>

有些设备返回的xml中 < > 被转义,导致解析时候出错。所以需要先反转义,然后再解析。

核心代码如下

/// 启动Server
- (void)start {
    if (self.webServer == nil) {
        self.webServer = [[GCDWebServer alloc] init];
        __weak typeof(self) weakSelf = self;
        //(Asynchronous version) The handler returns immediately and calls back GCDWebServer later with the generated HTTP response
        [weakSelf.webServer addHandlerForMethod:@"NOTIFY" path:SERVER_PATH requestClass:[GCDWebServerDataRequest class] asyncProcessBlock:^(__kindof GCDWebServerRequest *request, GCDWebServerCompletionBlock completionBlock) {
            // Do some async operation like network access or file I/O (simulated here using dispatch_after())
            GCDWebServerDataRequest *req = (GCDWebServerDataRequest *)request;
            __strong typeof(self) strongSelf = weakSelf;
            if (req.hasBody && strongSelf) {
                [strongSelf parseEventNotificationMessage:req.data];
            }
            GCDWebServerDataResponse* response = [GCDWebServerDataResponse responseWithHTML:@"<html><body><p>Hello</p></body></html>"];
            if (completionBlock) {
                completionBlock(response);
            }
        }];
        [self.webServer startWithPort:LOCAL_SERVER_PORT bonjourName:nil];
    }
}

/// 通知接受解析
- (void)parseEventNotificationMessage:(NSData *)data {
    if (data == nil) {
        return;
    }
    NSDictionary *dictData = [NSDictionary dictionaryWithXMLData:data];
    NSString *lastChange = [dictData stringValueForKeyPath:@"e:property.LastChange"];
    if (lastChange == nil || [lastChange isKindOfClass:[NSNull class]] || lastChange.length <= 0) {
        return;
    }
    NSDictionary *eproperty = [NSDictionary dictionaryWithXMLString:lastChange];
    NSString *transportstate = [eproperty stringValueForKeyPath:@"InstanceID.TransportState._val"] ? [eproperty stringValueForKeyPath:@"InstanceID.TransportState._val"] : [eproperty stringValueForKeyPath:@"InstanceID.TransportState.val"];
    if (transportstate == nil || [transportstate isKindOfClass:[NSNull class]] || transportstate.length <= 0) {
        return;
    }
    /// 处理transportstate,这里的transportstate为PAUSED_PLAYBACK、PLAYING、STOPPED、TRANSITIONING等
}