一、硬件介绍
Tab5 集成了双芯片架构和丰富的硬件资源,其主控采用基于 RISC‑V 架构的 ESP32‑P4 SoC,并配备 16MB Flash 与 32MB PSRAM,无线模块则选用 ESP32-C6-MINI-1U,支持 Wi-Fi 6;
还配备5英寸(1280×720 IPS)触控屏幕,以及2MP摄像头(1600×1200)、双麦克风阵列,3.5mm耳机孔与扬声器;
内置BMI270六轴传感器、实时时钟,板载HY2.0-4P,M5-Bus,GPIO_EXT排母和microSD卡槽等;
底部兼容NP‑F550可拆卸锂电池(具备充放电与实时监测电路);


系统框图

二、功能实现
1、Tab 5无线模块
Tab5的无线模块是ESP32-C6-MINI-1U,支持 Wi-Fi 6 / BLE 5,其天线系统可在内置 3D 天线与外部 MMCX 天线接口之间自由切换;
特性
ESP32-C6 是一款支持 2.4 GHz Wi-Fi 6、Bluetooth 5、Zigbee 3.0 及 Thread 1.3 系统级芯片 (SoC),集成了一 个高性能 RISC-V 32 位处理器和一个低功耗 RISC-V 32 位处理器、Wi-Fi、Bluetooth LE、802.15.4 基带和 MAC、 RF 模块及外设等;
硬件引脚连接

原理图

2、多功能环境采集模块
采集模块为Arduino Nicla Sense ME,主控为nRF52832 (64 MHz) 具有蓝牙功能;
配备了Bosch Sensortec的具有高线性、高精度,可探测气压、湿度、和温度的 BME688 四合一气体传感器,可检测挥发性有机化合物 (VOC) 和其他气体;


特性

系统框图

功能效果
主要功能效果:
通过Nicla Sense ME模块,将采集室内相关的环境数据(温湿度、CO2当量、气压、室内空气质量IAQ),通过板载的BLE功能将数据发送至Tab5开发板上;
并在Tab5的屏幕UI界面上进行相关数据的显示以及通过实时折线图进行数据采集分析(历史最大值 / 最小值),且当IAQ数值处于不同范围值时,会触发不同的语音播报功能。

1、数据参数解析
采集的数据有温度、湿度、CO2当量、气压、室内空气质量IAQ数值;
1、室内空气质量IAQ
室内空气质量(Indoor Air Quality,简称 IAQ)是指在一定时间和空间范围内,室内空气中所含各类物质(包括气体等)的浓度水平及其综合状态;
数值范围含义
0 - 50: 极好 (Excellent)
51 - 100: 良好 (Good)
101 - 150: 轻度污染 (Lightly polluted)
151 - 200: 中度污染 (Moderately polluted)
201 - 250: 重度污染 (Heavily polluted)
> 250: 严重污染 (Severely polluted)
2、CO2当量 (ppm)
数值范围含义

3、气压
测量的当前环境中的大气压力值,单位是百帕 (hPa)
4、温湿度
显示当前室内的环境温度(°C)、相对湿度(%)数据;
2、实物效果
实物搭建图:
模块和开发板均采用锂电池的供电方式;
采用锂电池供电的好处,可以将开发板 / 采集模块置于任何地方(仅需在蓝牙范围内即可),增加了移动和扩展性;
实物效果图:
整体界面:分为两部分(上 / 下);
上半部分:项目标题(室内环境监控系统);
下半部分:分为两排数据进行显示(每个数据显示:分为两大部分);
左侧为:历史数据变化趋势折线统计图【上方为历史最大值 / 下方为历史最小值】
右侧为:不同数据UI图标【下方为当前实时检测值】
第一排:显示当前的温度、湿度、CO2当量数据;
第二排:显示当前气压、IAQ数据;
当IAQ处于不同范围值时,会通过Tab5的扬声器播报不同的语音提醒音频,用于提醒用户当前的室内环境空气质量水平;

主要运行流程图

三、代码编写
1、模块采集部分
采集模块通过BLE功能(ble_server),每秒发送采集到的相关数据到连接的BLE设备(ble_client)上;
数据示例:

主要相关代码
// UUID 格式
#define BLE_SENSE_UUID(ID) ("19b10000-" ID "-537e-4f6c-d104768a1214")
BLEService service(BLE_SENSE_UUID("0000"));
// 设置传感器特征值
BLEFloatCharacteristic temperatureCharacteristic(BLE_SENSE_UUID("1001"), BLERead | BLENotify); //温度
BLEUnsignedIntCharacteristic humidityCharacteristic(BLE_SENSE_UUID("2001"), BLERead | BLENotify); //湿度
BLEFloatCharacteristic pressureCharacteristic(BLE_SENSE_UUID("3001"), BLERead | BLENotify); //气压
BLEUnsignedIntCharacteristic co2Characteristic(BLE_SENSE_UUID("4002"), BLERead | BLENotify); // CO2数值
BLEUnsignedIntCharacteristic bsecCharacteristic(BLE_SENSE_UUID("5001"), BLERead | BLENotify); // 空气质量指数
...
void setup(){
...
// 蓝牙设备名
name = "AirQuality-Module";
//广播扫描时显示
BLE.setLocalName(name.c_str());
BLE.setDeviceName(name.c_str());
BLE.setAdvertisedService(service);
// 添加特征值
service.addCharacteristic(temperatureCharacteristic);
service.addCharacteristic(humidityCharacteristic);
service.addCharacteristic(pressureCharacteristic);
service.addCharacteristic(bsecCharacteristic);
service.addCharacteristic(co2Characteristic);
// 断开连接事件
BLE.setEventHandler(BLEDisconnected, blePeripheralDisconnectHandler);
BLE.addService(service);
// 广播
BLE.advertise();
}
void loop(){
BHY2.update();
// 监听蓝牙设备连接
BLEDevice central = BLE.central();
// 有设备连接
if (central) {
Serial.println(central.address());
while (central.connected()) {
// 更新传感器数据
BHY2.update();
// 每秒发送一次数据
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= 1000) {
previousMillis = currentMillis;
// 连接时亮蓝灯
nicla::leds.setColor(blue);
// 更新数据
updateValues();
}
}
}
}
// 数据更新
void updateValues() {
// 获取当前传感器数值
float currentTemp = temperature.value();
uint32_t currentHum = humidity.value();
float currentPress = pressure.value() / 100.00; // Pa 转 hPa
uint32_t currentIAQ = bsec.iaq();
uint32_t currentCO2 = bsec.co2_eq();
// 写入特征值
temperatureCharacteristic.writeValue(currentTemp);
humidityCharacteristic.writeValue(currentHum);
pressureCharacteristic.writeValue(currentPress);
bsecCharacteristic.writeValue(currentIAQ);
co2Characteristic.writeValue(currentCO2);
}2、Tab5部分
原本是使用Arduino IDE完成相关功能的开发,但发现开发环境还存在一些问题,目前暂时无法实现;
原因详见文末
所以最后只好使用支持Micropython的官方工具uiflow2网页,来进行相关功能的开发了;
相关硬件功能支持比较完善
相关代码功能介绍
上传相关资源到板载设备的flash中(音频、字库、图像)

1、存储相关的音频文件
根据不同的IAQ范围数值变化,会进行不同语音提醒

2、生成的字库文件
将自定义的中文.ttf字库转换.vlw格式进行更好的UI显示适配,用于标题等内容显示

