系统概览

1
2
3
4
5
6
7
8
9
┌──────────┐  BLE UART    ┌──────────────┐  BLE GATT    ┌──────────┐
│ 手机 App │ ◄──────────► │ ESP32-S3 │ ◄──────────► │ 热敏打印机 │
│ (Central) │ Peripheral │ (双角色桥接) │ Central │ (Peripheral) │
└──────────┘ │ │ └──────────┘
│ ┌────────┐ │
│ │TJC串口屏│ │
│ │ UART2 │ │
│ └────────┘ │
└──────────────┘

ESP32-S3 同时扮演两个 BLE 角色:

角色 方向 协议 用途
Peripheral(外设) 手机 → ESP32 Nordic UART Service 手机收发指令/数据
Central(中心设备) ESP32 → 打印机 私有 GATT Service 发送 ESC/POS 打印指令

加上一路 UART2 驱动 TJC 串口屏,构成了完整的交互链路。


技术栈分层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌─────────────────────────────┐
│ 应用层 │
│ hello_world_main.c │ ← 主循环:采样、校准、显示
│ tjc_uart2.c (串口屏) │ ← 触摸事件解析 + 页面路由
│ key.c (物理按键) │ ← 焦点导航 + 按键分发
├─────────────────────────────┤
│ 蓝牙服务层 │
│ printer_scan.c │ ← BLE 双角色 + 打印 + 设备列表
├─────────────────────────────┤
│ NimBLE Host │ ← 开源 BLE 协议栈 (Apache Mynewt)
│ ble_gap / ble_gatt / ... │ GAP 广播/扫描/连接
│ ble_hs │ GATT 服务/特征/读写
├─────────────────────────────┤
│ NimBLE Controller │ ← ESP32-S3 片上 BLE 基带
├─────────────────────────────┤
│ FreeRTOS │ ← 多任务调度
│ ble_task / nimble_host / │
│ receive_and_decode / │
│ main loop │
├─────────────────────────────┤
│ ESP-IDF v5.5 │ ← 驱动层:UART/GPIO/I2C/NVS
└─────────────────────────────┘

NimBLE 协议栈

选择 NimBLE(而非 Bluedroid)的原因:

  • 轻量:ROM 占用约 60KB,Bluedroid 约 500KB+
  • 开源:Apache 2.0,来自 Apache Mynewt 项目
  • 官方支持:ESP-IDF v4.0+ 内置,配置 CONFIG_BT_NIMBLE_ENABLED=y

关键组件

1
2
3
4
5
6
#include "nimble/nimble_port.h"           // NimBLE 初始化端口
#include "nimble/nimble_port_freertos.h" // FreeRTOS 集成
#include "host/ble_hs.h" // BLE Host 核心 API
#include "host/ble_uuid.h" // UUID 定义
#include "services/gap/ble_svc_gap.h" // GAP 服务
#include "services/gatt/ble_svc_gatt.h" // GATT 服务

事件驱动模型

NimBLE 采用回调注册模式:

1
2
3
4
5
6
ble_hs_cfg.sync_cb  = ble_on_sync;   // BLE 栈就绪回调
ble_hs_cfg.reset_cb = ble_on_reset; // BLE 栈重置回调

// 扫描/连接也注册回调
ble_gap_disc(addr_type, BLE_HS_FOREVER, &params, printer_gap_event, NULL);
ble_gap_connect(addr_type, &peer, BLE_HS_FOREVER, &conn_params, printer_gap_event, NULL);

回调在 NimBLE Host Task 上下文中执行,通过 nimble_port_freertos_init() 启动:

1
2
3
4
5
6
nimble_port_freertos_init(nimble_host_task);

static void nimble_host_task(void *param) {
nimble_port_run(); // 阻塞事件循环
nimble_port_freertos_deinit();
}

双角色 BLE 架构

角色 1:手机 UART 外设

1
2
ESP32 广播 ──→ 手机扫描发现 ──→ GATT 连接 ──→ 订阅 TX 特征通知
──→ 写入 RX 特征

服务 UUID:Nordic UART Service (NUS) 标准

1
2
3
Service: 6E400001-B5A3-F393-E0A9-E50E24DCCA9E
├── RX Characteristic: 6E400002-... (手机写 → ESP32 读) WRITE
└── TX Characteristic: 6E400003-... (ESP32 通知 → 手机) NOTIFY
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 定义 UART 服务
static const struct ble_gatt_svc_def gatt_svr_svcs[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &gatt_svr_svc_uuid.u,
.characteristics = (struct ble_gatt_chr_def[]) {
{
.uuid = &gatt_svr_chr_rx_uuid.u,
.access_cb = gatt_svr_chr_access,
.flags = BLE_GATT_CHR_F_WRITE,
},
{
.uuid = &gatt_svr_chr_tx_uuid.u,
.access_cb = gatt_svr_chr_access,
.flags = BLE_GATT_CHR_F_NOTIFY,
},
{ 0 }
},
},
{ 0 }
};

