前言
不久前我用 ESP-IDF v5.5 在 M5Paper 上把青萍蓝牙温湿度计的 V40 UI 磨到了像素级——本地显示、SHT30 读取、AXP192 电量图标,全部跑稳了。但我这人就是闲不住,总想着能不能再把数据推到手机上,搞个“墨水屏 + 手机端”双屏联动的玩法。
正好最近刷到了 Dash IoT App(iOS / Android)。这玩意儿有点意思:手机通过 BLE 直连 ESP32,然后可以在线拖拖拽拽自定义 Dashboard,TextBox、Time Graph、Dial 什么的随心所欲。对于咱这种既想折腾硬件又不想写 App 的嵌入式爱好者来说,简直是现成的上位机方案。
Dash IoT 在 Arduino 里有官方支持的 DashioESP 库,把 NimBLE 底层那一堆 GATT 初始化、广播包拼接、WHO 握手时序全包圆了。于是我心一横,把 ESP-IDF 那套 V40 绘制逻辑原样搬到 Arduino,再裹一层 Dash IoT BLE,搞出了这个能同时站在 M5Paper 桌面上和 Dash App 手机屏里的温湿度计。
项目源码:https://github.com/HonestQiao/m5paper-weather/tree/main/arduino/m5paper_weather_dashio
| 主控板 | M5Paper (ESP32-D0WDQ6-V3) | 4.7" 960×540 EPD,16 级灰度 |
| 温湿度传感器 | SHT30 (板载) | I2C 地址 0x44 |
| 电源管理 | AXP192 | 电量百分比读取 |
M5Paper 没有给 SHT30 做高级封装,所以我直接通过 M5.In_I2C 发送原始 I2C 命令读取。
界面还是延续之前ESP-IDF中的V40版本界面设计,继续用内置的 7 段数码管字体 fonts::Font7,通过 setTextSize(scale) 逼近 V40 设计的像素高度:
温度整数:scale = 3.0f(≈144 px)
湿度整数:scale = 2.0f(≈96 px)
温度小数:scale ≈ 64/48(≈64 px)
湿度小数:scale = 1.0f(≈48 px)
°C 和 % 依然是纯手绘简单图形,圆环、蓝牙 Material 图标、电池图标、上下云龙纹全都在。最关键的是,所有坐标我已经硬编码成了绝对整数值,从 ESP-IDF 搬过来的时候基本不用动,稳得很。

