前言
起因是我有一个Xbox手柄,我想用ESP32去连接它,然后去控制一些东西。起初这个项目我是用ESP-IDF编写的,参考了官方的 bluetooth/esp_hid_host 示例,作了一些修改,最后我的ESP32设备成功地连接上了我的Xbox手柄,并能够读取各类数据(诸如摇杆、按键),但这不是今天的主题。
在后续的调试中,反复的修改、烧写、以及龟速的串口实在让我有些烦躁了,为了暂时摆脱他们,我决定用MicroPython先绘制出项目的大致架构,因为我可以直接用串口去执行Python代码,而无需烧写进Flash。
但不巧的是,官方的MicroPython固件只提供了BLE的驱动,而我的Xbox手柄是一个Classic BT设备。这似乎是个死局?幸好MicroPython是一个开源项目。
环境
- MCU:ESP-WROOM-32E 4MB Flash
- PC:Ubuntu 24.04 Linux 6.8.8
- IDE:CLion Nova 2024.2 EAP
- ESP-IDF:v5.1.4
- MicroPython:v1.24
前期准备
克隆MicroPython代码库
第一步当然是从托管平台克隆MicroPython的代码仓库:
git clone https://github.com/micropython/micropython
cd micropython/
编译子模块
进入文件夹后,我们需要初始化MicroPython的一些子模块,然后编译它的交叉编译工具
git submodule update --init --recursive
make -C mpy-cross
需要指出的是,我们对MicroPython代码的主要魔改对象在 micropython/ports 中,这些是与特定MCU相关的代码,剩下的一些解释器,内置库之类的,一般没必要去改。
然后,我们进入 ports/esp32 文件夹,编译一些子模块
cd ports/esp32
make submodules
此时, ports/esp32 目录就相当于一个ESP-IDF项目,因为ESP-IDF就是基于CMake管理项目的,我们自然就可以用CLion去打开它。
ESP-IDF配置
安装ESP-IDF编译环境的过程就不再赘述了,如果你正确安装了ESP-IDF,在终端上执行 “get_idf” 后,你应该看到类似下面的输出
Done! You can now compile ESP-IDF projects.
Go to the project directory and run:
idf.py build
但是不要急着按他说地做,我们必须先进行一些配置。
执行 idf.py -D MICROPY_BOARD=ESP32_GENERIC menuconfig
idf.py -D MICROPY_BOARD=ESP32_GENERIC menuconfig
-D选项指定了MICROPY_BOARD变量为ESP32_GENERIC,因为我使用的MCU是ESP-WROOM-32E,如果你使用了其他的MCU,就看看ports/*/boards的中是否有你使用的MCU对应的型号,如果有就改成那个,如果没有…那恐怕你得自己编写驱动了。这也不失为一种挑战
进入menuconfig菜单后,由于我要使用BT Classis功能,所以我要做的事就是把官方的sdkconfig中被关掉的BT Classis功能功能打开:
(高亮的地方原本是NimBLE - BLE only)
修改完我们需要的配置之后,可以试着执行 idf.py build 如果没有出错,就可以进行下一步
代码的定制
理论上来说,现在我们已经可以修改代码,然后再编译出自己的固件了。不如我们先试着把MicroPython启动时的报幕文字改成我们自己的名字?不错的主意。
修改报幕文字
用自己喜欢的IDE打开MicroPython项目后,我们打开 /ports/esp32/main.c
如果你和我一样,使用了CLion,那最好注意一下不要用右上角那个锤子去编译烧写,而是在终端中输入命令,并且CMake的编译输出路径要设置为build(与idf.py使用的目录同名)。
无论如何,现在让我们关注下面的代码
void app_main(void) {
// Hook for a board to run code at start up.
// This defaults to initialising NVS.
MICROPY_BOARD_STARTUP();
// Create and transfer control to the MicroPython task.
xTaskCreatePinnedToCore(mp_task, "mp_task", MICROPY_TASK_STACK_SIZE / sizeof(StackType_t), NULL, MP_TASK_PRIORITY, &mp_main_task_handle, MP_TASK_COREID);
}
MicroPython在ESP-IDF中是作为一个Task被执行的,并且运行在核心1。接下来我们进入mp_task
_Noreturn void mp_task(void *pvParameter) {
// ...
soft_reset:
// initialise the stack pointer for the main thread
mp_stack_set_top((void *)sp);
mp_stack_set_limit(MICROPY_TASK_STACK_SIZE - MP_TASK_STACK_LIMIT_MARGIN);
gc_init(mp_task_heap, mp_task_heap + MICROPY_GC_INITIAL_HEAP_SIZE);
mp_init();
mp_obj_list_append(mp_sys_path, MP_OBJ_NEW_QSTR(MP_QSTR__slash_lib));
readline_init0();
MP_STATE_PORT(native_code_pointers) = MP_OBJ_NULL;
// initialise peripherals
machine_pins_init();
#if MICROPY_PY_MACHINE_I2S
machine_i2s_init0();
#endif
// run boot-up scripts
pyexec_frozen_module("_boot.py", false);
int ret = pyexec_file_if_exists("boot.py");
if (ret & PYEXEC_FORCED_EXIT) {
goto soft_reset_exit;
}
if (pyexec_mode_kind == PYEXEC_MODE_FRIENDLY_REPL && ret != 0) {
int ret = pyexec_file_if_exists("main.py");
if (ret & PYEXEC_FORCED_EXIT) {
goto soft_reset_exit;
}
}
for (;;) {
if (pyexec_mode_kind == PYEXEC_MODE_RAW_REPL) {
vprintf_like_t vprintf_log = esp_log_set_vprintf(vprintf_null);
if (pyexec_raw_repl() != 0) {
break;
}
esp_log_set_vprintf(vprintf_log);
} else {
if (pyexec_friendly_repl() != 0) {
break;
}
}
}
// ...
}
这里只截取了mp_task的一部分,因为太长了。顺带一提,那个_Noreturn 前缀是我加上去的,否则CLion会把这一整段代码都画上黄色的下划线。
在上面那一大段代码中,我们着重关心这一段
for (;;) {
if (pyexec_mode_kind == PYEXEC_MODE_RAW_REPL) {
vprintf_like_t vprintf_log = esp_log_set_vprintf(vprintf_null);
if (pyexec_raw_repl() != 0) {
break;
}
esp_log_set_vprintf(vprintf_log);
} else {
if (pyexec_friendly_repl() != 0) {
break;
}
}
}
pyexec_friendly_repl() 就是我们在控制台与MicroPython设备交互时,实际负责处理指令的函数,我们进入其中
int pyexec_friendly_repl(void) {
vstr_t line;
vstr_init(&line, 32);
friendly_repl_reset:
mp_hal_stdout_tx_str(MICROPY_BANNER_NAME_AND_VERSION);
mp_hal_stdout_tx_str("; " MICROPY_BANNER_MACHINE);
mp_hal_stdout_tx_str("\r\n");
#if MICROPY_PY_BUILTINS_HELP
mp_hal_stdout_tx_str("Type \"help()\" for more information.\r\n");
// ...
显然,我们找到了自己想要修改的东西,那不妨把 MICROPY_BANNER_NAME_AND_VERSION 改成自己的名字试试。
int pyexec_friendly_repl(void) {
vstr_t line;
vstr_init(&line, 32);
friendly_repl_reset:
mp_hal_stdout_tx_str("Micropython Acite Customized Version v0.1");
mp_hal_stdout_tx_str("; " MICROPY_BANNER_MACHINE);
mp_hal_stdout_tx_str("\r\n");
#if MICROPY_PY_BUILTINS_HELP
mp_hal_stdout_tx_str("Type \"help()\" for more information.\r\n");
// ...
做完这一切后,我们在终端输入命令:
idf.py -D MICROPY_BOARD=ESP32_GENERIC build
idf.py -D MICROPY_BOARD=ESP32_GENERIC flash
这会先编译我们修改后的固件,然后烧录进ESP32设备之中(如果你正确地连接了ESP32设备的话)。
随后,我们打开一个Python的REPL窗口:
很显然,我们成功了。
源文件的添加
随后,我们来做我们本来要做的事——让MicroPython可以连接我的Xbox手柄,并且能从Python代码中获取数据。
首先,我们把 bluetooth/esp_hid_host 示例中的 esp_hid_gap.c 和 esp_hid_gap.h 文件复制到 ports/esp32 目录下。然后在 esp32_common.cmake 中添加对应的源文件:
list(APPEND MICROPY_SOURCE_PORT
...
esp_hid_gap.c
...
)
此后如果在项目中引入的新的源文件,也请注意要在 esp32_common.cmake 中添加,我就不再赘述了。
编写可被Python代码调用的模块
如果想让我们编写的C代码可以被Python代码调用,我们需要把它们封装为一个模块,并遵守相应的规范。
首先,我们在 ports/esp32 目录下添加 modacite.c 文件,我们将会在这里编写我们的模块。
// modacite.c
#include <stdio.h>
#include "esp_flash.h"
#include "esp_log.h"
#include "py/runtime.h"
#include "py/mperrno.h"
#include "py/mphal.h"
static const mp_rom_map_elem_t acite_module_globals_table[] = {
{MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_acite)},
};
static MP_DEFINE_CONST_DICT(acite_module_globals, acite_module_globals_table);
const mp_obj_module_t acite_module = {
.base = { &mp_type_module },
.globals = (mp_obj_dict_t *)&acite_module_globals,
};
MP_REGISTER_MODULE(MP_QSTR_acite, acite_module);
以上的代码定义了一个名为"acite"的模块(我是按照其他模块文件照葫芦画瓢画出来的,可能不符合代码规范)
随后,我们需要在这个模块中定义一个可以被调用的函数
// modacite.c
#include <stdio.h>
#include "esp_flash.h"
#include "esp_log.h"
#include "py/runtime.h"
#include "py/mperrno.h"
#include "py/mphal.h"
static mp_obj_t acite_test() // 定义一个Test函数
{
mp_print_str(MP_PYTHON_PRINTER, "Module Acite is Testing");
return mp_const_none;
}
static MP_DEFINE_CONST_FUN_OBJ_0(acite_test_obj, acite_test); // 定义相关的对象
static const mp_rom_map_elem_t acite_module_globals_table[] = {
{MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_acite)},
{MP_ROM_QSTR(MP_QSTR_Test), MP_ROM_PTR(&acite_test_obj)} // 添加对象引用
};
static MP_DEFINE_CONST_DICT(acite_module_globals, acite_module_globals_table);
const mp_obj_module_t acite_module = {
.base = { &mp_type_module },
.globals = (mp_obj_dict_t *)&acite_module_globals,
};
MP_REGISTER_MODULE(MP_QSTR_acite, acite_module);
至此,我们就定义了一个名为 “acite” 的模块,这个模块中有一个函数 Test() 可被调用,让我们编译看看效果
Perfect!
值得一提的是,在定制MicroPython代码的时候不要用ESP_LOG之类的函数,MicroPython重定向了log的输出,所以我们什么也看不到。最好用下面的函数
int mp_print_str(const mp_print_t *print, const char *str);
添加新的任务
现在有一个新的问题,MicroPython作为一个任务运行在ESP32(FreeRTOS)中,那我们的蓝牙相关的代码在哪运行呢?直接和MicroPython的代码混在一起不是一个明智的选择,但是既然MicroPython是一个任务,我们是否可以再创建一个新的任务做我们想做的事?当然可以。
蓝牙相关代码
在 ports/esp32 下新建源文件 bt_hid_proc.c 用于处理我们的业务逻辑。
// #include ...
uint8_t bt_buffer[32];
void hidh_callback(void *handler_args, esp_event_base_t base, int32_t id, void *event_data)
{
esp_hidh_event_t event = (esp_hidh_event_t)id;
esp_hidh_event_data_t *param = (esp_hidh_event_data_t *)event_data;
switch (event) {
case ESP_HIDH_OPEN_EVENT: {
if (param->open.status == ESP_OK) {
const uint8_t *bda = esp_hidh_dev_bda_get(param->open.dev);
mp_printf(MP_PYTHON_PRINTER, ESP_BD_ADDR_STR " OPEN: %s", ESP_BD_ADDR_HEX(bda), esp_hidh_dev_name_get(param->open.dev));
esp_hidh_dev_dump(param->open.dev, stdout);
} else {
mp_printf(MP_PYTHON_PRINTER, " OPEN failed!");
}
break;
}
case ESP_HIDH_BATTERY_EVENT: {
const uint8_t *bda = esp_hidh_dev_bda_get(param->battery.dev);
mp_printf(MP_PYTHON_PRINTER, ESP_BD_ADDR_STR " BATTERY: %d%%", ESP_BD_ADDR_HEX(bda), param->battery.level);
break;
}
case ESP_HIDH_INPUT_EVENT: {
esp_hidh_dev_bda_get(param->input.dev);
memcpy(bt_buffer, param->input.data, param->input.length);
break;
}
case ESP_HIDH_FEATURE_EVENT: {
const uint8_t *bda = esp_hidh_dev_bda_get(param->feature.dev);
mp_printf(MP_PYTHON_PRINTER, ESP_BD_ADDR_STR " FEATURE: %8s, MAP: %2u, ID: %3u, Len: %d", ESP_BD_ADDR_HEX(bda),
esp_hid_usage_str(param->feature.usage), param->feature.map_index, param->feature.report_id,
param->feature.length);
ESP_LOG_BUFFER_HEX("TAG", param->feature.data, param->feature.length);
break;
}
case ESP_HIDH_CLOSE_EVENT: {
const uint8_t *bda = esp_hidh_dev_bda_get(param->close.dev);
mp_printf(MP_PYTHON_PRINTER, ESP_BD_ADDR_STR " CLOSE: %s", ESP_BD_ADDR_HEX(bda), esp_hidh_dev_name_get(param->close.dev));
break;
}
default:
mp_printf(MP_PYTHON_PRINTER, "EVENT: %d", event);
break;
}
}
#define SCAN_DURATION_SECONDS 5
void hid_demo_task(void *pvParameters)
{
size_t results_len = 0;
esp_hid_scan_result_t *results = NULL;
mp_printf(MP_PYTHON_PRINTER, "SCAN...");
esp_hid_scan(SCAN_DURATION_SECONDS, &results_len, &results);
mp_printf(MP_PYTHON_PRINTER, "SCAN: %u results", results_len);
if (results_len) {
esp_hid_scan_result_t *r = results;
esp_hid_scan_result_t *cr = NULL;
while (r) {
printf(" %s: " ESP_BD_ADDR_STR ", ", (r->transport == ESP_HID_TRANSPORT_BLE) ? "BLE" : "BT ", ESP_BD_ADDR_HEX(r->bda));
printf("RSSI: %d, ", r->rssi);
printf("USAGE: %s, ", esp_hid_usage_str(r->usage));
if (r->transport == ESP_HID_TRANSPORT_BT) {
cr = r;
printf("COD: %s[", esp_hid_cod_major_str(r->bt.cod.major));
esp_hid_cod_minor_print(r->bt.cod.minor, stdout);
printf("] srv 0x%03x, ", r->bt.cod.service);
print_uuid(&r->bt.uuid);
printf(", ");
}
printf("NAME: %s ", r->name ? r->name : "");
printf("\n");
r = r->next;
}
if (cr) {
//open the first result
mp_printf(MP_PYTHON_PRINTER, "Try to Open " ESP_BD_ADDR_STR, ESP_BD_ADDR_HEX(cr->bda));
esp_hidh_dev_open(cr->bda, ESP_HID_TRANSPORT_BT, cr->ble.addr_type);
}
//free the results
esp_hid_scan_results_free(results);
}
vTaskDelete(NULL);
}
void bt_proc_main(void* param)
{
mp_printf(MP_PYTHON_PRINTER, "setting hid gap, mode:%d", HID_HOST_MODE);
ESP_ERROR_CHECK( esp_hid_gap_init(HID_HOST_MODE) );
ESP_ERROR_CHECK( esp_ble_gattc_register_callback(esp_hidh_gattc_event_handler));
esp_hidh_config_t config = {
.callback = hidh_callback,
.event_stack_size = 4096,
.callback_arg = NULL,
};
ESP_ERROR_CHECK(esp_hidh_init(&config));
xTaskCreate(&hid_demo_task, "hid_task", 4 * 1024, NULL, 2, NULL);
vTaskDelete(NULL);
}
平平无奇,就是在官方的示例上改的,但不同的是,我们要把启动蓝牙搜索的部分编写为一个FreeRTOS的任务。
这个代码文件做的事情简单来说就是寻找一个HID设备去配对,然后把接收到的数据保存在uint8_t bt_buffer[32];中。
随后,我们在acite模块中添加函数:
static mp_obj_t acite_bt_connect();
static mp_obj_t acite_bt_get_data();
前者负责启动蓝牙任务,后者负责把读取到的数据交付到Python代码层中:编写完成后的样子是这样:
//
// Created by acite on 7/18/24.
//
#include <stdio.h>
#include "esp_flash.h"
#include "esp_log.h"
#include "py/runtime.h"
#include "py/mperrno.h"
#include "py/mphal.h"
extern void bt_proc_main(void* param);
extern uint8_t bt_buffer[32];
static mp_obj_t acite_test()
{
mp_print_str(MP_PYTHON_PRINTER, "Module Acite is Testing");
return mp_const_none;
}
static MP_DEFINE_CONST_FUN_OBJ_0(acite_test_obj, acite_test);
static mp_obj_t acite_bt_connect()
{
TaskHandle_t bt_t;
xTaskCreatePinnedToCore(bt_proc_main,
"bt_task",
4096,
NULL,
2,
&bt_t,
MP_TASK_COREID);
return mp_const_none;
}
static MP_DEFINE_CONST_FUN_OBJ_0(acite_bt_connect_obj, acite_bt_connect);
static mp_obj_t acite_bt_get_data()
{
mp_obj_tuple_t *tuple = MP_OBJ_TO_PTR(mp_obj_new_tuple(32, NULL));
for (int i = 0; i < 32; i++) {
tuple->items[i] = mp_obj_new_int(bt_buffer[i]);
}
return MP_OBJ_FROM_PTR(tuple);
}
static MP_DEFINE_CONST_FUN_OBJ_0(acite_bt_get_data_obj, acite_bt_get_data);
static const mp_rom_map_elem_t acite_module_globals_table[] = {
{MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_acite)},
{MP_ROM_QSTR(MP_QSTR_Test), MP_ROM_PTR(&acite_test_obj)},
{MP_ROM_QSTR(MP_QSTR_BtConnect), MP_ROM_PTR(&acite_bt_connect_obj)},
{MP_ROM_QSTR(MP_QSTR_BtGetData), MP_ROM_PTR(&acite_bt_get_data_obj)}
};
static MP_DEFINE_CONST_DICT(acite_module_globals, acite_module_globals_table);
const mp_obj_module_t acite_module = {
.base = { &mp_type_module },
.globals = (mp_obj_dict_t *)&acite_module_globals,
};
MP_REGISTER_MODULE(MP_QSTR_acite, acite_module);
在 acite_bt_get_data 函数中,我们读取了bt_buffer的内容,然后把它作为一个Tuple返回。
成果
现在,我们可以编译看一看效果了:
除了一些细节的问题之外,可以说完美。
总结
虽然是一次很简单的源码定制过程,但是这种过程可以唤起我们的一种意识:没有什么代码是高高在上,只可远观不可亵玩的。不要一味的寻找能满足自己需求的轮子,如果一个轮子距离你的需求就差一点,而他又是开源的——那就勇敢的去研究、修改它。