广播启动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void ble_uart_advertise(void) {
struct ble_gap_adv_params adv_params;
struct ble_hs_adv_fields fields = {0};

fields.name = (uint8_t *)"ESP32-BLE-Bridge";
fields.name_len = strlen("ESP32-BLE-Bridge");
fields.name_is_complete = 1;
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;

ble_gap_adv_set_fields(&fields);
adv_params.conn_mode = BLE_GAP_CONN_MODE_UND; // 可连接
adv_params.disc_mode = BLE_GAP_DISC_MODE_GEN; // 通用发现
ble_gap_adv_start(addr_type, NULL, BLE_HS_FOREVER, &adv_params,
ble_uart_gap_event, NULL);
}

角色 2:打印机客户端

1
ESP32 扫描 ──→ 发现打印机 ──→ GATT 连接 ──→ 服务发现 ──→ 特征发现 ──→ 写入特征

打印机 UUID(私有协议):

1
2
Service: e7810a71-73ae-499d-8c15-faa9aef0c3f2
└── Write Characteristic: bef8d6c9-9c21-4c9e-b632-bd58c1009f9f WRITE_NO_RSP

扫描参数

1
2
3
4
disc_params.filter_duplicates = 0;   // 不滤重(内部去重)
disc_params.passive = 0; // 主动扫描
disc_params.itvl = 0x30; // 扫描间隔 ~30ms
disc_params.window = 0x25; // 扫描窗口 ~23ms

服务发现链:连接成功 → printer_discover_service() → 按 UUID 搜服务 → printer_svc_disc_cb() → 搜所有特征 → printer_chr_disc_cb() → 找到写入句柄,置 printer_ready = true


BLE 数据发送:MTU 自动分片

打印机写入特征采用 BLE_GATT_CHR_F_WRITE_NO_RSP(无响应写入),需要自行处理 MTU 分片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static void printer_send_data(const uint8_t *data, uint16_t len) {
if (!printer_ready || printer_attr_handle == 0) return;

uint16_t mtu = ble_att_mtu(printer_conn_handle);
if (mtu < 23) mtu = 23;
uint16_t chunk_size = mtu - 3; // ATT 头部占 3 字节

uint16_t offset = 0;
while (offset < len) {
uint16_t chunk = (len - offset) > chunk_size
? chunk_size : (len - offset);
struct os_mbuf *om = ble_hs_mbuf_from_flat(data + offset, chunk);
if (!om) return;
ble_gattc_write_no_rsp(printer_conn_handle, printer_attr_handle, om);
offset += chunk;
vTaskDelay(pdMS_TO_TICKS(10)); // 留间隔给 BLE 栈处理
}
}

ESC/POS 打印指令

打印机使用标准 ESC/POS 指令集:

1
2
3
4
5
6
7
#define ESC_INIT         "\x1B\x40"    // 打印机初始化
#define FS_SET_CHINESE "\x1C\x26" // 进入中文模式
#define FS_SELECT_CODEPAGE "\x1C\x43\x00" // 选择代码页
#define ESC_ALIGN_LEFT "\x1B\x61\x00" // 左对齐
#define ESC_ALIGN_CENTER "\x1B\x61\x01" // 居中对齐
#define ESC_ALIGN_RIGHT "\x1B\x61\x02" // 右对齐
#define FS_EXIT_CHINESE "\x1C\x2E" // 退出中文模式

打印一条记录的流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
bool printer_print_query_record(const void *vdata) {
// 1. 初始化 + 中文模式
memcpy(&buf[len], ESC_INIT, 2); len += 2;
memcpy(&buf[len], FS_SET_CHINESE, 2); len += 2;

// 2. 居中对齐 + 打印标题
memcpy(&buf[len], ESC_ALIGN_CENTER, 3); len += 3;
len += sprintf(&buf[len], "采样数据记录\n");

// 3. 左对齐 + 打印字段
memcpy(&buf[len], ESC_ALIGN_LEFT, 3); len += 3;
len += sprintf(&buf[len], "温度: %s C\n", data->Ta);
len += sprintf(&buf[len], "压力: %s KPa\n", data->Pa);
// ...

// 4. 退出中文 + 走纸
memcpy(&buf[len], FS_EXIT_CHINESE, 2); len += 2;
buf[len++] = 0x1B; buf[len++] = 0x64; buf[len++] = 0x03;

printer_send_data(buf, len);
}

TJC 串口屏通信

物理层

1
ESP32 UART2 ── 115200/8N1 ── TJC 串口屏

协议帧格式

1
2
[0x55][0x00][数据...][0xFF][0xFF][0xFF]
帧头 帧头 载荷 帧尾

收发实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ESP32 发 → 屏幕
void send_to_screen(const char *data) {
uint8_t *buf = malloc(strlen(data) + 3);
memcpy(buf, data, strlen(data));
buf[strlen(data)] = 0xFF;
buf[strlen(data) + 1] = 0xFF;
buf[strlen(data) + 2] = 0xFF;
uart_write_bytes(UART_NUM_2, buf, strlen(data) + 3);
free(buf);
}

