【完結】基於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的代碼。這部分應該也不是代碼的關注點。





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