ESP32 学习日志(3)——WIFI-MESH简介


一、WIFI-MESH简介

1.1 概述

ESP-MESH 是一套建立在 Wi-Fi 协议之上的网络协议。ESP-MESH 允许分布在大范围区域内(室内和室外)的大量设备(下文称节点)在同一个 WLAN(无线局域网)中相互连接。ESP-MESH 具有自组网和自修复的特性,也就是说 mesh 网络可以自主地构建和维护。

1.2 与传统WIFI网络区别

传统 Wi-Fi 网络架构

传统 Wi-Fi 网络架构

传统基础设施 Wi-Fi 网络是一个“单点对多点”的网络。这种网络架构的中心节点为接入点 (AP),其他节点 (station) 均与 AP 直接相连。其中,AP 负责各个 station 之间的仲裁和转发,一些 AP 还会通过路由器与外部 IP 网络交换数据。在传统 Wi-Fi 网络架构中,1)由于所有 station 均需与 AP 直接相连,不能距离 AP 太远,因此覆盖区域相对有限;2)受到 AP 容量的限制,因此网络中允许的 station 数量相对有限,很容易超载。

ESP-MESH 网络架构示意图

ESP-MESH 网络架构示意图

ESP-MESH 与传统 Wi-Fi 网络的不同之处在于:网络中的节点不需要连接到中心节点,而是可以与相邻节点连接。各节点均负责相连节点的数据中继。由于无需受限于距离中心节点的位置,所有节点仍可互连,因此 ESP-MESH 网络的覆盖区域更广。类似地,由于不再受限于中心节点的容量限制,ESP-MESH 允许更多节点接入,也不易于超载。

1.3 术语

术语描述
节点任何 属于可以成为 ESP-MESH 网络一部分的设备
根节点网络顶部的节点
子节点如节点 X 连接至节点 Y,且 X 相较 Y 与根节点的距离更远(跨越的连接数量更多),则称 X 为 Y 的子节点。
父节点与子节点对应的概念
后裔节点任何可以从根节点追溯到的节点
兄弟节点连接至同一个父节点的所有节点
连接AP 和 station 之间的传统 Wi-Fi 关联。ESP-MESH 中的节点使用 station 接口与另一个节点的 SoftAP 接口产生关联,进而形成连接。连接包括 Wi-Fi 网络中的身份验证和关联过程。
上行连接从节点到其父节点的连接
下行连接从父节点到其一个子节点的连接
无线 hop源节点和目标节点间无线连接路径中的一部分。单跳 指遍历单个连接的数据包,多跳 指遍历多个连接的数据包。
子网子网指 ESP-MESH 网络的一部分,包括一个节点及其所有后代节点。因此,根节点的子网包括 ESP-MESH 网络中的所有节点。
MAC 地址在 ESP-MESH 网络中用于区别每个节点或路由器的唯一地址
DS分布式系统(外部 IP 网络)

1.4 树型拓扑

ESP-MESH 建立在传统 Wi-Fi 协议之上,可被视为一种将多个独立 Wi-Fi 网络组合为一个单一 WLAN 网络的组网协议。在 Wi-Fi 网络中,station 在任何时候都仅限于与 AP 建立单个连接(上行连接),而 AP 则可以同时连接到多个 station(下行连接)。然而,ESP-MESH 网络则允许节点同时充当 station 和 AP。因此,ESP-MESH 中的节点可以使用 其 SoftAP 接口建立多个下行连接,同时使用 其 station 接口建立一个上行连接。这将自然产生一个由多层父子结构组成的树型网络拓扑结构。

ESP-MESH 树型拓扑

ESP-MESH 树型拓扑

ESP-MESH 是一个多跳网络,也就是说网络中的节点可以通过单跳或多跳向网络中的其他节点传送数据包。因此,ESP-MESH 中的节点不仅传输自己的数据包,而且同时充当其他节点的中继。假设 ESP-MESH 网络中的任意两个节点存在物理层上连接(通过单跳或多跳),则这两个节点可以进行通信。

ESP-MESH 网络中的大小(节点总数)取决于网络中允许的最大层级,以及每个节点可以具有的最大下行连接数。因此,这两个变量可用于配置 ESP-MESH 网络的大小。

1.5 节点类型

ESP-MESH 节点类型

ESP-MESH 节点类型

根节点: 指网络顶部的节点,是 ESP-MESH 网络和外部 IP 网络之间的唯一接口。根节点直接连接至传统的 Wi-Fi 路由器,并在 ESP-MESH 网络的节点和外部 IP 网络之间中继数据包。 ESP-MESH 网络中只能有一个根节点,且根节点的上行连接只能是路由器。如上图所示,节点 A 即为该 ESP-MESH 网络的根节点。

叶子节点: 指不允许拥有任何子节点(即无下行连接)的节点。因此,叶子节点只能传输或接收自己的数据包,但不能转发其他节点的数据包。如果节点处于 ESP-MESH 网络的最大允许层级,则该节点将成为叶子节点。叶子节点不回再产生下行连接,这可以防止节点继续生成下行连接,从而确保网络层级不会超出限制。由于建立下行连接必须使用 SoftAP 接口,因此一些没有 SoftAP 接口的节点(仅有 station 接口)也将被分配为叶子节点。如上图所示,位于网络最外层的 L/M/N 节点即为叶子节点。

中间父节点:既不是属于根节点也不属于叶子节点的节点即为中间父节点。中间父节点必须有且仅有一个上行连接(即一个父节点),但可以具有 0 个或多个下行连接(即 0 个或多个子节点)。因此,中间父节点可以发送和接收自己的数据包,也可以转发其上行和下行连接的数据包。如上图所示,节点 B 到 J 即为中间父节点。 注意,E/F/G/I/J 等没有下行连接的中间父节点并不等同于叶子节点,原因在于这些节点仍允许形成下行连接。

空闲节点:尚未加入网络的节点即为空闲节点。空闲节点将尝试与中间父节点形成上行连接,或者在有条件的情况下(参见 自动根节点选择 )成为一个根节点。如上图所示,K 和 O 节点即为空闲节点。

