【完结】基于upnp的DIAL协议的分析

1. 背景

    目前基于Wi-Fi Display的应用越来越少,随着Android AOSP放弃UI上对Wi-Fi Display的支持,后续对ChromeCast的支持力度会不断增加。而ChromeCast 则是基于DIAL协议进行的扩展和封装。

    对于多屏幕的支持,目前除了保持对Wi-Fi Display现有的力度,后续会逐渐切换到DIAL协议或Amazon的WhisperPlay SDK的学习上。 此篇则记录了基于最新的DIAL协议的一些简单分析。


2. 资料

    DIAL specification: http://www.dial-multiscreen.org/dial-protocol-specification(v2.1)  对应的中文版本下载(csdn需1积分):

    DIAL Server/Client Sample Code:https://github.com/Netflix/dial-reference 

    测试设备:2台android设备(因后续交叉编译需要有编译环境,使用公司设别进行测试)

    测试抓取的wireshark 封包(server IP:192.168.43.105  Client IP:192.168.43.208  AP:192.168.43.1),使用filter (!(ip.addr == 192.168.43.1)&&!(ip.addr == 8.8.8.8))后只查看server和client通讯


3. 基本原理

    DIAL是DIscoveryAndLaunch的缩写,主要应用是让second-screen上的应用发现并启动first-screen设备上的应用。

    这里需要解释一下一下几个名词:

  • First-screen : a TV, Blu-ray player, set-top-box, or similar device,可以简单认为是大屏幕的设备
  • Second-screen : a smartphone, tablet, or similar device,即小屏幕设备
  • DIAL Server: a device implementing the server side of the DIAL protocol, usually a first-screen device.
  • DIAL Client : a device that can discover and launch applications on a DIAL server, usually a second-screen device. 
    当然First-Screen和Second-Screen并没有严格的界限。
    从协议的角度看,DIAL可以分成两个阶段DIAL Service DiscoveryDIAL REST Service. 此处和P2P的阶段有点类似。都需要先发现后交互,只是两者采用的协议不一样而已。其实在Wi-Fi Direct的应用层面已经有了相关的使用, 具体可以参考Wi-Fi Direct Service中的Send Service(该协议就是基于uPnp实现设备的发现,文件的发送和校验)

    DIAL Service Discovery 

        此过程基于Upnp协议,分成M-Search和Get Location URL两个阶段。

        M-Search

  • Client端向239.255.255.250:1900地址广播M-Search消息,消息会设定ST(Seach Target)为urn:dail-multiscreen-org:service:dial 1
  • Server会监听该端口,并回复该端口的广播信息。回复信息中会包含LOCATION URL(包含本地IP地址)或WAKEUP字段(如果支持WoL或WoWLAN)。Client收到回复后,需要存储支持WAKEUP字段的Server信息,用于远程唤醒。
           需要注意的是, Client可能收到Server不同端口上相同的Service信息,例如Service含有多个网络接口。封包内容如下:
           
           

       GET Location URL

          在M-Search Response后,client已经得到Server端SSDP服务的IP和端口。
  • Client端发送HTTP GET消息用于获取Server更多信息。
  • Server回复消息中会包含Application-URL
                
                
               流程图如下:


    DIAL REST Service

        在Discovery阶段会得到Application-URL,该标示由DIAL REST Service URL 加单斜线“/”以及APP名字组成,后续的操作都是基于Application-URL来执行。 主要操作包括:

        应用状态查询: HTTP GET方法。 参考流程图中(1)(2)中"find out if app X exists",具体信息参考6.1。


           应用启动:HTTP Post方法, 具体信息参考6.2. 参考流程图步骤3/4:

            对应参考封包格式如下:

            关于request 和response的具体要求参考6.2.1 和6.2.2。 关于启动了HDMI-CEC的命令格式,参考6.2.3.

        应用停止:HTTP DELETE方法。


        应用隐藏和恢复:HTTP POST方法。恢复实际上调用的是Launch的方法


        发送附加数据: 此部分属于Server和APP沟通格式的定义,6.3章有详细介绍,参考代码中没有对这部分实现,也没有仔细研究。此处还涉及到跨域请求部分,参考https://yq.aliyun.com/articles/69313 的介绍。
        

