ESP32学习笔记(34)——BLE一主多从连接

一、简介

由于蓝牙主机和从机组网,如果不使用 Mesh 的话,只能组微微网。蓝牙5.0的微微网最大可以连接20个从机。

1.1 连接句柄

在主机与从机发生连接的时候会进行连接句柄的分配。连接句柄的作用是在蓝牙数据进行分组的时候进行设备区分的。连接句柄相当于一个“令牌”,从设备一旦和主设备发生连接,主设备就给从设备分配一个“令牌”。主设备通过这个“令牌”来识别与区分从设备。因此对于连接句柄的分配将是实现一主多从连接,并且进行通信的关键。

1.2 Bluedroid主机架构

在 ESP-IDF 中,使用经过大量修改后的 BLUEDROID 作为蓝牙主机 (Classic BT + BLE)。BLUEDROID 拥有较为完善的功能,⽀持常用的规范和架构设计,同时也较为复杂。经过大量修改后,BLUEDROID 保留了大多数 BTA 层以下的代码,几乎完全删去了 BTIF 层的代码,使用了较为精简的 BTC 层作为内置规范及 Misc 控制层。修改后的 BLUEDROID 及其与控制器之间的关系如下图:

二、API说明

以下 GATT 接口位于 bt/host/bluedroid/api/include/api/esp_gattc_api.h

2.1 esp_ble_gattc_open

2.2 esp_ble_gattc_search_service

2.3 esp_ble_gattc_get_char_by_uuid

2.4 esp_ble_gattc_get_descr_by_char_handle

2.5 esp_ble_gattc_get_attr_count

2.6 esp_ble_gattc_write_char

2.7 esp_ble_gattc_write_char_descr

2.8 esp_ble_gattc_register_for_notify

三、BT控制器和协议栈初始化

使用 esp-idf\examples\bluetooth\bluedroid\ble\gattc_multi_connect 中的例程

.........
//esp_bt_controller_config_t是蓝牙控制器配置结构体,这里使用了一个默认的参数
    esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT();
    //初始化蓝牙控制器,此函数只能被调用一次,且必须在其他蓝牙功能被调用之前调用
    ret = esp_bt_controller_init(&bt_cfg);
    if (ret) {
        ESP_LOGE(GATTC_TAG, "%s initialize controller failed: %s\n", __func__, esp_err_to_name(ret));
        return;
    }

    //使能蓝牙控制器,mode是蓝牙模式,如果想要动态改变蓝牙模式不能直接调用该函数,
    //应该先用disable关闭蓝牙再使用该API来改变蓝牙模式
    ret = esp_bt_controller_enable(ESP_BT_MODE_BLE);
    if (ret) {
        ESP_LOGE(GATTC_TAG, "%s enable controller failed: %s\n", __func__, esp_err_to_name(ret));
        return;
    }
    //初始化蓝牙并分配系统资源,它应该被第一个调用
    /*
    蓝牙栈bluedroid stack包括了BT和BLE使用的基本的define和API
    初始化蓝牙栈以后并不能直接使用蓝牙功能,
    还需要用FSM管理蓝牙连接情况
    */
    ret = esp_bluedroid_init();
    if (ret) {
        ESP_LOGE(GATTC_TAG, "%s init bluetooth failed: %s\n", __func__, esp_err_to_name(ret));
        return;
    }
    //使能蓝牙栈
    ret = esp_bluedroid_enable();
    if (ret) {
        ESP_LOGE(GATTC_TAG, "%s enable bluetooth failed: %s\n", __func__, esp_err_to_name(ret));
        return;
    }

    //建立蓝牙的FSM(有限状态机)
    //这里使用回调函数来控制每个状态下的响应,需要将其在GATT和GAP层的回调函数注册
    /*esp_gattc_cb和esp_gap_cb处理蓝牙栈可能发生的所有情况,达到FSM的效果*/
    ret = esp_ble_gap_register_callback(esp_gap_cb);
    if (ret){
        ESP_LOGE(GATTC_TAG, "gap register error, error code = %x", ret);
        return;
    }
    ret = esp_ble_gattc_register_callback(esp_gattc_cb);
    if(ret){
        ESP_LOGE(GATTC_TAG, "gattc register error, error code = %x", ret);
        return;
    }

    //下面创建了BLE GATT服务A、B、C,相当于3个独立的应用程序
    ret = esp_ble_gattc_app_register(PROFILE_A_APP_ID);
    if (ret){
        ESP_LOGE(GATTC_TAG, "gattc app register error, error code = %x", ret);
        return;
    }
    ret = esp_ble_gattc_app_register(PROFILE_B_APP_ID);
    if (ret){
        ESP_LOGE(GATTC_TAG, "gattc app register error, error code = %x", ret);
        return;
    }
    ret = esp_ble_gattc_app_register(PROFILE_C_APP_ID);
    if (ret){
        ESP_LOGE(GATTC_TAG, "gattc app register error, error code = %x", ret);
        return;
    }
    /*
    设置了MTU的值(经过MTU交换,从而设置一个PDU中最大能够交换的数据量)。
    例如:主设备发出一个1000字节的MTU请求,但是从设备回应的MTU是500字节,那么今后双方要以较小的值500字节作为以后的MTU。
    即主从双方每次在做数据传输时不超过这个最大数据单元。
    */
    ret = esp_ble_gatt_set_local_mtu(200);
    if (ret){
        ESP_LOGE(GATTC_TAG, "set local  MTU failed, error code = %x", ret);
    }