1.6 路由表

ESP-MESH 网络中的每个节点均会维护自己的路由表,并按路由表将数据包(请见 ESP-MESH 数据包)沿正确的路线发送至正确的目标节点。某个特定节点的路由表将包含 该节点的子网中所有节点的 MAC 地址,也包括该节点自己的 MAC 地址。每个路由表会划分为多个子路由表,与每个子节点的子网对应。

ESP-MESH 路由表示例

ESP-MESH 路由表示例

以上图为例,节点 B 的路由表中将包含节点 B 到节点 I 的 MAC 地址(即相当于节点 B 的子网)。节点 B 的路由表可划分为节点 C 和 G 的子路由表,分别包含节点 C 到节点 F 的 MAC 地址、节点 G 到节点 I 的 MAC 地址。

ESP-MESH 利用路由表来使用以下规则进行转发,确定 ESP-MESH 数据包应根据向上行转发还是向下行转发。

1. 如果数据包的目标 MAC 地址处于当前节点的路由表中且不是当前节点本身,则选择包含目标 MAC 地址的子路由表,并将数据包向下转发给子路由表对应的子节点。

2. 如果数据包的目标 MAC 地址不在当前节点的路由表内,则将数据包向上转发给当前节点的父节点,并重复执行该操作直至数据包达到目标地址。此步骤可重复至根节点(根节点包含整个网络的全部节点)。

二、官方示例应用

ESP-IDF 包含以下 ESP-MESH 示例项目(本文对内部通讯示例做解析):

内部通信示例 internal_communication)展示了如何搭建 ESP-MESH 网络,并让根节点向网络中的每个节点发送数据包。

手动连网示例 manual_networking)展示了如何在禁用自组网功能的情况下使用 ESP-MESH。此示例展示了如何对节点进行编程,以手动扫描潜在父节点的列表,并根据自定义标准选择父节点。

2.1 示例配置

使用官方提供的 Windows (ESP-IDF 工具安装器) 的介绍,安装所有必需工具。安装完成后会生成如下可执行文件。点开后工具安装器会自动配置环境变量,无需额外配置。

ESP-IDF

在此工具中进入IDF安装目录下的\examples\mesh\internal_communication\main目录,此处存放工程代码。

2.1.1 配置菜单

执行idf.py menuconfig

查看项目配置(IDF中项目都是配置好的),其相关配置如下

使用官方提供的 Windows (ESP-IDF 工具安装器) 的介绍,安装所有必需工具。安装完成后会生成如下可执行文件。点开后工具安装器会自动配置环境变量,无需额外配置。

  1. 进入Example Configuration

Example Configuration1
Example Configuration2

channel:信道。

Router SSID:路由器名称。

Router password:路由器密码。

Mesh AP Authentication Mode:MESH验证模式。

Mesh AP Password:MESH AP模式下密码。

Mesh AP Connections:MESH AP模式下最大连接数。

Mesh Max Layer:MESH最大层。

Mesh Routing Table Size:MESH路由表大小。

查看或保存后按ESC退出。

2.2 示例代码

本文档只对mesh_main.c中的MESH功能解析

2.2.1 mesh_main.c

/**===========================================================================

  @file     mesh_main.c
  @brief    本文件用于mesh通讯
  @author   青梅煮久
  @version  r0.1
  @date     2020/11/12
  @license 
----------------------------------------------------------------------------
  Remark: (备注描述)
  	mesh通讯示例。
  	此示例以app_main为入口对设备进行mesh配置,后开启2个任务。
  	任务一:其中担任根节点的设备对其他中间节点(包含根节点自身)发送数据。
  	任务二:其他中间节点(包含根节点自身)实时接收数据。
----------------------------------------------------------------------------
                                History
----------------------------------------------------------------------------
  <Date>     | <Version> | <Author>       | <Description>
-------------|-----------|----------------|---------------------------------
  2020/11/12 | r0.1      | 青梅煮久        | 创建
-------------|-----------|----------------|---------------------------------
             |           |                |
-------------|-----------|----------------|---------------------------------
             |           |                |
-------------|-----------|----------------|---------------------------------
             |           |                |
============================================================================*/

/*********************************************************************
 * INCLUDES
 */
#include <string.h>
#include "esp_wifi.h"
#include "esp_system.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_mesh.h"
#include "esp_mesh_internal.h"
#include "nvs_flash.h"

/*********************************************************************
 * DEFINITIONS
 */
#define RX_SIZE          				(1500)
#define TX_SIZE          				(1460)

/*********************************************************************
 * GLOBAL VARIABLES
 */

/*********************************************************************
 * LOCAL VARIABLES
 */
static const char *MESH_TAG = "mesh_main";
static const uint8_t MESH_ID[6] = {0x77, 0x77, 0x77, 0x77, 0x77, 0x77};
static uint8_t rx_buf[RX_SIZE] = {0};
static bool is_running = true;
static bool is_mesh_connected = false;
static mesh_addr_t mesh_parent_addr;
static int mesh_layer = -1;
static esp_netif_t *netif_sta = NULL;

#if TCP_CLIENT_FUNCTION
static bool isGetIp = false;
#endif 

/*********************************************************************
 * PUBLIC FUNCTIONS
 */