// 屏幕 → ESP32 收(在 receive_and_decode 任务中)
// 帧解析:找 0x55 0x00 帧头 → 累计直到连续 3 个 0xFF
// key=value 解析:strchr('=', str) 分割

指令类型

指令 示例 含义
按钮按下 bluetooth_open=1 屏幕按钮值改变
变量赋值 MAC.txt="DEVICE_01" 设置文本框内容
模拟点击 click buck,1 模拟按下按钮
变量查询 connecting.val=0 直接写按钮值(不触发事件)

关键区别

方式 触发屏幕事件 触发串口回传
click bt,1 ✅ 触发 ✅ 回传
bt.val=0 ✅ 变视觉 ❌ 不回传

连接失败时用 click 回传,因为需要触发按钮 press/release 动画;其他场景用 .val= 直接设值避免 echo。


FreeRTOS 任务架构

1
2
3
4
5
6
优先级  任务名              功能
─── ────── ────
高 NimBLE Host Task BLE 协议栈事件循环 (nimble_port_run)
│ ble_task 50ms 轮询:连接/断开/心跳/断线检测
│ receive_and_decode UART2 串口屏帧接收+解析
低 main loop 采样、校准、PID、屏幕刷新

ble_task 主循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void ble_task(void *arg) {
while (1) {
// 1. 处理连接/断开请求(来自串口屏)
if (ble_connect_flag) { ... }
if (ble_disconnect_flag) { ... }

// 2. 连接保活心跳(10秒一次空包)
if (connected && printer_ready) { ... }

// 3. 被动断线检测
if (connected && !ble_is_connected()) { ... }

vTaskDelay(pdMS_TO_TICKS(50));
}
}

标志位管理

核心原则:每个标志位只有一个写入方

1
2
3
4
串口屏按钮 ──→ sys_status.bluetooth_open       (开关)
串口屏按钮 ──→ ble_connect_flag (连接触发)
串口屏按钮 ──→ ble_disconnect_flag (断开触发)
ble_task ────→ sys_status.bluetooth_connecting (连接状态)
标志位 写入方 读取方
sys_status.bluetooth_open tjc_uart2.c (UI 事件) ble_task, key.c, hello_world_main.c
sys_status.bluetooth_connecting ble_task (实际状态) tjc_uart2.c, key.c
ble_connect_flag tjc_uart2.c ble_task
ble_disconnect_flag tjc_uart2.c ble_task

禁止 UI 直接写 sys_status.bluetooth_connecting——这个标志反映真实 BLE 连接状态,只能由 ble_task 根据 GAP 事件结果更新。


连接失败的 UI 回传

蓝牙连接是异步操作,失败后需要恢复串口屏上 connecting 按钮的视觉状态:

1
2
3
4
5
6
7
static void ble_reset_screen_button(void) {
char cmd[32];
snprintf(cmd, sizeof(cmd), "click connecting,1");
send_to_screen(cmd);
snprintf(cmd, sizeof(cmd), "click connecting,0");
send_to_screen(cmd);
}

ble_task 的三处调用:连接超时、主动断开、被动断线。

click 翻转按钮 val=1 → val=0,屏幕回传 connecting=0。由于接收端只对 value==1 做处理,echo 天然安全,不需要额外的抑制逻辑。


设备列表管理

1
2
3
4
5
6
7
8
9
10
扫描发现设备
→ ble_update_device_list()
→ RSSI < -85 ? 丢弃
→ memcmp MAC 去重 ? 更新 RSSI
→ 添加新条目 → ui_scanned_count++

用户按左/右键
→ ble_prev_mac() / ble_next_mac()
→ ble_get_scanned_device(index)
→ ble_page_set_device_name() → 屏幕显示

扫描窗口参数优化后,filter_duplicates = 0 确保新设备快速出现在列表中。


关键设计决策

决策 原因
NimBLE 而非 Bluedroid 轻量 60KB vs 500KB,双角色够用
ble_task 50ms 轮询而非纯事件驱动 需要超时检测 + 心跳保活
ble_connect_flag 异步触发 解耦 UI 线程和 BLE 操作
click 回传而非 .val= 需要按钮 press/release 动画
filter_duplicates = 0 应用层去重,加快首次列表填充
WRITE_NO_RSP 而非 WRITE 打印数据量大,无响应减少往返

文件清单

文件 行数 职责
printer_scan.h 75 BLE 模块对外接口(12 个声明)
printer_scan.c ~1020 BLE 双角色 + 打印 + 设备列表(27 个函数)
tjc_uart2.c ~1300 串口屏通信 + 页面路由
key.c ~1150 物理按键 + 焦点导航
hello_world_main.c ~2500 主循环 + 采样校准
data.h 220 全局结构体定义