.......

四、应用程序配置文件

应用程序配置文件是一种对功能进行分组的方法。它们的设计使每个应用程序配置文件连接到一个对等设备,这样同一个 ESP32 可以通过为每个设备分配一个应用程序配置文件连接到多个设备。每个应用程序配置文件都会创建一个 GATT 接口以连接到其他设备。应用程序配置文件由 ID 号定义,在此示例中有三个配置文件:

#define PROFILE_NUM 3
#define PROFILE_A_APP_ID 0  
#define PROFILE_B_APP_ID 1
#define PROFILE_C_APP_ID 2

esp_ble_gattc_app_register()函数用于将每个应用程序配置文件注册到 BLE 堆栈。注册操作会生成一个 GATT 接口,该接口作为注册事件中的参数返回。此外,每个应用程序配置文件还由一个结构定义,该结构可用于在堆栈传播新数据时保持应用程序的状态并更新其参数。

代码中的应用程序配置文件是gattc_profile_inst结构的实例。有关详细信息,请参见应用程序配置文件GATT客户实例演练

五、扫描

5.1 设置扫描参数

参见第设置扫描参数GATT客户实例演练

5.2 开始扫描

参阅Section开始扫描GATT客户实例演练

5.3 获取扫描结果

参阅Section获取扫描结果GATT客户实例演练

5.4 名称比较

  • 首先,从广告数据中提取设备名称并存储在adv_name变量中:
adv_name = esp_ble_resolve_adv_data(scan_result->scan_rst.ble_adv, ESP_BLE_AD_TYPE_NAME_CMPL, &adv_name_len);
  • 然后,将找到的设备名称与客户端想要连接的服务器名称进行比较。服务器名称在remote_device_name数组中定义:
static const char remote_device_name[3][20] = {"ESP_GATTS_DEMO_1", "ESP_GATTS_DEMO_2", “ESP_GATTS_DEMO_3"};

名称比较发生如下:

 if (strlen(remote_device_name[0]) == adv_name_len && strncmp((char *)adv_name, remote_device_name[0], adv_name_len) == 0) {
                 if (find_device_1 == false) {
                     find_device_1 = true;
                     ESP_LOGI(GATTC_TAG, "Searched device %s", remote_device_name[0]);
                     memcpy(gl_profile_tab[PROFILE_A_APP_ID].remote_bda, scan_result->scan_rst.bda, 6);
                 }
                 break;
             }
             else if (strlen(remote_device_name[1]) == adv_name_len && strncmp((char *)adv_name, remote_device_name[1], adv_name_len) == 0) {
                 if (find_device_2 == false) {
                     find_device_2 = true;
                     ESP_LOGI(GATTC_TAG, "Searched device %s", remote_device_name[1]);
                     memcpy(gl_profile_tab[PROFILE_B_APP_ID].remote_bda, scan_result->scan_rst.bda, 6);
                 }
             }
             else if (strlen(remote_device_name[2]) == adv_name_len && strncmp((char *)adv_name, remote_device_name[2], adv_name_len) == 0) {
                 if (find_device_3 == false) {
                     find_device_3 = true;
                     ESP_LOGI(GATTC_TAG, "Searched device %s", remote_device_name[2]);
                     memcpy(gl_profile_tab[PROFILE_C_APP_ID].remote_bda, scan_result->scan_rst.bda, 6);
                 }
                 break;
             }                                    
  • 如果找到的任何设备名称对应于远程设备名称,find_device_X则设置该标志并将远程设备的地址存储在gl_profile_tab表中。设置所有标志后,客户端将停止扫描并连接到远程设备。

六、连接到远程设备

6.1 连接到第一个远程设备

找到所有设备后,客户端将停止扫描:

if (find_device_1 && find_device_2 && find_device_3 && stop_scan == false {
    stop_scan = true;
    esp_ble_gap_stop_scanning();
    }

扫描停止触发一个ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT事件,该事件用于打开与第一个远程设备的连接。一旦客户端搜索服务、获取特征并在第一个设备上注册通知,第二个和第三个设备就会建立连接。此工作流旨在测试每个远程设备之间的通信是否正常工作,然后再尝试连接到下一个设备,或者如果出现错误,请跳到下一个设备。

case ESP_GAP_BLE_SCAN_STOP_COMPLETE_EVT:
    if (param->scan_stop_cmpl.status != ESP_BT_STATUS_SUCCESS){
        ESP_LOGE(GATTC_TAG, "Scan stop failed");
        break;
    }
    ESP_LOGI(GATTC_TAG, "Stop scan successfully");
    if (!stop_scan){
        ESP_LOGE(GATTC_TAG, "Did not find all devices");
    }
    if (find_device_1){
        esp_ble_gattc_open(gl_profile_tab[PROFILE_A_APP_ID].gattc_if, gl_profile_tab[PROFILE_A_APP_ID].remote_bda, true);
    }
    break;
  • 使用esp_ble_gattc_open()GATT 接口、远程设备地址和布尔值的函数打开连接,直接连接设置为 true,后台自动连接设置为 false。为了断开物理连接,使用了 GAP API 函数esp_ble_gap_disconnect()

    连接到第一个设备时,ESP_GATTC_CONNECT_EVT会生成一个事件,该事件将转发到所有配置文件。它还触发ESP_GATTC_OPEN_EVT仅转发到 Profile A 事件处理程序或gattc_profile_a_event_handler()函数的事件。该事件检查连接是否成功打开,如果没有,则忽略该设备,客户端尝试打开与第二个设备的连接:

case ESP_GATTC_OPEN_EVT:
      if (p_data->open.status != ESP_GATT_OK){
          //open failed, ignore the first device, connect the second device
          ESP_LOGE(GATTC_TAG, "connect device failed, status %d", p_data->open.status);
          if (find_device_2){
              esp_ble_gattc_open(gl_profile_tab[PROFILE_B_APP_ID].gattc_if, gl_profile_tab[PROFILE_B_APP_ID].remote_bda, true);
          }
          break;
      }

如果连接成功,客户端保存连接 ID,打印远程设备信息并将 MTU 大小配置为 200 字节。

 gl_profile_tab[PROFILE_A_APP_ID].conn_id = p_data->open.conn_id;
 ESP_LOGI(GATTC_TAG, "ESP_GATTC_OPEN_EVT conn_id %d, if %d, status %d, mtu %d", p_data->open.conn_id, gattc_if, p_data->open.status, p_data->open.mtu);
 ESP_LOGI(GATTC_TAG, "REMOTE BDA:");
 esp_log_buffer_hex(GATTC_TAG, p_data->open.remote_bda, sizeof(esp_bd_addr_t));
 esp_err_t mtu_ret = esp_ble_gattc_config_mtu (gattc_if, p_data->open.conn_id, 200);
 if (mtu_ret){
 ESP_LOGE(GATTC_TAG, "config MTU error, error code = %x", mtu_ret);
 }
 break;
  • 配置好MTU大小后,ESP_GATTC_CFG_MTU_EVT生成一个。此事件用于搜索远程设备上可用的已知服务。搜索是通过使用esp_ble_gattc_search_service()函数和由以下定义的服务 ID 执行的:
 static esp_bt_uuid_t remote_filter_service_uuid = {
 .len = ESP_UUID_LEN_16,
 .uuid = {.uuid16 = REMOTE_SERVICE_UUID,},
 };

然后处理程序搜索服务:

 case ESP_GATTC_CFG_MTU_EVT:
     if (param->cfg_mtu.status != ESP_GATT_OK){
         ESP_LOGE(GATTC_TAG,"Config mtu failed");
     }
     ESP_LOGI(GATTC_TAG, "Status %d, MTU %d, conn_id %d", param->cfg_mtu.status, param->cfg_mtu.mtu, param->cfg_mtu.conn_id);
     esp_ble_gattc_search_service(gattc_if, param->cfg_mtu.conn_id, &remote_filter_service_uuid);
     break;

如果找到该服务,ESP_GATTC_SEARCH_RES_EVT则会触发一个允许将 设置get_service_1 flag为 true 的事件。该标志用于打印信息,稍后获取客户端感兴趣的特征。

  • 一旦完成对所有服务的搜索,ESP_GATTC_SEARCH_CMPL_EVT就会生成一个事件,用于获取刚刚发现的服务的特征。这是通过esp_ble_gattc_get_characteristic()函数完成的:
 case ESP_GATTC_SEARCH_CMPL_EVT:
     if (p_data->search_cmpl.status != ESP_GATT_OK){
         ESP_LOGE(GATTC_TAG, "search service failed, error status = %x", p_data->search_cmpl.status);
         break;
     }
     if (get_service_1){
         esp_ble_gattc_get_characteristic(gattc_if, p_data->search_cmpl.conn_id, &remote_service_id, NULL);
     }
     break;

esp_ble_gattc_get_characteristic()函数以 GATT 接口、连接 ID 和远程服务 ID 作为参数。此外,还传递了一个 NULL 值,以表明我们希望所有特征都从第一个开始。如果客户端对特定特征感兴趣,它可以在此字段中传递特征 ID 来指定该特征。一个ESP_GATTC_GET_CHAR_EVT当特征发现了触发事件。此事件用于打印有关特征的信息。

  • 如果特征 ID 与 定义的相同REMOTE_NOTIFY_CHAR_UUID,则客户端注册该特征值的通知。
  • 最后,使用相同的esp_ble_gattc_get_characteristic()函数请求下一个特征,这次,最后一个参数设置为当前特征。这会触发另一个,ESP_GATTC_GET_CHAR_EVT并重复该过程,直到获得所有特征。
 case ESP_GATTC_GET_CHAR_EVT:
     if (p_data->get_char.status != ESP_GATT_OK) {
         break;
     }
     ESP_LOGI(GATTC_TAG, "GET CHAR: conn_id = %x, status %d", p_data->get_char.conn_id, p_data->get_char.status);
     ESP_LOGI(GATTC_TAG, "GET CHAR: srvc_id = %04x, char_id = %04x", p_data->get_char.srvc_id.id.uuid.uuid.uuid16, p_data->get_char.char_id.uuid.uuid.uuid16);

     if (p_data->get_char.char_id.uuid.uuid.uuid16 == REMOTE_NOTIFY_CHAR_UUID) {
         ESP_LOGI(GATTC_TAG, "register notify");
         esp_ble_gattc_register_for_notify(gattc_if, gl_profile_tab[PROFILE_A_APP_ID].remote_bda, &remote_service_id, &p_data->get_char.char_id);
     }

     esp_ble_gattc_get_characteristic(gattc_if, p_data->get_char.conn_id, &remote_service_id, &p_data->get_char.char_id);
     break;

此时,客户端已从远程设备获取了所有特征,并订阅了有关感兴趣特征的通知。每次客户端注册通知时,ESP_GATTC_REG_FOR_NOTIFY_EVT都会触发一个事件。在此示例中,此事件设置为使用该esp_ble_gattc_write_char_descr()函数写入远程设备客户端配置特征 (CCC) 。反过来,此函数用于写入特征描述符。蓝牙规范定义了许多特征描述符,但是,对于本示例,感兴趣的描述符是处理启用通知的描述符,即客户端配置描述符。

6.2 连接到下一个远程设备

  • 为了将此描述符作为参数传递,我们首先将其定义为:
 static esp_gatt_id_t notify_descr_id = {
 .uuid = {
     .len = ESP_UUID_LEN_16,
     .uuid = {.uuid16 = ESP_GATT_UUID_CHAR_CLIENT_CONFIG,},
     },
 .inst_id = 0,
 };

其中ESP_GATT_UUID_CHAR_CLIENT_CONFIG定义为 UUID 以识别 CCC:

#define ESP_GATT_UUID_CHAR_CLIENT_CONFIG            0x2902          /*  Client Characteristic Configuration */

要写入的值为“1”以启用通知。该参数ESP_GATT_WRITE_TYPE_RSP还传递给请求服务器响应写请求,以及ESP_GATT_AUTH_REQ_NONE指示写请求不需要授权的参数:

case ESP_GATTC_REG_FOR_NOTIFY_EVT: {
    if (p_data->reg_for_notify.status != ESP_GATT_OK){
        ESP_LOGE(GATTC_TAG, "reg notify failed, error status =%x", p_data->reg_for_notify.status);
        break;
    }
    uint16_t notify_en = 1;
    ESP_LOGI(GATTC_TAG, "REG FOR NOTIFY: status %d, srvc_id = %04x, char_id = %04x",
            p_data->reg_for_notify.status,
            p_data->reg_for_notify.srvc_id.id.uuid.uuid.uuid16,
            p_data->reg_for_notify.char_id.uuid.uuid.uuid16);

    esp_ble_gattc_write_char_descr(gattc_if,
                                   gl_profile_tab[PROFILE_A_APP_ID].conn_id,
                                   &remote_service_id,
                                   &p_data->reg_for_notify.char_id,
                                   &notify_descr_id,
                                   sizeof(notify_en),
                                   (uint8_t *)&notify_en,
                                   ESP_GATT_WRITE_TYPE_RSP,
                                   ESP_GATT_AUTH_REQ_NONE);
    break;
}
  • 启用通知后,远程设备会发送通知,触发ESP_GATTC_NOTIFY_EVT客户端上的事件。处理此事件以使用以下esp_ble_gattc_write_char()函数写回特征:
 case ESP_GATTC_NOTIFY_EVT:
     ESP_LOGI(GATTC_TAG, "ESP_GATTC_NOTIFY_EVT, Receive notify value:");
     esp_log_buffer_hex(GATTC_TAG, p_data->notify.value, p_data->notify.value_len);
     //write  back
     esp_ble_gattc_write_char(gattc_if,
                             gl_profile_tab[PROFILE_A_APP_ID].conn_id,
                             &remote_service_id,
                             &p_data->notify.char_id,
                             p_data->notify.value_len,
                             p_data->notify.value,
                             ESP_GATT_WRITE_TYPE_RSP,
                             ESP_GATT_AUTH_REQ_NONE);
     break;
  • 如果写入过程得到确认,则远程设备已成功连接并且通信建立且没有错误。立即,写入过程生成一个ESP_GATTC_WRITE_CHAR_EVT事件,在此示例中用于打印信息并连接到第二个远程设备:
 case ESP_GATTC_WRITE_CHAR_EVT:
     if (p_data->write.status != ESP_GATT_OK){
         ESP_LOGE(GATTC_TAG, "write char failed, error status = %x", p_data->write.status);
     }else{
         ESP_LOGI(GATTC_TAG, "write char success");
     }
     //connect the second device
     if (find_device_2){
         esp_ble_gattc_open(gl_profile_tab[PROFILE_B_APP_ID].gattc_if, gl_profile_tab[PROFILE_B_APP_ID].remote_bda, true);
     }
     break;
  • 这将触发由 Profile B 事件处理程序处理的打开事件。此处理程序遵循相同的步骤来搜索服务、获取特征、注册通知并作为第一个设备写入特征。第二个设备的序列也以一个ESP_GATTC_WRITE_CHAR_EVT事件结束,该事件又用于连接到第三个设备:
 case ESP_GATTC_WRITE_CHAR_EVT:
     if (p_data->write.status != ESP_GATT_OK){
         ESP_LOGE(GATTC_TAG, "Write char failed, error status = %x", p_data->write.status);
     }else{
         ESP_LOGI(GATTC_TAG, "Write char success");
     }
     //connect the third device
     if (find_device_3){
         esp_ble_gattc_open(gl_profile_tab[PROFILE_C_APP_ID].gattc_if, gl_profile_tab[PROFILE_C_APP_ID].remote_bda, true);
     }
     break;
  • 第三设备还以与第一和第二设备相同的形式执行相同的配置和通信步骤。成功完成后,所有三个远程设备同时正确连接并无错误地接收通知。

• 由 Leung 写于 2021 年 7 月 14 日

• 参考:GATT 客户端多连接示例演练

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,454评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,553评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,921评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,648评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,770评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,950评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,090评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,817评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,275评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,592评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,724评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,409评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,052评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,815评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,043评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,503评论 2 361
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,627评论 2 350

推荐阅读更多精彩内容