/**
 @brief mesh网络点对点接收数据
 @param arg -[in] 任意参数
 @return 无
*/
void esp_mesh_p2p_rx_main(void *arg)
{
    int recv_count = 0;
    esp_err_t err;
    mesh_addr_t from;
    mesh_data_t data;
    int flag = 0;
    data.data = rx_buf;
    data.size = RX_SIZE;
    is_running = true;

    while(is_running) 
	{
        data.size = RX_SIZE;
        err = esp_mesh_recv(&from, &data, portMAX_DELAY, &flag, NULL, 0);
        if(err != ESP_OK || !data.size) 
		{
            ESP_LOGE(MESH_TAG, "err:0x%x, size:%d", err, data.size);
            continue;
        }
        /* extract send count */
		if(mesh_layer == 1)
		{
			// 具体解析处理
			RootMeshDataAnalysis((char *)data.data, data.size);
		}
		else
		{
			// 具体解析处理
			LeafMeshDataAnalysis((char *)data.data, data.size);
		}
		
        recv_count++;
        if(!(recv_count % 1))
		{
            ESP_LOGW(MESH_TAG,
                     "[#RX:%d][L:%d] parent:"MACSTR", receive from "MACSTR", size:%d, heap:%d, flag:%d[err:0x%x, proto:%d, tos:%d]",
                     recv_count, mesh_layer,
                     MAC2STR(mesh_parent_addr.addr), MAC2STR(from.addr),
                     data.size, esp_get_free_heap_size(), flag, err, data.proto,
                     data.tos);
        }
    }
    vTaskDelete(NULL);
}


/**
 @brief mesh网络点对点发送数据
 @param 无
 @return 无
*/
void esp_mesh_p2p_tx_main(void *arg)
{
    int i;
    esp_err_t err;
    int send_count = 0;
    mesh_addr_t route_table[CONFIG_MESH_ROUTE_TABLE_SIZE];
    int route_table_size = 0;
    mesh_data_t data;
    data.data = tx_buf;
    data.size = sizeof(tx_buf);
    data.proto = MESH_PROTO_BIN;
    data.tos = MESH_TOS_P2P;
    is_running = true;

    while (is_running) 
	{
        /* non-root do nothing but print */
        if (!esp_mesh_is_root()) 
		{
			// 非根节点只进行打印
            ESP_LOGI(MESH_TAG, "layer:%d, rtableSize:%d, %s", mesh_layer,
                     esp_mesh_get_routing_table_size(),
                     (is_mesh_connected && esp_mesh_is_root()) ? "ROOT" : is_mesh_connected ? "NODE" : "DISCONNECT");
            vTaskDelay(10 * 1000 / portTICK_RATE_MS);
            continue;
        }
		// 节点查询自身路由表
        esp_mesh_get_routing_table((mesh_addr_t *) &route_table,
                                   CONFIG_MESH_ROUTE_TABLE_SIZE * 6, &route_table_size);
        if (send_count && !(send_count % 100))
		{
            ESP_LOGI(MESH_TAG, "size:%d/%d,send_count:%d", route_table_size,
                     esp_mesh_get_routing_table_size(), send_count);
        }

        send_count++;

        for (i = 0; i < route_table_size; i++) 
		{
			// 给每个节点发送数据,地址为NULL就是给根节点发。
			// 自身节点在自身路由表[0]中,如是根节点则生成根节点的地址和路由表中[0]记录的不一致,路由表中最后一个字节小1 
            err = esp_mesh_send(&route_table[i], &data, MESH_DATA_P2P, NULL, 0);
            if (err) 
			{
                ESP_LOGE(MESH_TAG,
                         "[ROOT-2-UNICAST:%d][L:%d]parent:"MACSTR" to "MACSTR", heap:%d[err:0x%x, proto:%d, tos:%d]",
                         send_count, mesh_layer, MAC2STR(mesh_parent_addr.addr),
                         MAC2STR(route_table[i].addr), esp_get_free_heap_size(),
                         err, data.proto, data.tos);
            } 
			else if (!(send_count % 100)) 
			{
                ESP_LOGW(MESH_TAG,
                         "[ROOT-2-UNICAST:%d][L:%d][rtableSize:%d]parent:"MACSTR" to "MACSTR", heap:%d[err:0x%x, proto:%d, tos:%d]",
                         send_count, mesh_layer,
                         esp_mesh_get_routing_table_size(),
                         MAC2STR(mesh_parent_addr.addr),
                         MAC2STR(route_table[i].addr), esp_get_free_heap_size(),
                         err, data.proto, data.tos);
            }
        }
        /* if route_table_size is less than 10, add delay to avoid watchdog in this task. */
        if (route_table_size < 10) 
		{
            vTaskDelay(1 * 1000 / portTICK_RATE_MS);
        }
    }
}


/**
 @brief mesh网络点对点任务启动
 @param 无
 @return 无
*/
esp_err_t esp_mesh_comm_p2p_start(void)
{
    static bool is_comm_p2p_started = false;
    if(!is_comm_p2p_started)
	{
        is_comm_p2p_started = true;
        xTaskCreate(esp_mesh_p2p_rx_main, "MPRX", 8192, NULL, 5, NULL);
    }
    return ESP_OK;
}

#if TCP_CLIENT_FUNCTION
/**
 @brief 等待分配IP,获取IP后开启TCP客户端创建任务
 @param arg -[in] 任意参数
 @return 无
*/
void EspWaitForIpTask(void *arg)
{
	while(1)
	{
		if(isGetIp)
		{
			TcpClientStart();
			break;
		}
	}
	vTaskDelete(NULL);
}

/**
 @brief 等待分配IP任务启动
 @param 无
 @return 无
*/
void EspWaitForIpStart(void)
{
	xTaskCreate(EspWaitForIpTask, "WFIP", 1024, NULL, 5, NULL);
}
#endif