三、Dash IoT 了解官方文档:https://dashio.io/documents/3.1 体系入门
Dash IoT 并不是单纯的一个手机 App,而是一套完整的 IoT 设备 ↔ 手机屏 的协议体系。它由两部分组成:
Dash IoT App(iOS / Android):负责扫描设备、加载 Dashboard、显示数据。
DeviceView Builder(网页端):在线拖拽各种控件,设计好界面后导出 configC64Str,即发到你邮箱的一段 Base64 压缩字符串。
我看过 Dash IoT 的 Controls V2 文档,它支持的控件非常多。我们这个项目主要用到两类:
TextBox —— 单行数值显示,带标题、单位、阈值颜色。
Time Graph —— 时间序列折线图,可定义多条曲线、不同颜色、左右 Y 轴。
此外还有 Knob、Dial、Button、Status 等控件,扩展性很强,以后想加风扇控制、灯光亮度调节,只要在 DeviceView Builder 里多拖两个控件、改改 processIncomingMessage 就行。
Dash IoT 的所有通信都遵循同一种类 TSV 格式:
字段之间用 \t(Tab)分隔
每条消息以 \n(换行)结束
这个 \n 在 Dashio 库里被定义为 END_DELIM。设备发给 App 的消息,以及 App 发给设备的命令,都靠这个格式解析。如果 \n 丢了,整条消息就会卡住不被处理。
Dash App 识别一个设备时,并不是只看蓝牙 MAC,而是看三个信息的组合:
字段本项目中的值含义
| Device Type | ESP32_Type | Dashboard 的模板钥匙,App 根据它来找对应的 layout |
| Device Name | DashIO_M5Paper | 用户看到的设备名称 |
| Device ID | BLE MAC 地址 | 唯一标识,格式如 3c:8a:1f:d7:a8:8a |
这里有个容易踩的坑:广播名(peripheral local name)和 Device Name 不是一回事。DashioESP 的源码显示,广播名必须是 DashIO_ + Device Type = DashIO_ESP32_Type,而 Device Name 是在 WHO 握手回复里告诉 App 的。如果开发者把广播名直接设成了设备名称(比如 DashIO_M5Paper),Dash App 就会搜不到设备。
App 连接设备后,不是直接读取数据,而是先走一套握手三板斧:
App 发送:
WHO
设备必须回复:
3c:8a:1f:d7:a8:8a WHO ESP32_Type DashIO_M5Paper 2
注意行首有一个 \t,后面依次是 Device ID、命令名、Device Type、Device Name、Layout Revision。
App 发送:
CONNECT
设备回复:
3c:8a:1f:d7:a8:8a CONNECT
同时我习惯在 CONNECT 回复后立刻主动推一次当前状态,这样用户连上后不用手动刷新就能看到温湿度。
当用户在 App 里下拉刷新,或切换回前台时,App 会发送:
STATUS
设备回复当前的 textBox、timeGraphLine 等控件状态。
Dash IoT 的数据推送不是 JSON,而是纯 Tab 分隔字符串。核心格式如下:
TextBox 推送:
textBox TB01 25.6
textBox = 控件类型
TB01 = 我在 DeviceView Builder 里给这个 TextBox 设的 Control ID
25.6 = 数值(如果 TextBox 是 Number format,这里绝对不能带单位后缀)
Time Graph 线条定义:
timeGraphLine IDTG L1 Temp line red yLeft
定义了一条名为 L1、标签 Temp、红色、实线、左 Y 轴的曲线。
Time Graph 数据点:
timeGraphPoint IDTG L1 2025-04-12T10:00:00Z 25.6
Dashio 库在内部会自动帮我们把 getTimeGraphPoint 生成成带时间戳的完整格式,不需要我手写 ISO 时间。
configC64Str 是一个 Base64 编码的 Dashboard 布局压缩包,里面包含了:
页面尺寸和背景色
每个控件的类型、位置、大小、Control ID
颜色主题、字体大小、阈值规则等
把它硬编码进 Sketch 后,用户在 App 里点击 Get Layout,设备就会通过 Notify 把这段 Base64 字符串分段发给 App,App 解码后立刻渲染出对应的控制面板。
这是我在调试时卡了半小时的地方。DashDevice 的构造函数第三个参数是 revision:
DashDevice dashDevice("ESP32_Type", configC64Str, 2);如果 revision 写成 0,或者你不传第三个参数(默认也是 0),App 点 Get Layout 时会弹出一个红色提示:
Attention: Layout Revision number must be greater than zero to download the layout.
这是因为 App 用 revision 来做本地缓存校验,只有大于 0 才认为这是一个合法的 layout。
如何获取/替换 Layout?
方法一:快速上手(本项目当前做法)
直接复制 DashIO_ESP32_temperature 示例自带的 configC64Str,它自带 TB01、TB02、TB03 和 IDTG,能立刻跑通。方法二:自定义高级玩法
打开 DashIO DeviceView Builder,在线拖拽 TextBox 和 Time Graph,把 Control ID 设成 TB01、TB02、IDTG,调好颜色和位置后点击 Export Layout。过一会儿你的邮箱会收到一封邮件,正文里就是新的 configC64Str,把它替换到代码里,revision 加 1,重新烧录即可。
在 Arduino IDE 或 PlatformIO 中安装以下库:
M5Unified — M5Stack 新一代统一硬件抽象层
M5GFX — LovyanGFX 的 M5 官方封装,负责墨水屏绘制
Dashio — Dash IoT 协议基础库(消息格式、Control ID 定义)
DashioESP — ESP32 专用传输层(BLE / TCP / MQTT)
这里要说明一下:DashioESP 并不是自己重写了一套 BLE 协议栈,它只是在你看不见的底层调用了 ESP-IDF NimBLE。所有 GAP 广播、GATT Service/Characteristic 注册、Notify 发送、连接状态机,本质上还是 NimBLE。但 DashioESP 把这些极其琐碎的 C 结构体初始化、UUID 字节序处理、Scan Response 拼接都包好了,你只需要写两行代码就能开始广播。
在 Arduino IDE 中,Board 选择:M5Stack / M5Paper。
五、核心代码解析5.1 屏幕驱动:M5Unified 是真的香
在 Arduino 框架下,M5Paper 的 IT8951 电子纸屏幕通过 M5Unified 库驱动。初始化两行代码就完事:
auto cfg = M5.config();M5.begin(cfg);
M5.begin(cfg) 内部把 EPD 控制器、AXP192 电源管理、I2C 总线、SD 卡槽全初始化好了。真正绘图时还是标准三板斧:
auto& dsp = M5.Display;dsp.startWrite();/* fillEllipse、drawString、fillRect 等绘图调用 */dsp.endWrite();dsp.display(); // 触发 EPD 全局刷新
M5Paper 板载 SHT30,I2C 地址 0x44。Arduino 下 M5Unified 提供 M5.In_I2C,用法和 ESP-IDF 的 m5::In_I2C 几乎一样,就是 vTaskDelay 换成了 delay()。
class SHT30 {
uint8_t _addr = 0x44;public:
bool read(float &temp, float &humi) {
uint8_t cmd[2] = { 0x2C, 0x06 };
if (!M5.In_I2C.start(_addr, false, 100000)) return false;
if (!M5.In_I2C.write(cmd, 2)) { M5.In_I2C.stop(); return false; }
M5.In_I2C.stop();
delay(15);
uint8_t buf[6];
if (!M5.In_I2C.start(_addr, true, 100000)) return false;
if (!M5.In_I2C.read(buf, 6, false)) { M5.In_I2C.stop(); return false; }
M5.In_I2C.stop();
uint16_t t_raw = ((uint16_t)buf[0] << 8) | buf[1];
uint16_t h_raw = ((uint16_t)buf[3] << 8) | buf[4];
temp = -45.0f + 175.0f * ((float)t_raw / 65535.0f);
humi = 100.0f * ((float)h_raw / 65535.0f);
return true;
}};M5Paper 的电源管理 IC 是 AXP192。M5Unified 封装为 M5.Power,读电量一行代码:
g_batt = M5.Power.getBatteryLevel(); // 返回 0~100
直接拿来驱动电池图标的填充条即可。
5.4 DashDevice / DashBLE 初始化顺序
DashDevice dashDevice("ESP32_Type", configC64Str, 2);DashBLE ble_con(&dashDevice, true);ble_con.setCallback(&processIncomingMessage);ble_con.begin();dashDevice.setup(ble_con.macAddress(), "DashIO_M5Paper");关键点:dashDevice.setup() 必须放在 ble_con.begin() 之后。因为 setup() 内部要读取 NimBLE 的 MAC 地址作为 deviceID,如果 NimBLE 还没初始化完毕,读到的 MAC 是空的,App 在 WHO 阶段就会判定设备非法并断开。
static void processIncomingMessage(MessageData *messageData) {
switch (messageData->control) {
case status:
case connect:
processStatus(messageData->connectionType);
break;
default:
break;
}}DashioESP 库已经帮我们把原始 Tab 分隔字符串解析成了 MessageData 结构体,我只需要根据 control 字段分发即可。who 的处理库内部已经自动完成,不需要开发者手写回复。
Dash App 的 TextBox 如果设成了 Number format,但它其实并不会做智能单位剥离,而是直接用 strtof 或类似函数尝试解析整行字符串。如果我在 payload 里加了单位后缀:
message += dashDevice.getTextBoxMessage("TB01", String(g_temp, 1) + " C");App 里这个 TextBox 就会一片空白,没有任何报错提示,搞得我以为 BLE 断连了。改成纯数字才正常:
message += dashDevice.getTextBoxMessage("TB01", String(g_temp, 1));message += dashDevice.getTextBoxMessage("TB02", String(g_humi, 1));单位的正确做法是在 DeviceView Builder 里设置控件的 Unit 属性,而不是在设备端硬编码。
Dash IoT 的 Time Graph 不是被动读取的,而是需要设备主动推送两样东西:
线条定义(getTimeGraphLine)—— 在 status / connect 时发送一次
数据点(getTimeGraphPoint)—— 定时发送,App 自动追加到曲线尾部
为了让温度(0~50℃)和湿度(0~100%RH)不挤在同一侧 Y 轴,我把温度放左轴(yLeft,红色),湿度放右轴(yRight,蓝色):
static void processStatus(ConnectionType connectionType) {
String message((char *)0);
message.reserve(256);
message += dashDevice.getTextBoxMessage("TB01", String(g_temp, 1));
message += dashDevice.getTextBoxMessage("TB02", String(g_humi, 1));
// 定义两条曲线
message += dashDevice.getTimeGraphLine("IDTG", "L1", "Temp", line, "red", yLeft);
message += dashDevice.getTimeGraphLine("IDTG", "L2", "Humi", line, "blue", yRight);
ble_con.sendMessage(message);}然后每分钟更新传感器时追加数据点:
static void update_sensors_and_screen() {
// ... 读取 SHT30 和电量 ...
update_screen();
if (g_ble_connected) {
String graphMsg = dashDevice.getTimeGraphPoint("IDTG", "L1", g_temp);
graphMsg += dashDevice.getTimeGraphPoint("IDTG", "L2", g_humi);
ble_con.sendMessage(graphMsg);
}}首次连接后大概要等 1~2 分钟,Graph 上才会出现第一个折线段。长期运行后会累积成完整的温湿度趋势图。
编译上传后,打开 Arduino 串口监视器(115200),连接 Dash App 时应看到如下日志:


搜索设备
界面设计


数据显示

arduino/m5paper_weather_dashio/ └── m5paper_weather_dashio.ino // 单一文件完整 Sketch
核心功能全部收敛在一个 .ino 文件中,约 475 行,包含:
V40 UI 精确绘制
SHT30 I2C 驱动
AXP192 电量读取
Dash IoT BLE 连接 + config layout 推送
1 分钟自动刷新 + Time Graph 双曲线数据推送
用 Arduino + DashioESP 来做 Dash IoT 设备,投入产出比非常高。库把 NimBLE 的脏活累活全包了,让我能把精力放回产品实现本身——也就是怎么把墨水屏上的温湿度显示做得更漂亮、更省电。
V40 的 UI 坐标和绘制逻辑全部保留在 setup() / loop() 的结构里。如果你是 Arduino 老玩家,这套代码拿来改刷新周期、加新控件、或者做 RTC 休眠唤醒,都会非常顺手。
后续可探索方向:
低功耗休眠:利用 M5Paper 的 EPD 特性,每小时唤醒一次刷新,续航能拉长到天级别;
本地数据缓存:把 24h 温湿度存到 SPIFFS,断电后重启也能恢复曲线;
自定义 Dashboard:在 Dash App 的 Edit Mode 里重新排列控件,然后 Export Layout,得到专属 configC64Str。
如果你也想复刻一个,直接把代码拖进 Arduino IDE,安装上面四个库,编译上传,几分钟就能在 M5Paper 上看到自己的青萍风温度计,并且手机上也能同步查看实时数据了!
我要赚赏金
