本项目是一套基于 M5Paper 客户端 + ESP32-P4 服务端的智能电子工牌系统。M5Paper 作为 BLE 外围设备和墨水屏显示终端,通过低功耗蓝牙与 P4 服务端通信,实现工牌信息展示、自动打卡、无线配置等功能。
这篇文章,给大家重点分享 M5Paper 客户端的实现细节,包括墨水屏 UI 设计、BLE 通信协议、本地数据存储、以及与 P4 服务端的交互逻辑。
项目的源码,可以直接访问:https://github.com/HonestQiao/e-badge
2. M5Paper 硬件平台
M5Paper 是本系统的显示终端,核心硬件参数如下:
| 主控 | ESP32-D0WD |
| 显示屏 | 4.7 寸电子墨水屏,分辨率 540×960 |
| 颜色 | 黑白灰度(16 级灰阶) |
| 触控 | 支持多点电容触控 |
| 连接 | WiFi、蓝牙 5.0 |
| 存储 | Flash + Preferences(掉电不丢失) |
电子墨水屏(EPD)与传统 LCD/OLED 有本质区别,开发时必须特别注意:
断电画面保持:屏幕内容在断电后仍然保持,这是墨水屏的核心优势
刷新有延迟:全屏刷新需要约 500ms~1s,期间屏幕会闪烁
禁止连续快速刷新:连续刷新会导致残影、闪烁,严重时会永久损坏屏幕
高对比度适合静态显示:黑白分明的文字显示效果极佳,但不适合动画
因此,M5Paper 端的设计原则是:以静态展示为主,刷新间隔至少 2 秒以上。
3. 系统架构

M5Paper 在系统中承担两个角色:
BLE 外围设备(Peripheral):持续广播设备名称,供 P4 扫描发现
墨水屏显示终端:展示工牌信息、打卡状态、等待配置等界面
4. 核心功能模块4.1 工牌信息展示
工牌展示是系统的核心界面,采用竖屏居中布局,整体风格简洁正式,符合企业工牌视觉规范。