/**
 @brief mesh网络事件回调
 @param arg -[in] 任意参数
 @param event_base -[in] 基本事件
 @param event_id -[in] 事件id
 @param event_data -[in] 事件数据
 @return 无
*/
void mesh_event_handler(void *arg, esp_event_base_t event_base,
                        int32_t event_id, void *event_data)
{
    mesh_addr_t id = {0};
    static uint8_t last_layer = 0;

    switch(event_id) 
	{
    	case MESH_EVENT_STARTED: 
		{
        	esp_mesh_get_id(&id);
        	ESP_LOGD(MESH_TAG, "<MESH_EVENT_MESH_STARTED>ID:"MACSTR"", MAC2STR(id.addr));
        	is_mesh_connected = false;
        	mesh_layer = esp_mesh_get_layer();
    	}
    	break;
	    case MESH_EVENT_STOPPED: 
		{
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_STOPPED>");
	        is_mesh_connected = false;
	        mesh_layer = esp_mesh_get_layer();
	    }
	    break;
	    case MESH_EVENT_CHILD_CONNECTED: 
		{
	        mesh_event_child_connected_t *child_connected = (mesh_event_child_connected_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_CHILD_CONNECTED>aid:%d, "MACSTR"",
	                 child_connected->aid,
	                 MAC2STR(child_connected->mac));
	    }
	    break;
	    case MESH_EVENT_CHILD_DISCONNECTED:
		{
	        mesh_event_child_disconnected_t *child_disconnected = (mesh_event_child_disconnected_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_CHILD_DISCONNECTED>aid:%d, "MACSTR"",
	                 child_disconnected->aid,
	                 MAC2STR(child_disconnected->mac));
	    }
	    break;
	    case MESH_EVENT_ROUTING_TABLE_ADD:
		{
	        mesh_event_routing_table_change_t *routing_table = (mesh_event_routing_table_change_t *)event_data;
	        ESP_LOGW(MESH_TAG, "<MESH_EVENT_ROUTING_TABLE_ADD>add %d, new:%d",
	                 routing_table->rt_size_change,
	                 routing_table->rt_size_new);
	    }
	    break;
	    case MESH_EVENT_ROUTING_TABLE_REMOVE: 
		{
	        mesh_event_routing_table_change_t *routing_table = (mesh_event_routing_table_change_t *)event_data;
	        ESP_LOGW(MESH_TAG, "<MESH_EVENT_ROUTING_TABLE_REMOVE>remove %d, new:%d",
	                 routing_table->rt_size_change,
	                 routing_table->rt_size_new);
	    }
	    break;
	    case MESH_EVENT_NO_PARENT_FOUND:
		{
	        mesh_event_no_parent_found_t *no_parent = (mesh_event_no_parent_found_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_NO_PARENT_FOUND>scan times:%d",
	                 no_parent->scan_times);
	    }
	    /* TODO handler for the failure */
	    break;
	    case MESH_EVENT_PARENT_CONNECTED:
		{
	        mesh_event_connected_t *connected = (mesh_event_connected_t *)event_data;
	        esp_mesh_get_id(&id);
	        mesh_layer = connected->self_layer;
	        memcpy(&mesh_parent_addr.addr, connected->connected.bssid, 6);
	        ESP_LOGI(MESH_TAG,
	                 "<MESH_EVENT_PARENT_CONNECTED>layer:%d-->%d, parent:"MACSTR"%s, ID:"MACSTR"",
	                 last_layer, mesh_layer, MAC2STR(mesh_parent_addr.addr),
	                 esp_mesh_is_root() ? "<ROOT>" :
	                 (mesh_layer == 2) ? "<layer2>" : "", MAC2STR(id.addr));
	        last_layer = mesh_layer;
	        is_mesh_connected = true;
	        if(esp_mesh_is_root()) 
			{
	            esp_netif_dhcpc_start(netif_sta);
	        }
			else
			{
			}
	        esp_mesh_comm_p2p_start();
	    }
	    break;
	    case MESH_EVENT_PARENT_DISCONNECTED:
		{
	        mesh_event_disconnected_t *disconnected = (mesh_event_disconnected_t *)event_data;
	        ESP_LOGI(MESH_TAG,
	                 "<MESH_EVENT_PARENT_DISCONNECTED>reason:%d",
	                 disconnected->reason);
	        is_mesh_connected = false;
	        mesh_layer = esp_mesh_get_layer();

	    }
	    break;
	    case MESH_EVENT_LAYER_CHANGE: 
		{
	        mesh_event_layer_change_t *layer_change = (mesh_event_layer_change_t *)event_data;
	        mesh_layer = layer_change->new_layer;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_LAYER_CHANGE>layer:%d-->%d%s",
	                 last_layer, mesh_layer,
	                 esp_mesh_is_root() ? "<ROOT>" :
	                 (mesh_layer == 2) ? "<layer2>" : "");
	        last_layer = mesh_layer;
	    }
	    break;
	    case MESH_EVENT_ROOT_ADDRESS: 
		{
	        mesh_event_root_address_t *root_addr = (mesh_event_root_address_t *)event_data;
			
			esp_mesh_get_id(&id);
			
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_ROOT_ADDRESS>root address:"MACSTR", ID:"MACSTR"",
	                 MAC2STR(root_addr->addr), MAC2STR(id.addr));

			// 此处登记自身在路由表中的地址
			int route_table_size = 0;
			mesh_addr_t route_table[CONFIG_MESH_ROUTE_TABLE_SIZE];
			esp_mesh_get_routing_table((mesh_addr_t *) &route_table, CONFIG_MESH_ROUTE_TABLE_SIZE * 6, &route_table_size);
			
			memcpy(g_meshAddr, route_table[0].addr, 6);

			ESP_LOGI(MESH_TAG, "<MESH_EVENT_ROOT_ADDRESS>meshAddr:"MACSTR"", MAC2STR(g_meshAddr));

			// 此处设置默认设备ID
			memcpy(g_deviceId, g_meshAddr, 6);
	    }
	    break;
	    case MESH_EVENT_VOTE_STARTED: 
		{
	        mesh_event_vote_started_t *vote_started = (mesh_event_vote_started_t *)event_data;
	        ESP_LOGI(MESH_TAG,
	                 "<MESH_EVENT_VOTE_STARTED>attempts:%d, reason:%d, rc_addr:"MACSTR"",
	                 vote_started->attempts,
	                 vote_started->reason,
	                 MAC2STR(vote_started->rc_addr.addr));
	    }
	    break;
	    case MESH_EVENT_VOTE_STOPPED: 
		{
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_VOTE_STOPPED>");
	        break;
	    }
	    case MESH_EVENT_ROOT_SWITCH_REQ: 
		{
	        mesh_event_root_switch_req_t *switch_req = (mesh_event_root_switch_req_t *)event_data;
	        ESP_LOGI(MESH_TAG,
	                 "<MESH_EVENT_ROOT_SWITCH_REQ>reason:%d, rc_addr:"MACSTR"",
	                 switch_req->reason,
	                 MAC2STR( switch_req->rc_addr.addr));
	    }
	    break;
	    case MESH_EVENT_ROOT_SWITCH_ACK: 
		{
	        /* new root */
	        mesh_layer = esp_mesh_get_layer();
	        esp_mesh_get_parent_bssid(&mesh_parent_addr);
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_ROOT_SWITCH_ACK>layer:%d, parent:"MACSTR"", mesh_layer, MAC2STR(mesh_parent_addr.addr));
	    }
	    break;
	    case MESH_EVENT_TODS_STATE: 
		{
	        mesh_event_toDS_state_t *toDs_state = (mesh_event_toDS_state_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_TODS_REACHABLE>state:%d", *toDs_state);
	    }
	    break;
	    case MESH_EVENT_ROOT_FIXED: 
		{
	        mesh_event_root_fixed_t *root_fixed = (mesh_event_root_fixed_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_ROOT_FIXED>%s",
	                 root_fixed->is_fixed ? "fixed" : "not fixed");
	    }
	    break;
	    case MESH_EVENT_ROOT_ASKED_YIELD: 
		{
	        mesh_event_root_conflict_t *root_conflict = (mesh_event_root_conflict_t *)event_data;
	        ESP_LOGI(MESH_TAG,
	                 "<MESH_EVENT_ROOT_ASKED_YIELD>"MACSTR", rssi:%d, capacity:%d",
	                 MAC2STR(root_conflict->addr),
	                 root_conflict->rssi,
	                 root_conflict->capacity);
	    }
	    break;
	    case MESH_EVENT_CHANNEL_SWITCH: 
		{
	        mesh_event_channel_switch_t *channel_switch = (mesh_event_channel_switch_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_CHANNEL_SWITCH>new channel:%d", channel_switch->channel);
	    }
	    break;
	    case MESH_EVENT_SCAN_DONE: 
		{
	        mesh_event_scan_done_t *scan_done = (mesh_event_scan_done_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_SCAN_DONE>number:%d",
	                 scan_done->number);
	    }
	    break;
	    case MESH_EVENT_NETWORK_STATE: 
		{
	        mesh_event_network_state_t *network_state = (mesh_event_network_state_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_NETWORK_STATE>is_rootless:%d",
	                 network_state->is_rootless);
	    }
	    break;
	    case MESH_EVENT_STOP_RECONNECTION: 
		{
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_STOP_RECONNECTION>");
	    }
	    break;
	    case MESH_EVENT_FIND_NETWORK: 
		{
	        mesh_event_find_network_t *find_network = (mesh_event_find_network_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_FIND_NETWORK>new channel:%d, router BSSID:"MACSTR"",
	                 find_network->channel, MAC2STR(find_network->router_bssid));
	    }
	    break;
	    case MESH_EVENT_ROUTER_SWITCH: 
		{
	        mesh_event_router_switch_t *router_switch = (mesh_event_router_switch_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_ROUTER_SWITCH>new router:%s, channel:%d, "MACSTR"",
	                 router_switch->ssid, router_switch->channel, MAC2STR(router_switch->bssid));
	    }
	    break;
	    default:
	        ESP_LOGI(MESH_TAG, "unknown id:%d", event_id);
	    break;
	}
}