3、相关的UI界面图像
用于UI界面的图标显示
主要相关功能
1、BLE功能(Ble_Central)
找到目标设备后自动连接;
发现指定 Service / Characteristics;
给各个特征的 CCCD 写 0x0001(开启订阅 Notify);
2、UI显示
3、IAQ分段语音提醒
# ============================================================
# IAQ 语音功能
# ============================================================
# 区间定义 / 相关音频文件
# (upper_bound, level_id, mp3_path)
IAQ_LEVELS = [
(50, 0, '/flash/res/audio/iaq_excellent.wav'),
(100, 1, '/flash/res/audio/iaq_good.wav'),
(150, 2, '/flash/res/audio/iaq_lightly.wav'),
(200, 3, '/flash/res/audio/iaq_moderately.wav'),
(250, 4, '/flash/res/audio/iaq_heavily.wav'),
(9999,5, '/flash/res/audio/iaq_severely.wav'),
]
_last_iaq_level_id = None #只在区间变化时播放
...
def iaq_audio_on_update(iaq_value):
global _last_iaq_level_id
level_id, path = iaq_level_id_of(iaq_value)
if _last_iaq_level_id is None:
_last_iaq_level_id = level_id
if level_id != _last_iaq_level_id:
_last_iaq_level_id = level_id
play_wav(path)
# ============================================================
# UI布局参数
# ============================================================
SCREEN_W = 1280
SCREEN_H = 720
X_PAD = 30
UI_TOP_SHIFT = 45
# 标题内容
TITLE_TEXT = "室内环境监控系统"
...
# UI 刷新
UI_REFRESH_MS = 120
CCCD_WRITE_INTERVAL_MS = 350
# ============================================================
# BLE_UUID 相关参数
# ============================================================
TARGET_UUID_ADV = bytes.fromhex("14128a7604d16c4f7e5300000000b119")
SERVICE_UUID = bluetooth.UUID("19b10000-0000-537e-4f6c-d104768a1214")
UUID_TEMP = bluetooth.UUID("19b10000-1001-537e-4f6c-d104768a1214")
UUID_HUM = bluetooth.UUID("19b10000-2001-537e-4f6c-d104768a1214")
UUID_PRES = bluetooth.UUID("19b10000-3001-537e-4f6c-d104768a1214")
UUID_CO2 = bluetooth.UUID("19b10000-4002-537e-4f6c-d104768a1214")
UUID_IAQ = bluetooth.UUID("19b10000-5001-537e-4f6c-d104768a1214")
CHAR_UUIDS = (UUID_TEMP, UUID_HUM, UUID_PRES, UUID_CO2, UUID_IAQ)
# IRQ constants
_IRQ_SCAN_RESULT = 5
_IRQ_SCAN_DONE = 6
_IRQ_PERIPHERAL_CONNECT = 7
_IRQ_PERIPHERAL_DISCONNECT = 8
_IRQ_GATTC_SERVICE_RESULT = 9
_IRQ_GATTC_SERVICE_DONE = 10
_IRQ_GATTC_CHARACTERISTIC_RESULT = 11
_IRQ_GATTC_CHARACTERISTIC_DONE = 12
_IRQ_GATTC_NOTIFY = 18
# ============================================================
# 数据格式布局
# ============================================================
vals = {"temp": "--", "hum": "--", "pres": "--", "co2": "--", "iaq": "--"}
_last_drawn = {"temp": None, "hum": None, "pres": None, "co2": None, "iaq": None}
history = {"temp": [], "hum": [], "pres": [], "co2": [], "iaq": []}
hist_dirty = {k: True for k in history}
def parse_float(b):
return round(struct.unpack("<f", b)[0], 2)
def parse_u32(b):
return int(struct.unpack("<I", b)[0])
def ticks_ms():
try:
return time.ticks_ms()
except:
return int(time.time() * 1000)
_CARD = {
"temp": (0, 0),
"hum": (0, 1),
"co2": (0, 2),
"pres": (1, 0),
"iaq": (1, 1),
}
CARD_W = SPARK_TEXT_W + SPARK_W + SPARK_GAP + ICON_SIZE + 14
CARD_H = max(ICON_SIZE, SPARK_H) + VALUE_BOX_H + 18
INNER_W = SCREEN_W - X_PAD * 2
def _row_start_x(card_count, gap_col):
total = card_count * CARD_W + (card_count - 1) * gap_col
return X_PAD + max(0, (INNER_W - total) // 2)
TOP_START_X = _row_start_x(3, GAP_COL_TOP)
BOT_START_X = _row_start_x(2, GAP_COL_BOT)
TOP_Y = GRID_TOP
BOT_Y = GRID_TOP + CARD_H + GAP_ROW
def card_xy(row, col):
if row == 0:
return TOP_START_X + col * (CARD_W + GAP_COL_TOP), TOP_Y
else:
return BOT_START_X + col * (CARD_W + GAP_COL_BOT), BOT_Y
def rects_for_metric(metric_key):
row, col = _CARD[metric_key]
cx, cy = card_xy(row, col)
tx = cx
ty = cy + 50
sx = cx + SPARK_TEXT_W
sy = cy + 6
ix = sx + SPARK_W + SPARK_GAP
iy = cy + 15
vx = ix
vy = cy + ICON_SIZE + 6
return (tx, ty, SPARK_TEXT_W, SPARK_H), (sx, sy, SPARK_W, SPARK_H), (ix, iy), (vx, vy, ICON_SIZE, VALUE_BOX_H)
# ============================================================
# UI 图标数据绘制
# ============================================================
def ui_draw_title_center(text):
x = max(X_PAD, (SCREEN_W - len(text) * TITLE_APPROX_CHAR_W) // 2)
M5.Lcd.setTextColor(FG, BG)
M5.Lcd.setCursor(x, TITLE_Y)
M5.Lcd.print(text)
def ui_draw_static():
M5.Lcd.clear(BG)
M5.Lcd.setRotation(1)
ui_draw_title_center(TITLE_TEXT)
icon_paths = {
"temp": "/flash/res/img/temp.png",
"hum": "/flash/res/img/hum.png",
"pres": "/flash/res/img/pres.png",
"co2": "/flash/res/img/co2.png",
"iaq": "/flash/res/img/iaq.png",
}
for k in ("temp", "hum", "co2", "pres", "iaq"):
(tx, ty, tw, th), (sx, sy, sw, sh), (ix, iy), (vx, vy, vw, vh) = rects_for_metric(k)
if 0 <= ix <= SCREEN_W - ICON_SIZE and 0 <= iy <= SCREEN_H - ICON_SIZE:
M5.Lcd.drawPng(icon_paths[k], ix, iy)
M5.Lcd.fillRect(tx, ty, tw, th, BG)
M5.Lcd.drawRect(sx, sy, sw, sh, SPARK_AXIS)
M5.Lcd.fillRect(vx, vy, vw, vh, BG)
def _fmt_value(metric_key, v):
if v == "--":
return "--"
if metric_key == "temp":
return "{} ℃".format(v)
if metric_key == "hum":
return "{} %".format(v)
if metric_key == "pres":
return "{} hPa".format(v)
if metric_key == "co2":
return "{} ppm".format(v)
return "{}".format(v)
def _fmt_minmax(metric_key, v):
if v is None:
return "--"
if metric_key in ("temp", "pres"):
return "{:.2f}".format(v)
return "{}".format(int(v))
def ui_draw_value_if_changed(metric_key):
if vals[metric_key] == _last_drawn[metric_key]:
return
(_, _, _, _), (_, _, _, _), (ix, iy), (vx, vy, vw, vh) = rects_for_metric(metric_key)
M5.Lcd.fillRect(vx, vy, vw, vh, BG)
txt = _fmt_value(metric_key, vals[metric_key])
M5.Lcd.setTextColor(FG, BG)
x = vx + max(0, (vw - len(txt) * VALUE_APPROX_CHAR_W) // 2)
y = vy + VALUE_TEXT_Y
M5.Lcd.setCursor(x, y)
M5.Lcd.print(txt)
_last_drawn[metric_key] = vals[metric_key]
def ui_draw_values_if_changed():
for k in ("temp", "hum", "pres", "co2", "iaq"):
ui_draw_value_if_changed(k)
def hist_add(metric_key, v):
arr = history[metric_key]
arr.append(v)
if len(arr) > HIST_N:
arr.pop(0)
hist_dirty[metric_key] = True
def ui_draw_spark(metric_key):
(tx, ty, tw, th), (sx, sy, sw, sh), (ix, iy), (vx, vy, vw, vh) = rects_for_metric(metric_key)
M5.Lcd.fillRect(tx, ty, tw, th, BG)
M5.Lcd.fillRect(sx + 1, sy + 1, sw - 2, sh - 2, BG)
M5.Lcd.drawRect(sx, sy, sw, sh, SPARK_AXIS)
arr = history[metric_key]
M5.Lcd.setTextColor(SPARK_TEXT, BG)
if len(arr) == 0:
M5.Lcd.setCursor(tx, ty -85); M5.Lcd.print("-")
M5.Lcd.setCursor(tx, ty + sh -30); M5.Lcd.print("-")
return
mn = min(arr)
mx = max(arr)
if mx == mn:
mx = mn + 1
M5.Lcd.setCursor(tx, ty -85)
M5.Lcd.print(_fmt_minmax(metric_key, max(arr)))
M5.Lcd.setCursor(tx, ty + sh -30)
M5.Lcd.print(_fmt_minmax(metric_key, min(arr)))
if len(arr) < 2:
return
n = len(arr)
x0 = sx + 1
y0 = sy + sh - 2
prev_px = None
prev_py = None
for i, v in enumerate(arr):
px = x0 + (i * (sw - 2)) // (n - 1)
py = y0 - int((v - mn) * (sh - 3) / (mx - mn))
if prev_px is not None:
M5.Lcd.drawLine(prev_px, prev_py, px, py, SPARK_COLOR)
prev_px, prev_py = px, py
def ui_draw_sparks_if_dirty():
for k in ("temp", "hum", "pres", "co2", "iaq"):
if hist_dirty[k]:
ui_draw_spark(k)
hist_dirty[k] = False
# ============================================================
# BLE 功能
# ============================================================
ble = bluetooth.BLE()
ble.active(True)
target_addr_type = None
target_addr = None
should_connect = False
scan_done = False
conn_handle = None
connected = False
svc_start = None
svc_end = None
value_handle_by_uuid = {}
pending_cccd_writes = []
subscribed = False
def reset_state():
global target_addr_type, target_addr, should_connect, scan_done
global conn_handle, connected, svc_start, svc_end
global value_handle_by_uuid, pending_cccd_writes, subscribed
target_addr_type = None
target_addr = None
should_connect = False
scan_done = False
conn_handle = None
connected = False
svc_start = None
svc_end = None
value_handle_by_uuid = {}
pending_cccd_writes = []
subscribed = False
def adv_has_target(payload):
i = 0
n = len(payload)
while i + 1 < n:
ln = payload[i]
if ln == 0:
break
t = payload[i + 1]
if t in (0x06, 0x07):
ad = bytes(payload[i + 2 : i + 1 + ln])
return (TARGET_UUID_ADV in ad)
i += 1 + ln
return False
def start_scan():
gc.collect()
reset_state()
ble.gap_scan(6000, 60000, 30000)
def irq(event, data):
global target_addr_type, target_addr, should_connect, scan_done
global conn_handle, connected, svc_start, svc_end
global pending_cccd_writes, subscribed
try:
if event == _IRQ_SCAN_RESULT:
addr_type, addr, adv_type, rssi, payload = data
if (not should_connect) and adv_has_target(payload):
target_addr_type = addr_type
target_addr = bytes(addr)
should_connect = True
ble.gap_scan(None)
elif event == _IRQ_SCAN_DONE:
scan_done = True
elif event == _IRQ_PERIPHERAL_CONNECT:
ch, addr_type, addr = data
conn_handle = ch
connected = True
ble.gattc_discover_services(conn_handle)
elif event == _IRQ_PERIPHERAL_DISCONNECT:
connected = False
subscribed = False
should_connect = True
elif event == _IRQ_GATTC_SERVICE_RESULT:
ch, start, end, uuid = data
if uuid == SERVICE_UUID:
svc_start = start
svc_end = end
elif event == _IRQ_GATTC_SERVICE_DONE:
if svc_start is not None:
ble.gattc_discover_characteristics(conn_handle, svc_start, svc_end)
elif event == _IRQ_GATTC_CHARACTERISTIC_RESULT:
ch, def_h, val_h, props, uuid = data
for u in CHAR_UUIDS:
if uuid == u:
value_handle_by_uuid[u] = val_h
elif event == _IRQ_GATTC_CHARACTERISTIC_DONE:
pending_cccd_writes = []
for u in (UUID_TEMP, UUID_HUM, UUID_PRES, UUID_IAQ, UUID_CO2):
vh = value_handle_by_uuid.get(u)
if vh is None:
continue
pending_cccd_writes.append(vh + 1)
subscribed = False
elif event == _IRQ_GATTC_NOTIFY:
ch, value_handle, notify_data = data
b = bytes(notify_data)
if value_handle == value_handle_by_uuid.get(UUID_TEMP) and len(b) >= 4:
v = parse_float(b[:4])
vals["temp"] = v
hist_add("temp", v)
elif value_handle == value_handle_by_uuid.get(UUID_HUM) and len(b) >= 4:
v = parse_u32(b[:4])
vals["hum"] = v
hist_add("hum", v)
elif value_handle == value_handle_by_uuid.get(UUID_PRES) and len(b) >= 4:
v = parse_float(b[:4])
vals["pres"] = v
hist_add("pres", v)
elif value_handle == value_handle_by_uuid.get(UUID_CO2) and len(b) >= 4:
v = parse_u32(b[:4])
vals["co2"] = v
hist_add("co2", v)
elif value_handle == value_handle_by_uuid.get(UUID_IAQ) and len(b) >= 4:
v = parse_u32(b[:4])
print("IAQ RAW:", v, "bytes:", b[:4])
vals["iaq"] = v
hist_add("iaq", v)
iaq_audio_on_update(v)
except Exception as e:
print("IRQ error:", e)
ble.irq(irq)
# ============================================================
# main
# ============================================================
M5.begin()
#加载字库
M5.Lcd.loadFont("/flash/res/font/font.vlw")
#扬声器初始化
speaker_init()
#UI界面初始化
ui_draw_static()
ui_draw_values_if_changed()
ui_draw_sparks_if_dirty()
#开启BLE扫描
start_scan()
last_ui_refresh_ms = 0
last_cccd_write_ms = 0
while True:
# 连接目标设备
if should_connect and (not connected) and target_addr is not None:
try:
ble.gap_connect(target_addr_type, target_addr)
except Exception as e:
print("connect failed:", e)
start_scan()
should_connect = False
# 开启订阅
if connected and (not subscribed) and pending_cccd_writes:
now = ticks_ms()
if now - last_cccd_write_ms > CCCD_WRITE_INTERVAL_MS:
cccd = pending_cccd_writes.pop(0)
gc.collect()
try:
ble.gattc_write(conn_handle, cccd, b"\x01\x00", 1)
except Exception:
pending_cccd_writes.append(cccd)
last_cccd_write_ms = now
if not pending_cccd_writes:
subscribed = True
# 断开重新连接
if scan_done and (not connected) and (target_addr is None) and (not should_connect):
time.sleep(0.3)
start_scan()
# UI 刷新显示
now = ticks_ms()
if now - last_ui_refresh_ms > UI_REFRESH_MS:
ui_draw_values_if_changed()
ui_draw_sparks_if_dirty()
last_ui_refresh_ms = now
time.sleep(0.02)四、程序烧录
1、连接USB数据线至开发板;
2、选择端口号对应的开发板;
3、点击 下载 烧录程序到开发板上;


五、效果演示
效果演示

六、总结
踩坑记录:使用Arduino IDE开发:
Tab5的M5官方库的所有BLE例程目前全部都无法使用,发现官方的库并没有实现蓝牙相关功能的API接口;
然后打算使用第三方NimBLE库进行开发,却发现还是无法使用;
最后查看 esp-hosted-mcu的功能实现,才发现目前关于BLE功能的相关Issues还没有解决 / 更新;
Tab5是 P4+C6(SDIO)方式进行通讯的,通过【esp-hosted-mcu】功能实现;


所以目前只是适配了P4的WIFI功能,BLE功能目前还没有适配(可能要在下个版本ESP32Core_3.3.6+后才开始适配)



而且目前官方M5Stack的esp32Core依赖还是3.2.5的版本;
所以目前硬件功能支持比较好的,还是使用Micropython固件进行开发了




我要赚赏金