屏幕尺寸为 540×960 像素,各元素位置通过代码精确计算:
static constexpr int HEADER_H = 80; // 顶部标题栏高度 static constexpr int AVATAR_Y = 140; // 头像圆顶部 Y 坐标 static constexpr int AVATAR_R = 70; // 头像圆半径 static constexpr int NAME_Y = 340; // 姓名 Y 坐标 static constexpr int SEP_Y = 400; // 分隔线 Y 坐标 static constexpr int DEPT_Y = 465; // 部门 Y 坐标 static constexpr int TITLE_Y = 540; // 职位 Y 坐标 static constexpr int ID_Y = 615; // 工号 Y 坐标 static constexpr int STATUS_BAR_Y = 880; // 底部状态栏顶部 Y 坐标 static constexpr int STATUS_BAR_H = 80; // 底部状态栏高度4.1.2 视觉层次设计
从上到下分为五个视觉层次:
顶部标题栏(黑色背景,白色文字)
显示"电子工牌"四个字
使用 efontCN_24 中文字体,正常字号
头像区域(居中圆形)
绘制加粗圆框(3 层同心圆模拟边框粗细)
圆内显示姓名首字(UTF-8 前 3 字节,即一个汉字)
姓名首字使用 2 倍放大字体,作为视觉焦点
个人信息区(居中文字)
姓名:2 倍放大字体,最醒目
分隔线:双像素加粗横线,视觉分割
部门:1.5 倍字体
职位:1.5 倍字体
工号:1.2 倍字体,灰色显示,弱化处理
底部状态栏(黑色背景,白色文字)
显示打卡状态或提示信息
与顶部标题栏形成视觉呼应
墨水屏使用灰度值(0~255)表示颜色:
static constexpr uint8_t C_WHITE = 255; // 纯白 static constexpr uint8_t C_BLACK = 0; // 纯黑 static constexpr uint8_t C_GRAY = 240; // 浅灰(工号等次要信息)
所有文字和图形采用高对比度黑白配色,确保在各种光照条件下清晰可读。灰色仅用于工号等次要信息,降低视觉干扰。
4.1.4 关键绘制代码void Display::drawBadgeContent(const BadgeInfo& info, bool isConfigured) {
auto& dsp = M5.Display;
int cx = SCREEN_WIDTH / 2; // 水平中心点
dsp.startWrite();
// 白色背景
dsp.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT, C_WHITE);
// 顶部黑色标题栏
dsp.fillRect(0, 0, SCREEN_WIDTH, HEADER_H, C_BLACK);
dsp.setTextColor(C_WHITE);
drawCenteredText("电子工牌", cx, 30, &fonts::efontCN_24, 1.0f);
// 头像圆框(加粗)
int circleCenterY = AVATAR_Y + AVATAR_R;
drawThickCircle(cx, circleCenterY, AVATAR_R, C_BLACK, 3);
// 姓名首字(居中于圆心,大号字体)
String firstChar = info.name.substring(0, 3); // UTF-8 一个汉字 = 3 字节
dsp.setTextColor(C_BLACK);
drawCenteredText(firstChar.c_str(), cx, circleCenterY, &fonts::efontCN_24, 2.0f);
// 姓名(大号字体)
drawCenteredText(info.name.c_str(), cx, NAME_Y, &fonts::efontCN_24, 2.0f);
// 分隔线(加粗:绘制两条相邻像素线)
dsp.drawLine(60, SEP_Y, SCREEN_WIDTH - 60, SEP_Y, C_BLACK);
dsp.drawLine(60, SEP_Y + 1, SCREEN_WIDTH - 60, SEP_Y + 1, C_BLACK);
// 部门、职位、工号
drawCenteredText(info.dept.c_str(), cx, DEPT_Y, &fonts::efontCN_24, 1.5f);
drawCenteredText(info.title.c_str(), cx, TITLE_Y, &fonts::efontCN_24, 1.5f);
// 工号(灰色,弱化显示)
char idBuf[48];
snprintf(idBuf, sizeof(idBuf), "工号:%s", info.badgeId.c_str());
dsp.setTextColor(C_GRAY);
drawCenteredText(idBuf, cx, ID_Y, &fonts::efontCN_24, 1.2f);
// 底部状态栏
dsp.fillRect(0, STATUS_BAR_Y, SCREEN_WIDTH, STATUS_BAR_H, C_BLACK);
dsp.setTextColor(C_WHITE);
// ... 状态文字
dsp.endWrite();
}居中绘制技巧:使用 MC_DATUM(Middle Center)文本对齐模式,使 x, y 坐标成为文字的几何中心,简化居中计算。
void Display::drawCenteredText(const char* text, int x, int y,
const lgfx::IFont* font, float scale) {
auto& dsp = M5.Display;
dsp.setFont(font);
dsp.setTextSize(scale);
dsp.setTextDatum(MC_DATUM); // 以文字中心为基准点
dsp.drawString(text, x, y);
}4.2 打卡状态
打卡状态显示在屏幕底部黑色状态栏中,有三种状态:
| 未配置 | "未配置" | 本地无工牌信息 |
| 已配置未打卡 | "请尽快打卡" | 有工牌信息,但当日未打卡 |
| 已打卡 | "已打卡 HH:MM:SS" | P4 发送打卡通知,显示打卡时间 |
P4 在检测到 M5Paper 靠近(RSSI ≥ -70dBm)且当日未打卡时,通过 BLE 发送打卡通知:
// P4 发送: "CHECKIN:09:15:32"
if (json.startsWith("CHECKIN:")) {
String time = json.substring(8);
display.showCheckInStatus(time); // 更新屏幕显示
storage.saveCheckInTime(time); // 保存到本地
}showCheckInStatus() 仅刷新底部状态栏区域,避免全屏重绘:
void Display::showCheckInStatus(const String& time) {
checkInTime = time;
auto& dsp = M5.Display;
int cx = SCREEN_WIDTH / 2;
// 仅重绘状态栏区域
dsp.fillRect(0, STATUS_BAR_Y, SCREEN_WIDTH, STATUS_BAR_H, C_GRAY);
dsp.setTextColor(C_WHITE);
char buf[48];
snprintf(buf, sizeof(buf), "已打卡 %s", time.c_str());
drawCenteredText(buf, cx, STATUS_BAR_Y + STATUS_BAR_H / 2,
&fonts::efontCN_24, 1.0f);
dsp.display();
}4.2.2 跨天自动清除P4 每日会同步当前日期到 M5Paper。当检测到日期变化时,M5Paper 自动清除打卡状态:
if (json.startsWith("TIME:")) {
String date = json.substring(5);
String savedDate = storage.loadCurrentDate();
if (!savedDate.isEmpty() && savedDate != date) {
// 跨天了,清除打卡状态
storage.clearCheckInTime();
display.clearCheckInStatus();
// 恢复工牌显示,提示重新打卡
BadgeInfo info = storage.load();
if (info.configured) {
display.showBadge(info, true);
display.showPromptCheckIn();
}
}
storage.saveCurrentDate(date);
}4.3 等待配置界面
M5Paper 首次启动(或配置被清空后)会显示等待配置界面,提示用户在 P4 Web 管理页面进行配置。