/**
 @brief IP事件回调
 @param arg -[in] 任意参数
 @param event_base -[in] 基本事件
 @param event_id -[in] 事件id
 @param event_data -[in] 事件数据
 @return 无
*/
void ip_event_handler(void *arg, esp_event_base_t event_base,
                      int32_t event_id, void *event_data)
{
    ip_event_got_ip_t *event = (ip_event_got_ip_t *) event_data;
    ESP_LOGI(MESH_TAG, "<IP_EVENT_STA_GOT_IP>IP:" IPSTR, IP2STR(&event->ip_info.ip));
}

/**
 @brief 程序入口
 @param 无
 @return 无
*/
void app_main(void)
{
    /* 初始化NVS */
    ESP_ERROR_CHECK(nvs_flash_init());
    /* 初始化底层TCP/IP堆栈        */
    ESP_ERROR_CHECK(esp_netif_init());
	
    /* 事件初始化*/
    ESP_ERROR_CHECK(esp_event_loop_create_default());
	/* 为 Mesh 创建STA和AP模式网络接口(仅保存STA接口供进一步处理),并关闭DHCP服务器和客户端(DHCP客户端只有设备升根节点才启用) */
	ESP_ERROR_CHECK(esp_netif_create_default_wifi_mesh_netifs(&netif_sta, NULL));
	
    /* Wi-Fi 初始化 */
    wifi_init_config_t config = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&config));
	/* 注册 IP 事件处理程序 */
    ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &ip_event_handler, NULL));
    ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_FLASH));
    ESP_ERROR_CHECK(esp_wifi_start());
	
    /* Mesh 初始化 */
    ESP_ERROR_CHECK(esp_mesh_init());
	/* 注册 Mesh 事件处理程序 */
    ESP_ERROR_CHECK(esp_event_handler_register(MESH_EVENT, ESP_EVENT_ANY_ID, &mesh_event_handler, NULL));
	/* 设置 Mesh 最大层 */
    ESP_ERROR_CHECK(esp_mesh_set_max_layer(CONFIG_MESH_MAX_LAYER));
	ESP_LOGI(MESH_TAG, "CONFIG_MESH_MAX_LAYER:%d", CONFIG_MESH_MAX_LAYER);
	/* 设置 Mesh 根节点推举百分比(只有达到此阈值才可成为根) */
    ESP_ERROR_CHECK(esp_mesh_set_vote_percentage(1));
	/* 设置 Mesh AP模式关联过期时间(在AP模式下此时间内未收到某个子节点的任何数据,Mesh将子节点置为不活跃的并分离它) */
    ESP_ERROR_CHECK(esp_mesh_set_ap_assoc_expire(10));

	/* Mesh 配置 */
	/* 默认启用 Mesh IE 加密 */
    mesh_cfg_t cfg = MESH_INIT_CONFIG_DEFAULT();
    /* Mesh ID */
    memcpy((uint8_t *) &cfg.mesh_id, MESH_ID, 6);
	
    /* 路由器 */
	/* 信道(需与路由器信道匹配)*/
    cfg.channel = CONFIG_MESH_CHANNEL;
    cfg.router.ssid_len = strlen(CONFIG_MESH_ROUTER_SSID);
    // 这里为了方便直接填写了要接入WIFI的SSID和密码,长度自己算好,这里统一显示为8
    memcpy((uint8_t *) &cfg.router.ssid, "名称", 8);
    memcpy((uint8_t *) &cfg.router.password, "密码", 8);
    /* Mesh softAP */
    ESP_ERROR_CHECK(esp_mesh_set_ap_authmode(CONFIG_MESH_AP_AUTHMODE));
    cfg.mesh_ap.max_connection = CONFIG_MESH_AP_CONNECTIONS;
	ESP_LOGI(MESH_TAG, "CONFIG_MESH_AP_CONNECTIONS:%d", CONFIG_MESH_AP_CONNECTIONS);
    memcpy((uint8_t *) &cfg.mesh_ap.password, CONFIG_MESH_AP_PASSWD, strlen(CONFIG_MESH_AP_PASSWD));
	ESP_LOGI(MESH_TAG, "CONFIG_MESH_AP_PASSWD:%s", CONFIG_MESH_AP_PASSWD);
    ESP_ERROR_CHECK(esp_mesh_set_config(&cfg));
	
    /* Mesh 启动 */
    ESP_ERROR_CHECK(esp_mesh_start());
    ESP_LOGI(MESH_TAG, "mesh starts successfully, heap:%d, %s\n",  esp_get_free_heap_size(),
             esp_mesh_is_root_fixed() ? "root fixed" : "root not fixed");
	
}