4. 代码学习

    本节分析的代码基于 SourceCode,包含source和client两部分。分析代码的第一步就是编译代码,实际运行后,思考为何要这么实现。

4.1 交叉编译

    由于本机有了android的编译环境,最直接的方法是将Source Code放到android编译环境中直接编译。可以参考修改后的代码
    主要的修改有:
    1. 在原android系统的external/wpa_supplicant 的android.mk中添加server和client的makefile
    2. 在server和client中添加对应的makefile,参考原始的makefile
    3. 修改部分编译错误,主要是C++数组初始化错误

    编译完成,输出结果: dialServer  dialClient  和Test。 

    测试步骤:
    1. 准备两台android设备,复制对应的binary到/data/misc/media目录下(需要root)
    2. 启动dialServer
    3. 启动dialClient,一旦client发现server,就会在client端log中打印出server信息,选择对应的设别进行后续操作。

     Q: 遇到好几次Client搜索到了Server设备,但是返回的IP, Friendly name等都是空。

4.2 Server端

    启动Server后,等待Client链接。启动部分的流程图如下:

    函数mg_start() 是开源库mangoose中已经提供的API,简单的分析如下:
struct mg_context *mg_start(mg_callback_t user_callback, void *user_data, int port) {
  struct mg_context *ctx;

  // Allocate context and initialize reasonable general case defaults.
  // TODO(lsm): do proper error handling here.
  ctx = (struct mg_context *) calloc(1, sizeof(*ctx));
  //定义用户的回调函数,参考main函数中定义的回调函数
  ctx->user_callback = user_callback;
  ctx->user_data = user_data;

  //更新socket连接需要使用的port信息
  if (!set_ports_option(ctx, port)) {
    free_context(ctx);
    return NULL;
    
  }
  // Ignore SIGPIPE signal, so if browser cancels the request, it
  // won't kill the whole process.
  (void) signal(SIGPIPE, SIG_IGN);
  (void) pthread_mutex_init(&ctx->mutex, NULL);

  //cond : worker_thread处理完后通知master_thread 的信号
  //sq_empty : worker_thread 消耗时发送的信号
  //sq_full :  master_thread 接受到新连接时发送的信号
  (void) pthread_cond_init(&ctx->cond, NULL);
  (void) pthread_cond_init(&ctx->sq_empty, NULL);
  (void) pthread_cond_init(&ctx->sq_full, NULL);

  // Start master (listening) thread
  start_thread(ctx, (mg_thread_func_t) master_thread, ctx);

  // Start worker threads
  for (int i = 0; i < NUM_THREADS; i++) {
    if (start_thread(ctx, (mg_thread_func_t) worker_thread, ctx) != 0) {
      cry(fc(ctx), "Cannot start worker thread: %d", ERRNO);
    } else {
      ctx->num_threads++;
    }
  }

  return ctx;
}

    其中SSDP对应的Port为:56790, DIAL service对应的port为56789。 

    对应流程图中的函数:
static int set_ports_option(struct mg_context *ctx, int port) {
  ...
  // MacOS needs that. If we do not zero it, subsequent bind() will fail. 
  //DIAL Server监听本机56789端口的数据,SSDP监听本机端口56790端口数据
  memset(&ctx->local_address, 0, sock_len);
  ctx->local_address.sin_family = AF_INET;
  ctx->local_address.sin_port = htons((uint16_t) port);
  ctx->local_address.sin_addr.s_addr = htonl(INADDR_ANY);

  struct timeval tv;
  tv.tv_sec = 0;
  tv.tv_usec = 500 * 1000;

  if ((ctx->local_socket = socket(PF_INET, SOCK_STREAM, 6)) == INVALID_SOCKET ||
      setsockopt(ctx->local_socket, SOL_SOCKET, SO_REUSEADDR, &reuseaddr, sizeof(reuseaddr)) != 0 ||
      setsockopt(ctx->local_socket, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) != 0 ||
      bind(ctx->local_socket, (const struct sockaddr *) &ctx->local_address, sock_len) != 0 ||
      // TODO(steineldar): Replace 20 (max socket backlog len in connections).
      listen(ctx->local_socket, 20) != 0) {
    close(ctx->local_socket);
    cry(fc(ctx), "%s: cannot bind to port %d: %s", __func__, port, strerror(ERRNO));
    success = 0;
  } else if (getsockname(ctx->local_socket, (struct sockaddr *) &ctx->local_address, &sock_len)) {
    close(ctx->local_socket);
    cry(fc(ctx), "%s: %s", __func__, strerror(ERRNO));
    success = 0;
  }

  if (!success) {
    ctx->local_socket = INVALID_SOCKET;
    close_all_listening_sockets(ctx);
  }

  return success;
}


    master_thread()开始监听刚刚创建的socket, 并调用produce_socket()进行数据处理(通过signal控制thread):
static void master_thread(struct mg_context *ctx) {
  struct socket accepted;
  ...
  while (ctx->stop_flag == 0) {
    memset(&accepted.remote_addr, 0, sock_len);
    
    accepted.sock = accept(ctx->local_socket,
        (struct sockaddr *) &accepted.remote_addr, &sock_len);

     if (accepted.sock != INVALID_SOCKET) {
      // Put accepted socket structure into the queue.
      DEBUG_TRACE(("accepted socket %d", accepted.sock));
      produce_socket(ctx, &accepted);
    }
  }
  ...
 }


// Master thread adds accepted socket to a queue
static void produce_socket(struct mg_context *ctx, const struct socket *sp) {
  (void) pthread_mutex_lock(&ctx->mutex);

  //keep waiting until sq_empty signaled
  while (ctx->sq_head - ctx->sq_tail >= (int) ARRAY_SIZE(ctx->queue)) {
    (void) pthread_cond_wait(&ctx->sq_empty, &ctx->mutex);
  }
  assert(ctx->sq_head - ctx->sq_tail < (int) ARRAY_SIZE(ctx->queue));

  // Copy socket to the queue and increment head
  ctx->queue[ctx->sq_head % ARRAY_SIZE(ctx->queue)] = *sp;
  ctx->sq_head++;
  DEBUG_TRACE(("queued socket %d", sp->sock));

  // After update the connected socket, signal sq_full to work_thread
  (void) pthread_cond_signal(&ctx->sq_full);
  (void) pthread_mutex_unlock(&ctx->mutex);
}


    一旦master_thread完成了连接任务,并发送了sq_full 信号量,则会激活worker_thread的流程处理,
static void worker_thread(struct mg_context *ctx) {
  ...
  while (ctx->stop_flag == 0 && consume_socket(ctx, &conn->client)) {
    ...

    // Fill in peer IP, port info early so even if SSL setup below fails,
    // error handler would have the corresponding info.
    // Thanks to Johannes Winkelmann for the patch.
    memcpy(&conn->request_info.remote_addr,
           &conn->client.remote_addr, sizeof(conn->client.remote_addr));

    // Fill in local IP info
    socklen_t addr_len = sizeof(conn->request_info.local_addr);
    getsockname(conn->client.sock,
        (struct sockaddr *) &conn->request_info.local_addr, &addr_len);

    process_new_connection(conn);

    close_connection(conn);
  }
  ...

  (void) pthread_cond_signal(&ctx->cond);
  ...
 }


    其中consume_socket()由sq_full触发,