顶部灰色标题栏:与已配置状态的黑色标题栏区分,表示未就绪
问号圆圈:视觉提示设备尚未配置
"等待配置...":主提示文字
"请在P4管理页面配置":操作指引
设备 ID:如 设备ID: D71F8A3C,供 Web 端识别选择
底部状态栏:显示"未配置"
设备 ID 基于 ESP32 的 eFuse MAC 地址生成,确保全局唯一:
String generateDeviceId() {
uint64_t mac = ESP.getEfuseMac();
char buf[13];
sprintf(buf, "%02X%02X%02X%02X",
(uint8_t)(mac >> 24), (uint8_t)(mac >> 16),
(uint8_t)(mac >> 8), (uint8_t)mac);
return String(buf);}取 MAC 地址后 4 字节(32 位),格式化为 8 位十六进制字符串,如 D71F8A3C。
4.3.3 配置清空触发条件以下情况会触发配置清空,进入等待配置状态:
新设备首次被 P4 检测到:P4 数据库中无该设备配置,发送 CLEAR_CONFIG 指令
Web 管理页面手动删除配置:P4 发送 CLEAR_CONFIG 指令
M5Paper 本地恢复出厂设置:手动清除 Preferences
if (json == "CLEAR_CONFIG") {
storage.clear(); // 清除工牌信息
storage.clearCheckInTime(); // 清除打卡时间
storage.clearCurrentDate(); // 清除日期
display.clearCheckInStatus(); // 清除屏幕状态
// ⚠️ 关键:延迟 2 秒再全屏刷新
delay(2000);
display.showWaitingConfig(deviceId);
}4.4 配置解析与自动重启
当用户在 P4 Web 管理页面提交配置后,P4 通过 BLE 将 JSON 配置发送到 M5Paper。
4.4.1 配置 JSON 格式{
"name": "张三",
"dept": "技术部",
"title": "高级工程师",
"id": "T10086"
}4.4.2 配置解析流程void parseConfig(const String& json) {
JsonDocument doc;
DeserializationError error = deserializeJson(doc, json);
if (error) {
Serial.print("[Config] JSON parse failed: ");
Serial.println(error.c_str());
return;
}
BadgeInfo info;
info.name = doc["name"] | "";
info.dept = doc["dept"] | "";
info.title = doc["title"] | "";
info.badgeId = doc["id"] | "";
info.configured = true;
if (info.name.isEmpty()) {
Serial.println("[Config] Name is empty, ignoring");
return;
}
// 保存到 Preferences
storage.save(info);
Serial.println("[Config] Saved to storage");
// 刷新屏幕
display.showBadge(info, true);
Serial.println("[Config] Display updated");
// 新配置下发后,如果没有当天打卡记录,提示打卡
String savedDate = storage.loadCurrentDate();
String savedTime = storage.loadCheckInTime();
if (savedDate.isEmpty() || savedTime.isEmpty()) {
display.showPromptCheckIn();
}
// 配置完成后自动重启,让 P4 重新检测并自动打卡
Serial.println("[Config] Auto-reboot in 2s...");
delay(2000);
ESP.restart();
}4.4.3 自动重启的设计意图配置完成后自动重启有两个目的:
让 P4 重新检测:重启后 BLE 服务重新初始化,P4 的扫描循环会再次发现该设备,触发自动打卡流程
确保状态一致:重启后从 Preferences 重新加载配置,避免内存与存储不一致
5. 本地数据存储(Preferences)
M5Paper 使用 ESP32 的 Preferences 库实现掉电不丢失的本地存储。所有数据存储在 badge 命名空间下。
5.1 存储数据结构| name | String | 姓名 |
| dept | String | 部门 |
| title | String | 职位 |
| id | String | 工号 |
| configured | bool | 是否已配置 |
| citime | String | 当日打卡时间(HH:MM:SS) |
| curdate | String | 当前日期(YYYY-MM-DD,用于跨天判断) |
class Storage {public:
void init();
BadgeInfo load(); // 加载工牌信息
void save(const BadgeInfo& info); // 保存工牌信息
void clear(); // 清除工牌信息
String loadCheckInTime(); // 加载打卡时间
void saveCheckInTime(const String& time);
void clearCheckInTime();
String loadCurrentDate(); // 加载当前日期
void saveCurrentDate(const String& date);
void clearCurrentDate();
};5.3 读写实现Preferences 以键值对形式存储,读写时自动处理 Flash 擦写:
BadgeInfo Storage::load() {
Preferences prefs;
BadgeInfo info;
if (!prefs.begin("badge", true)) { // true = 只读模式
info.configured = false;
return info;
}
info.name = prefs.getString("name", "");
info.dept = prefs.getString("dept", "");
info.title = prefs.getString("title", "");
info.badgeId = prefs.getString("id", "");
info.configured = prefs.getBool("configured", false);
prefs.end();
return info;
}设计要点:
每次读写都独立打开/关闭 Preferences,避免长时间占用
只读模式(true)不会触发 Flash 写入,延长寿命
所有数据使用同一个命名空间,简化管理
6. BLE 通信协议
M5Paper 作为 BLE 外围设备(Peripheral),P4 作为中心设备(Central)。通信基于 GATT 协议,使用自定义 UUID 的服务和特征值。
6.1 BLE 服务定义#define SERVICE_UUID "0000ABCD-0000-1000-8000-00805F9B34FB" #define CHARACTERISTIC_UUID "00001234-0000-1000-8000-00805F9B34FB"
| 服务 | 0000ABCD-0000-1000-8000-00805F9B34FB | 主服务 |
| 特征值 | 00001234-0000-1000-8000-00805F9B34FB | 读 / 写 |
M5Paper 启动后创建 BLE 服务并持续广播,设备名称格式为 M5B_ + 8 位设备 ID:
void BLEPeripheral::init(const String& deviceId) {
deviceName = String("M5B_") + deviceId; // 如: M5B_D71F8A3C
BLEDevice::init(deviceName.c_str());
pServer = BLEDevice::createServer();
BLEService* pService = pServer->createService(SERVICE_UUID);
pCharacteristic = pService->createCharacteristic(
CHARACTERISTIC_UUID,
BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE );
pCharacteristic->setCallbacks(new ConfigCallbacks());
pService->start();
BLEAdvertising* pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->setScanResponse(true);
BLEDevice::startAdvertising();
}6.3 消息协议P4 与 M5Paper 之间通过特征值的 Write 操作传输消息:
| 工牌配置 | P4 → M5Paper | {"name":"...","dept":"...","title":"...","id":"..."} | 下发工牌信息 |
| 打卡通知 | P4 → M5Paper | CHECKIN:HH:MM:SS | 当日首次打卡 |
| 时间同步 | P4 → M5Paper | TIME:YYYY-MM-DD | 日期同步,用于跨天判断 |
| 清空配置 | P4 → M5Paper | CLEAR_CONFIG | 清空本地配置 |
当 P4 通过 BLE 写入配置时,触发 onWrite 回调:
class ConfigCallbacks : public BLECharacteristicCallbacks {public:
void onWrite(BLECharacteristic* pCharacteristic) {
String value = pCharacteristic->getValue();
if (value.length() > 0 && gBLEPeripheral) {
gBLEPeripheral->configJson = value;
gBLEPeripheral->configReceived = true;
Serial.print("[BLE] Config received: ");
Serial.println(value);
}
}
};主循环中检查配置标志并处理:
void loop() {
blePeripheral.loop();
if (blePeripheral.isConfigReceived()) {
String json = blePeripheral.getConfigJson();
parseConfig(json); // 解析并处理配置
blePeripheral.clearConfigFlag();
}
delay(100);
}7. P4 Web 管理界面(简要)
P4 提供 Web 管理页面,用户通过浏览器访问 P4 的 IP 地址即可管理所有工牌设备。
7.1 页面结构
页面采用四标签设计:
| 打卡记录 | 显示所有设备的打卡历史,含姓名、部门、时间、RSSI |
| 检测记录 | 显示蓝牙扫描到的设备列表,实时更新 |
| 工牌信息 | 显示已保存的所有工牌配置,支持删除 |
| 工牌配置 | 选择在线设备,填写信息后下发到 M5Paper |
点击"工牌配置"标签,页面自动扫描并列出在线设备(广播名称前缀为 M5B_)
点击设备名称,自动从数据库加载已有配置填充表单(如无配置则留空)
填写姓名、部门、职位、工号
点击"发送配置到设备",P4 通过 BLE 将配置发送到 M5Paper
M5Paper 收到配置后自动重启,P4 重新检测到后自动完成打卡
8. 墨水屏特殊处理
电子墨水屏的物理特性决定了必须采取特殊的刷新策略,否则可能损坏屏幕或导致设备异常。
8.1 刷新冷却控制所有显示操作都经过刷新冷却检查,确保两次刷新间隔至少 2 秒:
#define REFRESH_COOLDOWN 2000 // 最小刷新间隔(ms)bool Display::canRefresh() {
unsigned long now = millis();
if (now - lastRefresh < REFRESH_COOLDOWN) return false;
lastRefresh = now;
return true;}8.2 局部刷新优化打卡状态更新时,仅重绘底部状态栏,避免全屏刷新:
// 仅刷新状态栏区域(540×80 像素) dsp.fillRect(0, STATUS_BAR_Y, SCREEN_WIDTH, STATUS_BAR_H, C_GRAY); // ... 绘制状态文字 dsp.display();
9. 代码结构
m5paper/ ├── m5paper.ino # 主程序:初始化、配置解析、主循环 ├── config.h # 配置常量:BLE UUID、屏幕尺寸、刷新控制 ├── storage.h/cpp # Preferences 存储封装 ├── display.h/cpp # 墨水屏 UI 绘制 └── ble_peripheral.h/cpp # BLE 外围设备:广播、GATT 服务、配置接收9.1 各文件职责
| m5paper.ino | 系统入口,负责初始化显示、存储、BLE,主循环处理配置接收 |
| config.h | 集中管理可配置常量,如 UUID、设备前缀、屏幕参数 |
| storage.cpp | 封装 Preferences 读写,提供 BadgeInfo 结构体存取 |
| display.cpp | 所有屏幕绘制逻辑,含工牌界面、等待界面、状态更新 |
| ble_peripheral.cpp | BLE 协议栈初始化,广播配置,处理 Central 的连接和写入 |
void setup() {
Serial.begin(115200);
display.init(); // 初始化墨水屏
storage.init(); // 初始化存储
deviceId = generateDeviceId(); // 生成唯一设备 ID
BadgeInfo info = storage.load();
if (info.configured) {
// 已配置:显示工牌,恢复打卡状态
display.showBadge(info);
String savedCheckIn = storage.loadCheckInTime();
if (!savedCheckIn.isEmpty()) {
display.showCheckInStatus(savedCheckIn);
} else {
display.showPromptCheckIn();
}
} else {
// 未配置:显示等待配置页
display.showWaitingConfig(deviceId);
}
blePeripheral.init(deviceId); // 启动 BLE 广播}void loop() {
blePeripheral.loop();
if (blePeripheral.isConfigReceived()) {
String json = blePeripheral.getConfigJson();
parseConfig(json); // 解析配置/打卡通知/时间同步
blePeripheral.clearConfigFlag();
}
delay(100);
}10. 完整使用流程
以下是从零开始使用 M5Paper 电子工牌的完整流程:

M5Paper 上电后:
初始化墨水屏、Preferences、BLE
检查本地是否有工牌配置
无配置:显示等待配置界面,展示设备 ID(如 M5B_D71F8A3C)
开始 BLE 广播
用户在浏览器中访问 P4 的管理页面:
点击"工牌配置"标签
在设备列表中找到 M5B_D71F8A3C
点击设备名称,自动加载已有配置(如有)
填写姓名、部门、职位、工号
点击"发送配置到设备"
M5Paper 通过 BLE 收到配置 JSON:
解析 JSON,提取工牌信息
保存到 Preferences
刷新屏幕显示工牌信息
延迟 2 秒后自动重启
M5Paper 重启后:
重新初始化 BLE 并广播
P4 的扫描循环检测到 M5B_D71F8A3C
P4 检查数据库:该设备今日未打卡
P4 发送 CHECKIN:HH:MM:SS 通知
M5Paper 显示"已打卡"状态,保存打卡时间
M5Paper 持续显示工牌信息和打卡状态
墨水屏断电后画面保持,无需持续供电
跨天时 P4 发送新的 TIME:YYYY-MM-DD,M5Paper 自动清除打卡状态,提示重新打卡




我要赚赏金