三、流程说明

Created with Raphaël 2.2.0 开始 MESH前准备 MESH初始化 MESH启动 结束

3.1 MESH配置前准备

ESP-MESH 在正常启动前必须先初始化 LwIP 和 Wi-Fi 软件栈。下方代码展示了 ESP-MESH 在开始自身初始化前必须完成的步骤。

  1. 初始化非易失性存储库
ESP_ERROR_CHECK(nvs_flash_init());
  1. 初始化底层TCP/IP需要用到的堆栈
ESP_ERROR_CHECK(esp_netif_init());
  1. 事件初始化

事件循环库是esp提供的一种事件处理方法,而默认事件循环是用于系统事件(例如WiFi事件)的特殊循环类型,这里创建一个默认事件循环用以处理mesh连接事件

ESP_ERROR_CHECK(esp_event_loop_create_default());
  1. 为 Mesh 创建STA和AP模式网络接口

为MESH创建STA和AP模式网络接口(仅保存STA接口供进一步处理),并关闭DHCP服务器和客户端(DHCP客户端只有设备升根节点才启用)

ESP_ERROR_CHECK(esp_netif_create_default_wifi_mesh_netifs(&netif_sta, NULL));
  1. WIFI初始化
/* Wi-Fi 初始化 */
wifi_init_config_t config = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&config));
/* 注册 IP 事件处理程序 */
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &ip_event_handler, NULL));
ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_FLASH));
ESP_ERROR_CHECK(esp_wifi_start());

/**
 @brief IP事件回调
 @param arg -[in] 任意参数
 @param event_base -[in] 基本事件
 @param event_id -[in] 事件id
 @param event_data -[in] 事件数据
 @return 无
*/
void ip_event_handler(void *arg, esp_event_base_t event_base,
                      int32_t event_id, void *event_data)
{
    ip_event_got_ip_t *event = (ip_event_got_ip_t *) event_data;
    ESP_LOGI(MESH_TAG, "<IP_EVENT_STA_GOT_IP>IP:" IPSTR, IP2STR(&event->ip_info.ip));
}

3.2 初始化MESH

  1. 初始化MESH
ESP_ERROR_CHECK(esp_mesh_init());
  1. 注册MESH事件处理程序
ESP_ERROR_CHECK(esp_event_handler_register(MESH_EVENT, ESP_EVENT_ANY_ID, &mesh_event_handler, NULL));