static int consume_socket(struct mg_context *ctx, struct socket *sp) {
  ...
  // If the queue is empty, wait. We're idle at this point.
  while (ctx->sq_head == ctx->sq_tail && ctx->stop_flag == 0) {
    pthread_cond_wait(&ctx->sq_full, &ctx->mutex);
  }
  ...
  // Copy socket from the queue and increment tail
  *sp = ctx->queue[ctx->sq_tail % ARRAY_SIZE(ctx->queue)];
  ctx->sq_tail++;
  ...
  (void) pthread_cond_signal(&ctx->sq_empty);
  ...
}

    建立连接的目的是为了进行数据的处理, 最终进入process_new_connection(),该函数有两个重要的API:parse_http_request()和handle_request()。

    解析出来的数据在handle_request()中调用call_user(), 最终就到了mg_start() 入口参数的callback变量了。
    代码大部分的流程都在handle_request()中:
    - SSDP对应的handle_request()只是回复了对方的SSDP request,逻辑很简单。
    - DIAL对应的handle_request()需要参考REST Service中定义的消息,进行逻辑判断和消息回复。

    上述流程完成后,基本可以进行SSDP和DIAL的信息交互,不过我们要先去看看M-Search的处理流程:

    参考quick_ssdp.c中的handle_mcast(),此处监听239.255.255.250上1900端口的数据,收到消息后回复指定格式数据到对方相应的IP和Port。 此函数对应的Client段的函数为: send_mcast()
static void handle_mcast() {
    ...
    saddr.sin_family = AF_INET;
    saddr.sin_addr.s_addr = inet_addr("239.255.255.250");
    saddr.sin_port = htons(1900);
    ...
    //239.255.255.250 & 127.0.0.1 in the group
    mreq.imr_multiaddr.s_addr = inet_addr("239.255.255.250");
    mreq.imr_interface.s_addr = inet_addr(ip_addr);
    ...
    while (1) {
        addrlen = sizeof(saddr);
        if (-1 == (bytes = recvfrom(s, gBuf, sizeof(gBuf) - 1, 0,
                                    (struct sockaddr *)&saddr, &addrlen))) {
            perror("recvfrom");
            continue;
        }
		...
        if (!strstr(gBuf, "urn:dial-multiscreen-org:service:dial:1")) {

                continue;
            }
        ...
        if (-1 == sendto(s, send_buf, send_size, 0, (struct sockaddr *)&saddr, addrlen)) {
            perror("sendto");
            continue;
        }
    }
}

    下面看看DIAL REST Service的request_handler()的流程:

   代码中定义的HTTP Header关键字
    #define APPS_URI "/apps/"
    #define RUN_URI "/run"
    #define HIDE_URI "/hide"

    对应的流程图为:


4.3 Client端 

    启动Client端后,会主动触发M-Search的广播消息。启动部分的流程图下:

    Client启动后,发送M-SEARCH消息,查看当前网络上是否有active的设备:
void *DialDiscovery::send_mcast() 
{
    ...
    if (-1 == (my_sock = socket(AF_INET, SOCK_DGRAM, 0))) {
        perror("socket");
        exit(1);
    }
    ...
    
	saddr.sin_family = AF_INET;
    saddr.sin_addr.s_addr = inet_addr("239.255.255.250");
    saddr.sin_port = htons(1900);

    addrlen = sizeof(saddr);
	//往239.255.255.250:1900上广播M-SEARCH消息
    if (-1 == sendto(my_sock, send_buf, send_size, 0, (struct sockaddr *)&saddr, addrlen)) {
        perror("sendto");
        return 0;
    }
    ...

    connection.saddr = saddr;
    connection.sock = my_sock;
    connection.addrlen = addrlen;

    receiveResponses(&connection);
    ...
}


    对应的数据交互流程图如下:

Client端选择不同的输入对应的选择项为:
0. Rescan and list DIAL servers
调用send_mcast(),参考前面代码。

