AT
工程:https://github.com/espressif/esp-at
AT
文档:https://docs.espressif.com/projects/esp-at/zh_CN/latest/
一. 简介
虽然 ESP-AT
内部已经集成了很多指令, 比如 Wi-Fi
, BT
, BLE
, IP
等等, 但是同时也支持客户进行二次开发, 定义客户自己的命令.
本文主要介绍客户如何自定义 AT
命令.
1.1 ESP-AT 命令的四种格式
ESP-AT
命令包含 4 种命令格式:
- Test Command
- 示例: AT+<x>=?
- 用途: 查询 Set Command 的各个参数以及参数的范围
- Query Command
- 示例: AT+<x>?
- 用途: 查询命令, 可以返回当前参数的值, 也可以返回其他一些想要得到的信息
- Set Command
- 示例: AT+<x>=<…>
- 用途: 设置命令, 向AT输入一些参数, 执行相应的操作
- Execute Command
- 示例: AT+<x>
- 用途: 执行指令, 该指令不带参数
1.2 如何开始自定义一组 AT 命令
首先, 我们看一下 ESP-AT
命令结构体的定义:
typedef struct {
char *at_cmdName; /*!< at command name */
uint8_t (*at_testCmd)(uint8_t *cmd_name); /*!< Test Command function pointer */
uint8_t (*at_queryCmd)(uint8_t *cmd_name); /*!< Query Command function pointer */
uint8_t (*at_setupCmd)(uint8_t para_num); /*!< Setup Command function pointer */
uint8_t (*at_exeCmd)(uint8_t *cmd_name); /*!< Execute Command function pointer */
} esp_at_cmd_struct;
这个结构体中包含 5 个元素, 第一个是个字符串指针, 是 AT 命令的名字, AT 命令的名字有一定的格式要求, 都是+
开始. 后面跟着四个函数参数指针, 分别对应上面提到的四种命令.
现在我们首先举个简单的例子, 定义一个命令, 用来输出Hello word
.
定义命令:
static esp_at_cmd_struct at_example_cmd[] = {
{"+EXAMPLE", NULL, NULL, NULL, NULL},
};
这样, 这条命令就定义好了, 命令的名字叫 +EXAMPLE
, 实际用起来的时候,用户输入的命令就是这个样子:
- AT+EXAMPLE=?
- AT+EXAMPLE?
- AT+EXAMPLE=<param_1>,<param_2>,<param_3>…
- AT+EXAMPLE
当然, 仅仅这样, 还是远远不够的, 要想真的用起来, 至少还少 2 个步骤, 首先就是注册这组命令, 其次就是添加命令的具体实现.
注册命令:
注册自定义命令数组需要用 API
:
bool esp_at_custom_cmd_array_regist(const esp_at_cmd_struct *custom_at_cmd_array, uint32_t cmd_num);
注册客户自定义命令的代码需要加到 app_main()
里, 建议放在 app_main()
的最后 at_custom_init();
之前, 参考代码如下:
bool esp_at_example_cmd_regist(void)
{
return esp_at_custom_cmd_array_regist(at_example_cmd, sizeof(at_example_cmd) / sizeof(at_example_cmd[0]));
}
void app_main()
{
...
if(esp_at_example_cmd_regist() == false) {
printf("regist example cmd fail\r\n");
}
at_custom_init();
}
添加命令的具体实现:
刚次才我们定义的命令数组里, 四个回调函数都是 NULL
, 其实还是什么都做不了的, 我们现在添加个实例函数, 来输出 Hello World
.
因为不需要带参数, 我们就用执行命令来实现吧, 示例代码如下:
uint8_t at_exeCmdExample(uint8_t *cmd_name)
{
esp_at_port_write_data("Hello World", strlen("Hello World"));
return ESP_AT_RESULT_CODE_OK;
}
同时修改命令数组如下:
static esp_at_cmd_struct at_example_cmd[] = {
{"+EXAMPLE", NULL, NULL, NULL, at_exeCmdExample},
};
如果想同时打印这条命令的名字, 可以将 cmd_name
也打印出来, 例如:
uint8_t at_exeCmdExample(uint8_t *cmd_name)
{
esp_at_port_write_data((uint8_t *)cmd_name, strlen((char *)cmd_name));
esp_at_port_write_data((uint8_t *)":Hello World\r\n",strlen(":Hello World"));
return ESP_AT_RESULT_CODE_OK;
}
此时在终端打印信息是这样的:
AT+EXAMPLE
+EXAMPLE:Hello Word
OK
如何添加多个命令
上面的例子只有一个命令, 如果需要多个命令, 可以依次添加, 例如:
static esp_at_cmd_struct at_example_cmd[] = {
{"+EXAMPLE1", NULL, NULL, NULL, NULL},
{"+EXAMPLE2", NULL, NULL, NULL, NULL},
{"+EXAMPLE3", NULL, NULL, NULL, NULL},
{"+EXAMPLE4", NULL, NULL, NULL, NULL},
{"+EXAMPLE5", NULL, NULL, NULL, NULL},
};
二. ESP-AT 自定义命令进阶
2.1 命令实现中, 不同返回值的区别
在上面的 Hello Word
示例中, 我们在 at_exeCmdExample()
最后返回了 ESP_AT_RESULT_CODE_OK
, 这个返回值的作用就是命令执行完毕之后, 输出字符 "OK"
.
在 ESP-AT
中, 返回值不止这一个, 而且每个效果都不同, 我们先看一下一共有哪些返回值
/**
* @brief the result code of AT command processing
*
*/
typedef enum {
ESP_AT_RESULT_CODE_OK = 0x00, /*!< "OK" */
ESP_AT_RESULT_CODE_ERROR = 0x01, /*!< "ERROR" */
ESP_AT_RESULT_CODE_FAIL = 0x02, /*!< "ERROR" */
ESP_AT_RESULT_CODE_SEND_OK = 0x03, /*!< "SEND OK" */
ESP_AT_RESULT_CODE_SEND_FAIL = 0x04, /*!< "SEND FAIL" */
ESP_AT_RESULT_CODE_IGNORE = 0x05, /*!< response nothing */
ESP_AT_RESULT_CODE_PROCESS_DONE = 0x06, /*!< response nothing */
ESP_AT_RESULT_CODE_MAX
} esp_at_result_code_string_index;
从后面的注释能够看出, 不同的返回值可以输出 "OK"
, "ERROR"
, "SEND OK"
, "SEND FAIL"
, 或者处理结果的应答都没有.
前面 3 个应该很好理解, 如果顺利执行完毕, 那么就返回 ESP_AT_RESULT_CODE_OK
, 最终在串口上面会输出 "OK"
, 如果返回的是 ESP_AT_RESULT_CODE_ERROR
,ESP_AT_RESULT_CODE_FAIL
, 那么最终会在串口上面输出 "ERROR"
ESP_AT_RESULT_CODE_SEND_OK
和ESP_AT_RESULT_CODE_SEND_FAIL
可以用在这样的一个场景, 比如基于TCP
连接发送一包数据, 这个时候发送失败了, 可以 return ESP_AT_RESULT_CODE_SEND_FAIL;
, 如果发送成功了, 可以 return ESP_AT_RESULT_CODE_SEND_OK;
除了通过 return
返回值的方式, 向串口输出指令执行的结果, 我们还可以用另外一种方式来做:
/**
* @brief response AT process result,
*
* @param result_code see esp_at_result_code_string_index
*
*/
void esp_at_response_result(uint8_t result_code);
假如您现在向服务器发送了一串数据, 然后您想先打印 "SEND OK"
, 然后等待服务器回应, 最后再退出函数, 您可以这样做:
uint8_t at_exeCmdExample(uint8_t *cmd_name)
{
//send data to Server
Send...
// 先打印 SEND OK
esp_at_response_result(ESP_AT_RESULT_CODE_OK);
// 等待服务器回应, 具体的阻塞实现在下面会介绍
wait...
//最后返回 OK
//如果您在这里不想再输出 OK 了, 可以 return ESP_AT_RESULT_CODE_PROCESS_DONE;
return ESP_AT_RESULT_CODE_OK;
}
ESP_AT_RESULT_CODE_IGNORE
和 ESP_AT_RESULT_CODE_PROCESS_DONE
的区别就是:
ESP_AT_RESULT_CODE_IGNORE
不输出命令执行结果, 也不会有状态的切换, 仍处于处理当前命令的状态. 这个时候输入下一条命令, 会返回 busy
.
ESP_AT_RESULT_CODE_PROCESS_DONE
不输出命令执行结果, 但会将当前的状态切换到空闲状态, 可以处理下一条命令
2.2 如何获取命令的参数
上一节提到的设置命令, 该命令是需要带一些参数的, 参数可能不止一个, 类型也不尽相同, 有的是整形, 有的是字符串, 该怎么在回调函数中处理这些参数呢?我们还是举一个例子, 假如我们现在要去连接一个 TCP Server
, 那么它的参数至少有两个, IP
地址和端口号, 我们约定他的命令是这个样子的:
AT+TCP=<IP>,<port>
其中, IP
地址是字符串, port
是数字.
我们可以定义命令数组如下:
static esp_at_cmd_struct at_example_cmd[] = {
{"+TCP", NULL, NULL, at_setupCmdTcp, NULL},
};
设置命令的具体实现如下:
uint8_t at_setupCmdTcp(uint8_t para_num)
{
int32_t cnt = 0, value = 0;
uint8_t* s = NULL;
// 首先获取ip地址, 这是一个字符串, 如果失败, 会返回 ERROR
if (esp_at_get_para_as_str(cnt++, &s) != ESP_AT_PARA_PARSE_RESULT_OK) {
return ESP_AT_RESULT_CODE_ERROR;
}
// 然后获取端口号, 同理, 如果失败, 也会返回错误
if (esp_at_get_para_as_digit(cnt++, &value) != ESP_AT_PARA_PARSE_RESULT_OK) {
return ESP_AT_RESULT_CODE_ERROR;
}
// 最后再检查下参数个数, para_num 是用户输入的这条命令的参数个数, 如果不等于 2, 也可以报错
if (para_num != cnt) {
return ESP_AT_RESULT_CODE_ERROR;
}
// 下面就可以加入用户自己的处理代码了
// TODO:
return ESP_AT_RESULT_CODE_OK;
}
这里需要关注这样的几点:
param_num
是用户实际输入的参数个数, 每个参数之间是以,
隔开esp_at_para_parse_result_type esp_at_get_para_as_digit(int32_t para_index, int32_t *value);
用于获取整形数据esp_at_para_parse_result_type esp_at_get_para_as_str(int32_t para_index, uint8_t **result);
用于获取字符串参数
2.3 如何处理可选参数
有的时候, 可能有些参数是可选, 也就是可变参数.
这个时候涉及到两种情况, 一种省略的是第一个或者中间的参数, 另一种是省略最后的部分, 省略第一个参数和省略中间参数的处理方式相同.
2.3.1 省略的是中间的参数
这种情况, 命令的定义一般是这样的:
AT+TESTCMD=<parm_1>[,<param_2>],<param_3>
约定中间的参数 param_2
可以省略, 它的类型是整形, 另外两个参数 param_1
是整形, param_3
是字符串.
示例代码如下:
static esp_at_cmd_struct at_example_cmd[] = {
{"+TESTCMD", NULL, NULL, at_setupCmdTestCmd, NULL},
};
uint8_t at_setupCmdTestCmd(uint8_t para_num)
{
int32_t cnt = 0, value = 0;
uint8_t* s = NULL;
esp_at_para_parse_result_type parse_result = ESP_AT_PARA_PARSE_RESULT_FAIL;
// 首先获取第一个参数 param_1,他的类型是整形
if (esp_at_get_para_as_digit(cnt++, &value) != ESP_AT_PARA_PARSE_RESULT_OK) {
return ESP_AT_RESULT_CODE_ERROR;
}
// 这里需要注意, 需要把 value 的值赋值给另外一个变量, 因为下面的代码会把 value 的值重置成另一个参数的值, 或者下面再获取整形参数值的时候, 用另外定义的变量
// param_1 = value;
// 现在处理第二个可选参数, 尝试下是否能获取到
parse_result = esp_at_get_para_as_digit(cnt++, &value);
if (parse_result != ESP_AT_PARA_PARSE_RESULT_OMITTED) {
// 能走到这里, 说明这个可选参数没有被省略
// 进一步判断返回值是不是OK
// 需要注意, 例子这里举得是整形参数, 如果是字符串的话
// 客户输入 "" 这样的空串也是会返回OK的, 只是字符串指针是 NULL
if (parse_result != ESP_AT_PARA_PARSE_RESULT_OK) {
return ESP_AT_RESULT_CODE_ERROR;
}
// param_2 = value;
} else {
// 走到这里, 说明用户没有输入第二个参数
// 是不是用默认值, 还是做别的处理, 取决于客户自己的逻辑
}
// 现在获取最后一个参数 param_3
if (esp_at_get_para_as_str(cnt++, &s) != ESP_AT_PARA_PARSE_RESULT_OK) {
return ESP_AT_RESULT_CODE_ERROR;
}
// param_3 = s;
// 最后再检查下参数个数, para_num 是用户输入的这条命令的参数个数, 如果不等于3, 报错
if (para_num != cnt) {
return ESP_AT_RESULT_CODE_ERROR;
}
// 下面就可以加入用户自己的处理代码了
// TODO:
return ESP_AT_RESULT_CODE_OK;
}
2.3.2 省略的是最后的参数
这种情况, 命令的定义一般是这样的:
AT+TESTCMD=<parm_1>,<param_2>[,<param_3>]
约定最后的的参数 param_3
可以省略, 它的类型是整形, 另外两个参数 param_1
是整形, param_2
是字符串.
省略的形式有两种, 例如:
AT+TESTCMD=123,"abc"
AT+TESTCMD=123,"abc",
示例代码如下:
static esp_at_cmd_struct at_example_cmd[] = {
{"+TESTCMD", NULL, NULL, at_setupCmdTestCmd, NULL},
};
uint8_t at_setupCmdTestCmd(uint8_t para_num)
{
int32_t cnt = 0, value = 0;
uint8_t* s = NULL;
esp_at_para_parse_result_type parse_result = ESP_AT_PARA_PARSE_RESULT_FAIL;
// 首先获取第一个参数 param_1, 他的类型是整形
if (esp_at_get_para_as_digit(cnt++, &value) != ESP_AT_PARA_PARSE_RESULT_OK) {
return ESP_AT_RESULT_CODE_ERROR;
}
// 这里需要注意, 需要把 value 的值赋值给另外一个变量, 因为下面的代码会把 value 的值重置成另一个参数的值, 或者下面再获取整形参数值的时候, 用另外定义的变量
//param_1 = value;
// 现在处理第二个参数
if (esp_at_get_para_as_str(cnt++, &s) != ESP_AT_PARA_PARSE_RESULT_OK){
return ESP_AT_RESULT_CODE_ERROR;
}
// param_2 = s;
if (para_num != cnt) {
// 走到这里说明第三个参数可能存在, 尝试获取它, 看看是不是真的输入了
parse_result = esp_at_get_para_as_digit(cnt++, &value);
if (parse_result != ESP_AT_PARA_PARSE_RESULT_OMITTED) {
if (parse_result != ESP_AT_PARA_PARSE_RESULT_OK) {
// 第三个参数格式错误
return ESP_AT_RESULT_CODE_ERROR;
}
// 获取到了第三个参数
// param_3 = value;
} else {
// 说明参数还是被省略
// 用户自己处理这种情况
}
}
// 最后再检查下参数个数, para_num 是用户输入的这条命令的参数个数, 如果不等于 3, 报错
if (para_num != cnt) {
return ESP_AT_RESULT_CODE_ERROR;
}
// 下面就可以加入用户自己的处理代码了
// TODO:
return ESP_AT_RESULT_CODE_OK;
}
2.4 如何在命令中加入定时器进入阻塞状态
某些应用场景, 需要将命令处理过程阻塞在哪里, 然后等待结果返回, 再将命令退出, 这种情况, 我们一般可以通过信号量来处理.
这次我们用查询指令来举例, 比如想去云端查询某个状态.
示例代码如下:
static esp_at_cmd_struct at_example_cmd[] = {
{"+TESTCMD", NULL, at_queryCmdTestCmd, NULL, NULL},
};
static xSemaphoreHandle at_operation_sema = NULL;
uint8_t at_queryCmdTestCmd(uint8_t *cmd_name)
{
...
...
assert(!at_operation_sema);
at_operation_sema = xSemaphoreCreateBinary();
//用户代码处理, 比如向另外一个 task 发送一个 queue, 让这个 task 做一些耗时比较久的事情, 然后 AT 这里等待结果
xSemaphoreTake(at_operation_sema, portMAX_DELAY);
vSemaphoreDelete(at_operation_sema);
at_operation_sema = NULL;
return ESP_AT_RESULT_CODE_OK;
}
另一个 task 处理完可以这样处理:
void user_task(void)
{
...
if (at_operation_sema) {
xSemaphoreGive(at_operation_sema);
}
...
}
2.5 如何从 AT 命令 port 中截取数据
一般有两个应用场景, 第一个是截取指定长度的数据, 另一个是数据长度不指定, 类似于透传
这里需要注意这两个 API
:
/**
* @brief Set AT core as specific status, it will call callback if receiving data.
* @param callback
*
*/
void esp_at_port_enter_specific(esp_at_port_specific_callback_t callback);
/**
* @brief Exit AT core as specific status.
* @param NONE
*
*/
void esp_at_port_exit_specific(void);
一个用于设置回调函数, 一个用于删除回调函数, 在本小节里的这种应用场景中, 他的工作原理是这样的:
- 首先设置回调函数
- 如果
AT Port
收到数据, 会回调这个函数 - 我们在回调函数里
Give
信号量 AT
命令处理代码一直在等这个信号量Take
到这个信号量之后, 就可以获取到AT port
的数据了- 退出的时候, 删除回调函数, 删除信号量
2.5.1 数据长度不指定
如果用户需要进入输入模式, 直接获取串口的数据, 比如进入透传状态, 我们可以这样做:
定义命令:
static esp_at_cmd_struct at_example_cmd[] = {
{"+TESTCMD", NULL, NULL, NULL, at_exeCmdTestCmd},
};
具体实现如下:
static xSemaphoreHandle at_sync_sema = NULL;
static void at_wait_data_callback(void)
{
xSemaphoreGive(at_sync_sema);
}
#define BUFFER_LEN 2048
uint8_t at_exeCmdExample(uint8_t *cmd_name)
{
int32_t temp_len = 0;
uint8_t test_buf[BUFFER_LEN] = {0};
...
...
vSemaphoreCreateBinary(at_sync_sema);
xSemaphoreTake(at_sync_sema, portMAX_DELAY);
//打印输入提示符 '>'
esp_at_port_write_data((uint8_t*)">", at_strlen(">"));
esp_at_port_enter_specific(at_wait_data_callback);
while (xSemaphoreTake(at_sync_sema, portMAX_DELAY)) {
memset(test_buf, 0x0, BUFFER_LEN);
//读取数据到buffer
temp_len = esp_at_port_read_data(test_buf, BUFFER_LEN);
//下面这段处理逻辑是判读是否推出透传, 这里示例代码的判断条件是 "+++" 三个字节的字符
if ((temp_len == 3) && (memcmp((char *) test_buf, "+++", strlen("+++"))==0)) {
esp_at_port_exit_specific();
temp_len = esp_at_port_get_data_length();
if (temp_len > 0) {
esp_at_port_recv_data_notify(temp_len, portMAX_DELAY);
}
break;
} else if (temp_len > 0 ){
// 这里就把 RAW DATA 交由用户处理了
customer_do_something(test_buf, temp_len);
}
}
vSemaphoreDelete(at_sync_sema);
at_sync_sema = NULL;
// 这里就退出透传了, 同时还会输出OK字符, 如果您不想把OK在这里输出, 而是放在 ‘>’ 之前
// 您首先需要在打印输入提示符 '>' 之前, 调用
// esp_at_response_result(ESP_AT_RESULT_CODE_OK);
// 然后在最后这里 return ESP_AT_RESULT_CODE_IGNORE;
return ESP_AT_RESULT_CODE_OK;
}
2.5.2 指定数据长度
如果数据长度是指定, 和上面略微有些差异, 大体思路相同, 我们可以这么做:
定义命令:
static esp_at_cmd_struct at_example_cmd[] = {
{"+TESTCMD", NULL, NULL, at_setupCmdTestCmd, NULL},
};
命令带一个整形参数.
具体实现如下:
static xSemaphoreHandle at_sync_sema = NULL;
static void at_wait_data_callback(void)
{
xSemaphoreGive(at_sync_sema);
}
uint8_t at_setupCmdTestCmd(uint8_t para_num)
{
int32_t cnt = 0 , value = 0, len = 0, temp_len = 0;
uint8_t *test_buf = NULL;
// 获取数据长度
if (esp_at_get_para_as_digit(cnt++, &value) != ESP_AT_PARA_PARSE_RESULT_OK) {
return ESP_AT_RESULT_CODE_ERROR;
}
len = value;
// 检查参数个数
if (para_num != cnt) {
return ESP_AT_RESULT_CODE_ERROR;
}
test_buf = (uint8_t *)malloc(len * sizeof(uint8_t));
if (test_buf == NULL) {
printf("malloc fail\n");
return ESP_AT_RESULT_CODE_ERROR;
}
vSemaphoreCreateBinary(at_sync_sema);
xSemaphoreTake(at_sync_sema, portMAX_DELAY);
//打印输入提示符 '>'
esp_at_port_write_data((uint8_t*)">", at_strlen(">"));
esp_at_port_enter_specific(at_wait_data_callback);
temp_len = 0;
// 开始截取指定长度的数据,
while (xSemaphoreTake(at_sync_sema, portMAX_DELAY)) {
temp_len += esp_at_port_read_data(test_buf + temp_len, len - temp_len);
if (temp_len == len) {
// 走到这里, 说明已经接收到了想要的长度的数据, 但是要要注意, 如果时间输入长度超过指定想要的长度, 也会进到这里
esp_at_port_exit_specific();
// 这里就是在获取看看还有多少数据没有读取, 如果是0, 那就是正好数据长度就是指定的
temp_len = esp_at_port_get_data_length();
if (temp_len > 0) {
//如果实际输入的长度超过想要的长度, 会走到这里, 在这个例子中, 是直接把多余的数据打印出来, 您的应用中怎么处理取决于您
esp_at_port_recv_data_notify(temp_len, portMAX_DELAY);
}
break;
}
}
vSemaphoreDelete(at_sync_sema);
at_sync_sema = NULL;
free(test_buf);
...
return ESP_AT_RESULT_CODE_OK;
}