/**
 @brief mesh网络事件回调
 @param arg -[in] 任意参数
 @param event_base -[in] 基本事件
 @param event_id -[in] 事件id
 @param event_data -[in] 事件数据
 @return 无
*/
void mesh_event_handler(void *arg, esp_event_base_t event_base,
                        int32_t event_id, void *event_data)
{
    mesh_addr_t id = {0};
    static uint8_t last_layer = 0;

    switch(event_id) 
	{
    	case MESH_EVENT_STARTED: 
		{
        	esp_mesh_get_id(&id);
        	ESP_LOGD(MESH_TAG, "<MESH_EVENT_MESH_STARTED>ID:"MACSTR"", MAC2STR(id.addr));
        	is_mesh_connected = false;
        	mesh_layer = esp_mesh_get_layer();
    	}
    	break;
	    case MESH_EVENT_STOPPED: 
		{
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_STOPPED>");
	        is_mesh_connected = false;
	        mesh_layer = esp_mesh_get_layer();
	    }
	    break;
	    case MESH_EVENT_CHILD_CONNECTED: 
		{
	        mesh_event_child_connected_t *child_connected = (mesh_event_child_connected_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_CHILD_CONNECTED>aid:%d, "MACSTR"",
	                 child_connected->aid,
	                 MAC2STR(child_connected->mac));
	    }
	    break;
	    case MESH_EVENT_CHILD_DISCONNECTED:
		{
	        mesh_event_child_disconnected_t *child_disconnected = (mesh_event_child_disconnected_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_CHILD_DISCONNECTED>aid:%d, "MACSTR"",
	                 child_disconnected->aid,
	                 MAC2STR(child_disconnected->mac));
	    }
	    break;
	    case MESH_EVENT_ROUTING_TABLE_ADD:
		{
	        mesh_event_routing_table_change_t *routing_table = (mesh_event_routing_table_change_t *)event_data;
	        ESP_LOGW(MESH_TAG, "<MESH_EVENT_ROUTING_TABLE_ADD>add %d, new:%d",
	                 routing_table->rt_size_change,
	                 routing_table->rt_size_new);
	    }
	    break;
	    case MESH_EVENT_ROUTING_TABLE_REMOVE: 
		{
	        mesh_event_routing_table_change_t *routing_table = (mesh_event_routing_table_change_t *)event_data;
	        ESP_LOGW(MESH_TAG, "<MESH_EVENT_ROUTING_TABLE_REMOVE>remove %d, new:%d",
	                 routing_table->rt_size_change,
	                 routing_table->rt_size_new);
	    }
	    break;
	    case MESH_EVENT_NO_PARENT_FOUND:
		{
	        mesh_event_no_parent_found_t *no_parent = (mesh_event_no_parent_found_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_NO_PARENT_FOUND>scan times:%d",
	                 no_parent->scan_times);
	    }
	    /* TODO handler for the failure */
	    break;
	    case MESH_EVENT_PARENT_CONNECTED:
		{
	        mesh_event_connected_t *connected = (mesh_event_connected_t *)event_data;
	        esp_mesh_get_id(&id);
	        mesh_layer = connected->self_layer;
	        memcpy(&mesh_parent_addr.addr, connected->connected.bssid, 6);
	        ESP_LOGI(MESH_TAG,
	                 "<MESH_EVENT_PARENT_CONNECTED>layer:%d-->%d, parent:"MACSTR"%s, ID:"MACSTR"",
	                 last_layer, mesh_layer, MAC2STR(mesh_parent_addr.addr),
	                 esp_mesh_is_root() ? "<ROOT>" :
	                 (mesh_layer == 2) ? "<layer2>" : "", MAC2STR(id.addr));
	        last_layer = mesh_layer;
	        is_mesh_connected = true;
	        if(esp_mesh_is_root()) 
			{
				// 根节点需要在这里开启DHCP获取IP
	            esp_netif_dhcpc_start(netif_sta);
	        }
			else
			{
			}
			// 开启2个任务
	        esp_mesh_comm_p2p_start();
			   
	    }
	    break;
	    case MESH_EVENT_PARENT_DISCONNECTED:
		{
	        mesh_event_disconnected_t *disconnected = (mesh_event_disconnected_t *)event_data;
	        ESP_LOGI(MESH_TAG,
	                 "<MESH_EVENT_PARENT_DISCONNECTED>reason:%d",
	                 disconnected->reason);
	        is_mesh_connected = false;
	        mesh_layer = esp_mesh_get_layer();

	    }
	    break;
	    case MESH_EVENT_LAYER_CHANGE: 
		{
	        mesh_event_layer_change_t *layer_change = (mesh_event_layer_change_t *)event_data;
	        mesh_layer = layer_change->new_layer;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_LAYER_CHANGE>layer:%d-->%d%s",
	                 last_layer, mesh_layer,
	                 esp_mesh_is_root() ? "<ROOT>" :
	                 (mesh_layer == 2) ? "<layer2>" : "");
	        last_layer = mesh_layer;

			if(mesh_layer == 1)
			{
				// 这里1是根节点,意味着节点此时变化为根节点
				// 此处可以添加节点切换的用户自定义功能 
			}
			else
			{
			}
	    }
	    break;
	    case MESH_EVENT_ROOT_ADDRESS: 
		{
	        mesh_event_root_address_t *root_addr = (mesh_event_root_address_t *)event_data;
			
			esp_mesh_get_id(&id);
			
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_ROOT_ADDRESS>root address:"MACSTR", ID:"MACSTR"",
	                 MAC2STR(root_addr->addr), MAC2STR(id.addr));

			// 此处登记自身在路由表中的地址
			int route_table_size = 0;
			mesh_addr_t route_table[CONFIG_MESH_ROUTE_TABLE_SIZE];
			esp_mesh_get_routing_table((mesh_addr_t *) &route_table, CONFIG_MESH_ROUTE_TABLE_SIZE * 6, &route_table_size);
			
			memcpy(g_meshAddr, route_table[0].addr, 6);

			ESP_LOGI(MESH_TAG, "<MESH_EVENT_ROOT_ADDRESS>meshAddr:"MACSTR"", MAC2STR(g_meshAddr));

			// 此处设置默认设备ID
			memcpy(g_deviceId, g_meshAddr, 6);
	    }
	    break;
	    case MESH_EVENT_VOTE_STARTED: 
		{
	        mesh_event_vote_started_t *vote_started = (mesh_event_vote_started_t *)event_data;
	        ESP_LOGI(MESH_TAG,
	                 "<MESH_EVENT_VOTE_STARTED>attempts:%d, reason:%d, rc_addr:"MACSTR"",
	                 vote_started->attempts,
	                 vote_started->reason,
	                 MAC2STR(vote_started->rc_addr.addr));
	    }
	    break;
	    case MESH_EVENT_VOTE_STOPPED: 
		{
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_VOTE_STOPPED>");
	        break;
	    }
	    case MESH_EVENT_ROOT_SWITCH_REQ: 
		{
	        mesh_event_root_switch_req_t *switch_req = (mesh_event_root_switch_req_t *)event_data;
	        ESP_LOGI(MESH_TAG,
	                 "<MESH_EVENT_ROOT_SWITCH_REQ>reason:%d, rc_addr:"MACSTR"",
	                 switch_req->reason,
	                 MAC2STR( switch_req->rc_addr.addr));
	    }
	    break;
	    case MESH_EVENT_ROOT_SWITCH_ACK: 
		{
	        /* new root */
	        mesh_layer = esp_mesh_get_layer();
	        esp_mesh_get_parent_bssid(&mesh_parent_addr);
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_ROOT_SWITCH_ACK>layer:%d, parent:"MACSTR"", mesh_layer, MAC2STR(mesh_parent_addr.addr));
	    }
	    break;
	    case MESH_EVENT_TODS_STATE: 
		{
	        mesh_event_toDS_state_t *toDs_state = (mesh_event_toDS_state_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_TODS_REACHABLE>state:%d", *toDs_state);
	    }
	    break;
	    case MESH_EVENT_ROOT_FIXED: 
		{
	        mesh_event_root_fixed_t *root_fixed = (mesh_event_root_fixed_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_ROOT_FIXED>%s",
	                 root_fixed->is_fixed ? "fixed" : "not fixed");
	    }
	    break;
	    case MESH_EVENT_ROOT_ASKED_YIELD: 
		{
	        mesh_event_root_conflict_t *root_conflict = (mesh_event_root_conflict_t *)event_data;
	        ESP_LOGI(MESH_TAG,
	                 "<MESH_EVENT_ROOT_ASKED_YIELD>"MACSTR", rssi:%d, capacity:%d",
	                 MAC2STR(root_conflict->addr),
	                 root_conflict->rssi,
	                 root_conflict->capacity);
	    }
	    break;
	    case MESH_EVENT_CHANNEL_SWITCH: 
		{
	        mesh_event_channel_switch_t *channel_switch = (mesh_event_channel_switch_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_CHANNEL_SWITCH>new channel:%d", channel_switch->channel);
	    }
	    break;
	    case MESH_EVENT_SCAN_DONE: 
		{
	        mesh_event_scan_done_t *scan_done = (mesh_event_scan_done_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_SCAN_DONE>number:%d",
	                 scan_done->number);
	    }
	    break;
	    case MESH_EVENT_NETWORK_STATE: 
		{
	        mesh_event_network_state_t *network_state = (mesh_event_network_state_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_NETWORK_STATE>is_rootless:%d",
	                 network_state->is_rootless);
	    }
	    break;
	    case MESH_EVENT_STOP_RECONNECTION: 
		{
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_STOP_RECONNECTION>");
	    }
	    break;
	    case MESH_EVENT_FIND_NETWORK: 
		{
	        mesh_event_find_network_t *find_network = (mesh_event_find_network_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_FIND_NETWORK>new channel:%d, router BSSID:"MACSTR"",
	                 find_network->channel, MAC2STR(find_network->router_bssid));
	    }
	    break;
	    case MESH_EVENT_ROUTER_SWITCH: 
		{
	        mesh_event_router_switch_t *router_switch = (mesh_event_router_switch_t *)event_data;
	        ESP_LOGI(MESH_TAG, "<MESH_EVENT_ROUTER_SWITCH>new router:%s, channel:%d, "MACSTR"",
	                 router_switch->ssid, router_switch->channel, MAC2STR(router_switch->bssid));
	    }
	    break;
	    default:
	        ESP_LOGI(MESH_TAG, "unknown id:%d", event_id);
	    break;
	}
}

  1. 配置MESH网络