1. Launch Netflix
调用launchApplication(),最后调用到 DialServer::sendCommand(),对应命令为:COMMAND_LAUNCH

2. Hide Netflix
调用hideApplication(),最后调用到 DialServer::sendCommand(),对应命令为:COMMAND_HIDE

3. Stop Netflix
调用stopApplication(),最后调用到 DialServer::sendCommand(),对应命令为:COMMAND_KILL

4. Netflix status
调用getStatus(),最后调用到 DialServer::sendCommand(),对应命令为:COMMAND_STATUS

5. Launch YouTube
调用launchApplication(),参考Netflix

6. Hide YouTube
调用hideApplication(),参考Netflix

7. Stop YouTube
调用stopApplication(),参考Netflix

8. YouTube status
调用getStatus(),参考Netflix

9. Run conformance tests
调用runConformance()

10. Wake up on lan/wlan
调用sendMagic(),发送特定格式的封包,用于激活支持WoL或WoWLAN的设备。该封包内容为6Byte的0xFF

11. QUIT
退出当前loop

    由于Client端最终都会调用到DialServer::sendCommand(),我们接下来看看该函数的内容:
int DialServer::sendCommand( 
        string &url, 
        int command, 
        string &payload, 
        string &responseHeaders, 
        string &responseBody ) 
{
    ...
    if (curl_global_init(CURL_GLOBAL_ALL) != CURLE_OK) 
    {
        ...
    }

    if ((curl = curl_easy_init()) == NULL) 
    {
        ...
    }

    if (command == COMMAND_LAUNCH || command == COMMAND_HIDE) 
    {
        curl_easy_setopt(curl, CURLOPT_POST, true);
        curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, payload.size());
        if( payload.size() )
        {
            slist = curl_slist_append(slist, "Expect:");
            curl_easy_setopt(curl, CURLOPT_HTTPHEADER, slist);
            curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload.c_str());
        }
        ...
    } 
    else if (command == COMMAND_KILL)
    {
        curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE");
    }
    ...
    curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
    curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, header_cb);
    curl_easy_setopt(curl, CURLOPT_HEADERDATA, &responseHeaders);
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, receiveData);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &responseBody);
    res = curl_easy_perform(curl);

    if (slist!=NULL) curl_slist_free_all(slist);
    curl_easy_cleanup(curl);
    curl_global_cleanup();
    return (res == CURLE_OK);
}

    关于Curl的信息参考:https://curl.haxx.se/libcurl/c/curl_easy_setopt.html
    
    curl_slist_append()     - add a string to an slist . appends a specified string to a linked list of strings. The existing list should be passed as the first argument while the new list is returned from this function. The specified string has been appended when this function returns. curl_slist_append() copies the string.

   下面介绍curl_easy_setopt()函数使用的各个参数:

CURLOPT_POST  Issue a HTTP POST request. See CURLOPT_POST

CURLOPT_POSTFIELDSIZE The POST data is this big. See CURLOPT_POSTFIELDSIZE

CURLOPT_POSTFIELDS  Send a POST with this data. See CURLOPT_POSTFIELDS

CURLOPT_HTTPHEADER  Custom HTTP headers. See CURLOPT_HTTPHEADER

CURLOPT_CUSTOMREQUEST  Custom request/method. See CURLOPT_CUSTOMREQUEST 

CURLOPT_URL  URL to work on. See CURLOPT_URL

CURLOPT_HEADERFUNCTION  Callback for writing received headers. See CURLOPT_HEADERFUNCTION

CURLOPT_HEADERDATA  Data pointer to pass to the header callback. See CURLOPT_HEADERDATA

CURLOPT_WRITEFUNCTION  Callback for writing data. See CURLOPT_WRITEFUNCTION

CURLOPT_WRITEDATA  Data pointer to pass to the write callback. See CURLOPT_WRITEDATA


其实Client没有处理HTTP Response的代码。这部分应该也不是代码的关注点。





發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章