/* 设置 Mesh 最大层 */
ESP_ERROR_CHECK(esp_mesh_set_max_layer(CONFIG_MESH_MAX_LAYER));
ESP_LOGI(MESH_TAG, "CONFIG_MESH_MAX_LAYER:%d", CONFIG_MESH_MAX_LAYER);
/* 设置 Mesh 根节点推举百分比(只有达到此阈值才可成为根) */
ESP_ERROR_CHECK(esp_mesh_set_vote_percentage(1));
/* 设置 Mesh AP模式关联过期时间(在AP模式下此时间内未收到某个子节点的任何数据,Mesh将子节点置为不活跃的并分离它) */
ESP_ERROR_CHECK(esp_mesh_set_ap_assoc_expire(10));

/* Mesh 配置 */
/* 默认启用 Mesh IE 加密 */
mesh_cfg_t cfg = MESH_INIT_CONFIG_DEFAULT();
/* Mesh ID */
memcpy((uint8_t *) &cfg.mesh_id, MESH_ID, 6);
/* 路由器 */
/* 信道(需与路由器信道匹配)*/
cfg.channel = CONFIG_MESH_CHANNEL;
cfg.router.ssid_len = strlen(CONFIG_MESH_ROUTER_SSID);
memcpy((uint8_t *) &cfg.router.ssid, "路由器名称", 8);
memcpy((uint8_t *) &cfg.router.password, "密码", 8);
/* Mesh softAP */
ESP_ERROR_CHECK(esp_mesh_set_ap_authmode(CONFIG_MESH_AP_AUTHMODE));
cfg.mesh_ap.max_connection = CONFIG_MESH_AP_CONNECTIONS;
ESP_LOGI(MESH_TAG, "CONFIG_MESH_AP_CONNECTIONS:%d", CONFIG_MESH_AP_CONNECTIONS);
memcpy((uint8_t *) &cfg.mesh_ap.password, CONFIG_MESH_AP_PASSWD, strlen(CONFIG_MESH_AP_PASSWD));
ESP_LOGI(MESH_TAG, "CONFIG_MESH_AP_PASSWD:%s", CONFIG_MESH_AP_PASSWD);
ESP_ERROR_CHECK(esp_mesh_set_config(&cfg));

3.3 MESH启动

/* Mesh 启动 */
ESP_ERROR_CHECK(esp_mesh_start());
ESP_LOGI(MESH_TAG, "mesh starts successfully, heap:%d, %s\n",  esp_get_free_heap_size(),
             esp_mesh_is_root_fixed() ? "root fixed" : "root not fixed");

3.4 MESH关闭(本示例中未启用)

ESP_ERROR_CHECK(esp_mesh_stop());

• 由 青梅煮久 写于 2020 年 12 月 03 日

• 参考:

ESP-MESH 编程指南 - ESP32 - — ESP-IDF 编程指南 latest 文档

ESP-MESH - ESP32 - — ESP-IDF 编程指南 latest 文档

  • 4
    点赞
  • 42
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值