一句话理解
从"四块板各自能跑"到"PC 上看到一个会自己动的多传感器系统":四板四节点组网 → 协调器二进制上行 → Web 上位机,打通「采集—组网—聚合—呈现—反控」闭环。
实验 6、PC 端多点数据采集实验(综合实验箱版本)
副标题:四板四节点组网 → 协调器二进制上行 → Web 上位机,打通「采集—组网—聚合—呈现—反控」闭环
适用:物联网综合实践 / 传感器与传感网 / 单片机与接口技术 课时建议:4 学时(含扩展任务建议 6 学时) 前置:实验五《传感器数据收发》。单板怎么读传感器、51 与 CC2530 之间那 6/9 字节怎么来的,本指南不再重复。 配套:《协调器-PC-二进制协议.md》(帧格式权威定义)、《四板四节点-组网与烧录说明.md》(烧录与入网排查)、《51单片机-采集板串口协议.md》、《RGB蜂鸣器板-51工程与协议.md》——前两份已全文并入本页第三部分。 本文范围:从「四块板各自能跑」到「PC 上看到一个会自己动的多传感器系统」。
0. 实验目标与准备
0.1 学完你应该能做到
| 编号 | 能力目标 | 验收方式 |
|---|---|---|
| G1 | 画出四层链路图,说清每层的数据形态、单位、以及「谁负责转换」 | 检查点 1 的口头答辩 + 报告第 1 节 |
| G2 | 用「超级循环」与「OSAL 事件驱动」两种结构解释 51 与 CC2530 的分工,指出各自的阻塞点 | 报告第 2 节:画出两张流程图并标出阻塞位置 |
| G3 | 独立设计一条二进制串口协议(帧头 / 长度 / 校验 / 掩码),并说明每个字段防的是什么错 | 检查点 2 + 思考题 1、2 |
| G4 | 实现多节点并发采集,正确处理短地址变化、节点掉线、数据过期 | 检查点 3、4(幽灵节点与掉线场景) |
| G5 | 用「分层二分 + 多条观测通道」在 3 分钟内定位故障出在哪一层 | 检查点 4:教师现场注入 1 个故障 |
| G6 | 在没有硬件时用合成帧自测上位机,并解释为什么这不等于「绕过协议」 | 检查点 0 + 思考题 7 |
0.2 器材清单
| 器材 | 型号/规格 | 数量 | 本实验中的角色 |
|---|---|---|---|
| 光敏采集板 | STC12C5A60S2 + 光敏电阻(ADC1 / P1.1) | 1 | 上报光照 ADC 0~1023 |
| 温湿度采集板 | STC12C5A60S2 + AM2321(P1.1 单总线) | 1 | 上报温度、湿度 |
| 门禁采集板 | STC12C5A60S2 + 红外对射(ADC0 / P1.0) | 1 | 上报门开/门关 |
| 执行器板 | STC12C5A60S2 + RGB5050 + 有源蜂鸣器 | 1 | 接收状态码,出颜色与声响 |
| ZigBee 节点 | CC2530 模块 | 4 | 每块 51 板配 1 个 |
| 协调器 | CC2530 模块 + USB 转串口 | 1 | 组网 + 上行打包 + 下行转发 |
| 下载器 | CC Debugger | 1 | 配 IAR EW8051 8.0 |
| USB-TTL | CH340 / CP2102 | 1 | 调 51 侧时用(与协调器那根分开) |
| 杜邦线 | 母-母 | 10 根 | 交叉接 TX/RX,务必共地 |
⚠️ 两个 USB 转串口不要混用:协调器那根接 PC 的 COM 口跑 38400;调 51 的那根跑 9600。 插在同一台机器上时,设备管理器里会出现两个 CH340,认口只能靠拔插观察,不要靠猜。
0.3 软件与工程
| 软件 | 用途 | 关键配置 |
|---|---|---|
| IAR EW8051 8.0 | 编译/下载 CC2530 固件 | 两个配置:CoordinatorEB(协调器)、EndDeviceEB(节点) |
| Keil C51 | 编译 4 个 51 工程 | 晶振 11.0592 MHz,器件 STC12C5A60S2/16S2 |
| STC-ISP | 烧 51 的 hex | 冷启动下载(先点下载再上电) |
| Python 3.8+ | 跑上位机 | 需 pip install pyserial |
| Chrome / Edge | 看大屏 | 不要直接双击 html,必须由上位机提供 |
0.4 开始前自检(答不出就先回看)
- 掩码
0x18是哪两位?代表什么? → bit3 | bit4,温度 + 湿度。(0x18 = 0001 1000) - 38400 8N1 传 14 个字节要多久? → 14 × 10 bit ÷ 38400 ≈ 3.6 ms。
- 短地址
0x99D1在AA 55帧里怎么排? →D1 99(小端,低字节在前)。 - 一帧 CRC 校验通过,数据有没有可能还是错的? → 可能。CRC 只保证「这一串比特没在传输中翻转」,不保证「字段解释对了」。 把
0x18当成光照、把 mask 的高字节漏读,CRC 一样通过。 - 一块板重烧后短地址会变。那么上位机能不能拿短地址当「设备唯一编号」? → 不能。这是本实验最容易吃亏的一个认知,见 4.4 节。
0.5 本实验要交的东西
| 交付物 | 形式 | 判据 |
|---|---|---|
| 全链路拓扑图 + 数据形态表 | 报告插图 | 能标出每段的速率、帧长、校验方式 |
capture.log(--rec 抓包文件) | 文本 | 三个节点各自的帧都出现,且含至少一次状态码下行帧 |
| 端到端延时实测 | 表格 | 三次测量取平均,误差不超过 ±200 ms |
| 故障注入记录 | 表格 | 4 个故障:现象 / 定位层 / 根因 / 恢复动作 |
| 上位机界面截图 | 图片 | 含「在线节点 3/4」与一个非 0 状态码 |
1. 系统全貌:一张图看懂四层
1.1 拓扑
┌──────────┐ ADC1(P1.1) ┌──────────┐ UART 9600 ┌──────────┐
│ 光敏电阻 │ ────────────→ │ 51 光敏板│ ─────────────→ │ CC2530#1 │──┐
└──────────┘ └──────────┘ 6B 问 / 9B 答└──────────┘ │ 簇 0x0400
│
┌──────────┐ 单总线(P1.1) ┌──────────┐ UART 9600 ┌──────────┐ │ 802.15.4
│ AM2321 │ ────────────→ │ 51 温湿度│ ─────────────→ │ CC2530#2 │──┤ 信道 16
└──────────┘ └──────────┘ └──────────┘ │ PAN 0x1234
│
┌──────────┐ ADC0(P1.0) ┌──────────┐ UART 9600 ┌──────────┐ │
│ 红外对射 │ ────────────→ │ 51 门禁板│ ─────────────→ │ CC2530#3 │──┤
└──────────┘ └──────────┘ └──────────┘ │
│
┌──────────┐ RGB + 蜂鸣 ┌──────────┐ UART 9600 ┌──────────┐ │
│ 灯 + 喇叭│ ←────────────│ 51 执行器│ ←───────────── │ CC2530#4 │──┘
└──────────┘ 状态码 0~5 └──────────┘ └──────────┘
│
▼
┌──────────────────┐
┌───────────────┐ UART 38400 │ 协调器 │
│ PC 上位机 │ ←─────────────→ │ CC2530 + USB │
│ Python + 网页 │ AA 55 二进制帧 │ 状态表 8 格 │
└───────────────┘ 上行/下行双向 └──────────────────┘1.2 四层职责:每一层「只做一件事」
| 层 | 器件 | 只做 | 绝不做 | 出问题时的典型现象 |
|---|---|---|---|---|
| L1 感知 | 传感器 + 51 | 把物理量变成数字,装进 9 字节帧 | 不判超限、不组网、不记忆历史 | 串口助手看不到 9 字节回应 |
| L2 接入 | CC2530 节点 | 按节拍问 51、判死区、上报 ZCL | 不判断「要不要报警」 | 节点 Terminal I/O 无 [51 TH] 行 |
| L3 汇聚 | 协调器 | 记每个节点的最新值,打包成 AA 55 帧;转发下行 | 不做阈值判断(本项目明确规定) | [HB] peers=0、上行帧不来 |
| L4 呈现 | PC 上位机 | 解析、存储、判阈值、显示、下发 | 不直接碰任何硬件寄存器 | 页面红条、rx=0、卡片不动 |
这张表是本实验最重要的一张表。 后面 90% 的调试,本质就是先确认「问题在哪一层」,而不是一上来就翻代码。
1.3 数据在同一份读数上的「四级变形」
以「光照 590」为例,它在系统里会以四种完全不同的样子出现:
| 级 | 形态 | 值 | 单位 | 长度 | 谁负责转换 |
|---|---|---|---|---|---|
| ① 传感器 | 电压 → ADC 计数 | 590 | 1 LSB ≈ 1/1024 Vref | 2 字节 | 51 的 ADC |
| ② 51 帧 | Modbus 响应 | 01 03 04 02 4E 00 00 1D xx | 仍是 ADC 计数 | 9 字节 | 51 原样装帧 |
| ③ ZCL 属性 | 0x0400 的 MeasuredValue | 590 | lux(仅名义) | 2 字节 | 节点,本项目不做换算 |
| ④ PC JSON | {"light": 590} | 590 | 无 | — | 协调器打包 + Python 解析 |
温湿度会多一级换算(0.1 → 0.01),见实验五第 1 章。三级单位不一致,是本项目最容易出错的地方: 在 51 上看到的 253 是 25.3 ℃,在 ZCL 里是 2530,在网页上是 25.30,同一个温度三个数。
1.4 主线案例:手挡住光敏,650 ms 后网页变色
把这一条链路的时间账算清楚,你就理解了「为什么这套系统是这么设计的」。
| 时刻 | 发生了什么 | 证据在哪看 |
|---|---|---|
| t = 0 | 手挡住光敏,ADC 从 620 掉到 120 | — |
| t ≤ 300 ms | 节点定时器到期,发 01 03 00 2A 00 00 问 51 | 节点 Terminal I/O 或串口助手 |
| +7 ms | 51 采 ADC、装 9 字节帧回;节点解析成功 | [51 LIGHT] 590 之类的打印 |
| +1 ms | 死区判断:|120−620| = 500 ≥ 10 → 认为「真变化」 | 没有打印也能从 [RPT] 看出 |
| +30 ms | 空口传输,协调器收到 ZCL 属性上报 | 协调器 [RX] node=... mask=0x01 L=120 |
| +1 ms | 协调器更新状态表 → 立刻打包 12 字节 → 38400 发出 | 协调器 [TX] 无,上行是静默的 |
| +3.6 ms | PC 串口线程收到整帧,CRC 通过,入库 | /api/state 里 light 变了 |
| ≤ 250 ms | 网页轮询到,进度条与数字变化 | 页面 |
| 合计 | 0.35 ~ 0.62 s | 用 --rec 的时间戳可以精确量 |
对比:如果沿用「每 10 秒无条件上报一次」的旧策略,最坏要等 10.3 s。 这就是早期版本「手都摸完了网页才动」的真正原因 —— 不是串口慢,也不是网页慢, 而是上报策略决定的(见 4.6 节)。先量时间,再改代码,这是本实验要养成的第一习惯。
✅ 检查点 0:先证明「显示链路」是好的
目的:在上硬件之前,把「上位机 → 网页」这一段单独验通。这样后面任何异常,都可以立刻二分掉一半可能。
| 步 | 操作 |
|---|---|
| ① | 进入 pc上位机示例/,双击 启动模拟演示.bat |
| ② | 浏览器自动打开 http://127.0.0.1:8765/ |
| ③ | 盯 40 秒(一个完整循环),记录状态码变化 |
预期(这个剧本是写死的,必须分毫不差):
| 时间 | 光照 | 温度 | 门 | 自动判定 | 状态码 |
|---|---|---|---|---|---|
| 0~20 s | ~620 | 24 → 29 ℃ | 关 | 全部正常 | 0 |
| 20~26 s | 120 | ~29 ℃ | 关 | 光照 ADC 120 < 150 | 1 |
| 26~32 s | ~620 | 30 → 32 ℃ | 关 | 温度 30.72℃ ≥ 30.0℃ | 2 |
| 32~40 s | ~620 | 32 → 33 ℃ | 开 | 门开 / 有人进入 | 4 |
| 40 s 起 | 循环回 0 | 0 |
同时确认:
- 底部显示 「在线节点 3 / 4」(第 4 个执行器在第 30 秒才发第一帧
mask=0的地址帧,之后变 4) - 日志栏里方向为
tx的行带 「(模拟)」 后缀,十六进制形如AA 55 05 01 05 00 00 01 39 17 - 控制台窗口打印
数据源: 模拟(--fake)
判据:四个状态码按 0 → 1 → 2 → 4 出现过,就说明「解析 → 判定 → 下发组帧 → 网页渲染」这条链是通的。
为什么这一步值得单独做? 因为它是不需要任何硬件的基线。 后面只要出现问题,先跑一次
--fake:如果模拟模式正常、接硬件不正常,问题一定在「硬件 + 串口」这一侧; 如果模拟模式也不正常,那就是上位机或网页被改坏了。一个开关省掉半小时。
2. 程序结构:四种典型结构,各管一段
一个采集系统里能同时见到四种完全不同的程序结构。本节逐个拆,最后给四条可迁移原则。
2.1 51 层:超级循环 + 中断搬运
51 上没有操作系统,用最朴素的「主循环 + 中断」:
void UART1_RCV(void) interrupt 4 /* 中断只做三件事:收、存、置标志 */
{
if (RI) {
RI = 0;
if (!(uart1_wr == 0 && SBUF != 0x01)) { /* 帧头同步:只从 0x01 开始 */
if (uart1_wr < 6) RX0_Buffer[uart1_wr++] = SBUF; /* 越界保护 */
flag = 1; /* 只置标志,不处理 */
}
}
if (TI) { TI = 0; B_TI = 1; } /* 给 printf 的 putchar 放行 */
}
void main(void)
{
uart1_init();
P1ASF = (1 << ADC_CH1); /* P1.1 作模拟输入 */
ADC_CONTR = ADC_360T | ADC_ON;
while (1) {
if (flag == 1) {
delay_ms(5); /* 等这一帧剩下的字节收完 */
if (uart1_wr >= 6) { /* 收齐才处理:判长度,不能只判某一位 */
if (RX0_Buffer[0]==0x01 && RX0_Buffer[1]==0x03 && RX0_Buffer[3]==0x2A) {
j = adc10_start(1); /* 采样 */
/* ...装 9 字节帧、算 CRC、连发 9 字节... */
}
flag = 0; uart1_wr = 0; /* 成功失败都要复位 */
}
}
}
}三条纪律(违反任何一条都会出现「偶发死机」):
| 纪律 | 为什么 |
|---|---|
| 中断里只搬字节 | 9600 下 9 字节约 9.4 ms。在中断里算 CRC + 回帧,等于把中断关了 9.4 ms,下一个字节(1.04 ms 后到)直接丢 |
| 主循环判长度,不判标志 | flag 在收到第一个字节就置 1,此时后 5 个字节还在路上。只判 [3] 会被上一帧残留误触发 |
| 成功失败都复位缓冲区 | 只在成功分支清 flag,一旦收到半帧就永远卡住(原厂例程有这个问题) |
阻塞点在哪:delay_ms(5) 与 adc10_start() 都是死等。这时主循环不能做别的事 —— 这正是「一块 51 只干一种传感器」的原因。
提问点:为什么不把 CRC 计算和回帧直接写进中断里,岂不是更快? 答案是「更快」和「更可靠」在这里是矛盾的:中断服务程序越长,丢字节的概率越高。 嵌入式里有一条经验:中断服务程序超过 100 µs 就该重新设计。
2.2 CC2530 节点:OSAL 事件驱动
节点不能写 while(1) —— 协议栈自己占着主循环。Z-Stack 用 OSAL(Operating System Abstraction Layer)把主循环变成「事件轮询」:
main() → osal_init_system() → osal_start_system()
│
▼
┌───────────────────────┐
│ for(;;) { │
│ 找最高优先级任务 │
│ 有事件 → 调处理函数│
│ 没事件 → 睡到中断 │
│ } │
└───────────────────────┘应用层只需要实现两件事:注册事件、处理事件。
/* ① 定时器事件:自己给自己定时,不占用 CPU */
osal_start_timerEx(zclSampleTemperatureSensor_TaskID,
ZCL_SENSOR_REPORT_EVT, /* 自定义事件位 0x0008 */
ZCL_SENSOR_SAMPLE_PERIOD); /* 光敏 300ms / 温湿度 500ms */
/* ② 事件处理:所有业务都从这里进来 */
UINT16 zclSampleTemperatureSensor_ProcessEvent(byte task_id, UINT16 events)
{
if (events & SYS_EVENT_MSG) { /* 协议栈消息:入网状态等 */
/* ... ZDO_STATE_CHANGE / ZCL_INCOMING_MSG ... */
return (events ^ SYS_EVENT_MSG);
}
if (events & ZCL_SENSOR_REPORT_EVT) { /* 到点采样 */
/* 问 51 → 解析 → 判死区 → 需要才上报 */
return (events ^ ZCL_SENSOR_REPORT_EVT);
}
return 0;
}事件表(一个节点运行时真正在跑的东西):
| 事件 | 谁置的 | 干什么 | 典型周期 |
|---|---|---|---|
SYS_EVENT_MSG | 协议栈 | 处理入网状态变化、收到下行 ZCL 命令 | 事件驱动 |
ZCL_SENSOR_REPORT_EVT | osal_start_timerEx | 发读指令、解析、判死区、上报 | 200 / 300 / 500 / 1000 ms |
| 串口 RX 回调 | UART 中断 | 只把字节搬进缓冲、置 GotFrame | 收到才触发 |
51 与 CC2530 的结构对比:
| 维度 | 51(超级循环) | CC2530(OSAL) |
|---|---|---|
| 谁决定「下一步做什么」 | 程序员写的 if 顺序 | 事件优先级 + 定时器 |
| 能不能阻塞 | 能(delay_ms 随便用) | 不能,一阻塞协议栈就掉线 |
| 实时性来源 | 轮询周期 | 硬件定时器 |
| 加一个新传感器 | 改 while(1) 里的分支 | 加一个事件位 + 一个 case |
| 出错表现 | 死循环、不响应 | 看门狗复位、state 反复跳 |
记住一句话:51 是「我按顺序做完」,CC2530 是「谁到点谁说话」。 所以 51 侧可以写
delay_ms(5),CC2530 侧写delay_ms(5)就是埋雷。
2.3 协调器:状态表 + 打包(聚合器模式)
协调器收到某个节点任一簇的上报,不转发原始 ZCL,而是更新自己的一张表,然后把该节点的全量状态打包发出去:
#define ZC_PROTO_MAX_NODES 8 /* 只有 8 格:CC2530 的 XDATA 就 8KB */
typedef struct {
uint8 used; /* 槽是否有效 */
uint16 shortAddr; /* 节点短地址 —— 表的主键 */
uint16 mask; /* 该节点累计出现过的属性位 */
uint16 light; /* 0~1023 */
uint8 door; /* 0 关 / 1 开 */
int16 temp; /* 0.01℃(有符号!) */
uint16 humi; /* 0.01%RH */
} zcNodeState_t;
static void zcSendFrame(zcNodeState_t *n)
{
len = 1 + 2 + 2; /* 版本 + 掩码 + 地址 */
if (n->mask & ZC_ATTR_LIGHT) len += 2;
if (n->mask & ZC_ATTR_DOOR ) len += 1;
if (n->mask & ZC_ATTR_TEMP ) len += 2;
if (n->mask & ZC_ATTR_HUMI ) len += 2;
/* ...填帧头、掩码、地址、按掩码顺序填值、算 CRC、UART 发出... */
}为什么要「聚合」而不是「透传」?
| 方案 | 优点 | 缺点 |
|---|---|---|
| 透传(把 ZCL 原样转给 PC) | 协调器代码极简 | PC 要懂 ZCL/APS/NWK 三层;节点每报一个属性就来一帧;掉一帧就永远缺这个值 |
| 聚合(本项目) | PC 只认一种帧;一帧带全量,丢一帧下一帧就补回来;节点只需在变化时上报 | 协调器有容量上限;PC 看到的是「协调器认为的最新值」,不是「原始事件流」 |
容量上限不是随便写的:ZC_PROTO_MAX_NODES 从 16 改大会链接失败(Error[e16]: Segment XDATA_I is too long)。 每格约 12 字节,8 格已是协调器在跑完整协议栈后的余量。这是嵌入式里典型的「资源决定架构」。
协调器不做的事(本项目明确规定):
- 不判阈值、不算状态码 —— 那是上位机的活(见 4.5)
- 不记历史、不记时间戳 —— 它只保留「最新值」
- 不主动轮询节点 —— 节点自己上报,协调器只被动收
2.4 PC 层:三线程 + 单一状态中心 + 快照
上位机是这套系统里唯一有「操作系统」的一层,结构反而要最小心:
┌──────────────────────┐
│ Hub(唯一状态中心)│
│ nodes{} log[] ...│ ← 一把 threading.Lock 保护
└───▲───────▲──────┬───┘
│ │ │
ingest() │ │ │ snapshot()
│ │ ▼
┌──────────┴──┐ ┌──┴────────────┐ ┌────────────────┐
│ 串口线程 │ │ 判定线程 │ │ HTTP 线程池 │
│ 收帧→解析 │ │ 每 300ms 判 │ │ /api/state │
│ 38400 8N1 │ │ 变化即下发 │ │ /api/cmd │
└─────────────┘ └───────────────┘ └────────────────┘
▲
│ 网页每 250ms 轮询三个设计决策,每一个都有代价:
| 决策 | 理由 | 代价 |
|---|---|---|
| 串口线程与判定线程分离 | 串口断开时判定照样在算,一插上就能立刻补发当前状态 | 多一个线程、多一处锁 |
| 前端轮询而非 WebSocket 推送 | 250 ms 延迟可接受;curl /api/state 就能看到全部状态,调试成本极低 | 每秒 4 次请求,CPU 略高;不是「实时」 |
| 快照(snapshot)而非共享对象 | 前端拿到的是一次性不可变 JSON,永远不会读到「改到一半」的数据 | 每 250 ms 序列化一次,节点多时略费 |
快照是并发编程里最省事的一招,值得单独记住:
def snapshot(self):
with self.lock: # 只在拷贝时持锁,序列化在锁外
return {"link": {...}, "nodes": [...], "log": [...], "auto": {...}}反面教材:把
hub.nodes直接交给渲染函数,在渲染过程中串口线程又往里写 —— 轻则数字跳变,重则RuntimeError: dictionary changed size during iteration。 这类 bug 一晚上只能复现一次,非常难查。
2.5 四条可以迁移到别的项目的结构原则
- 采集与通信分离:51 只管读数,CC2530 只管收发。任一侧坏了,另一侧仍可单独验证。
- 中断只搬运,决策放主循环/任务:中断里
flag=1,不要在中断里算 CRC、不要在里面发长帧。 - 状态集中,输出快照:一个
Hub、一把锁、一个snapshot()。不要有第二个地方缓存节点数据。 - 判定与呈现分离:阈值在 PC,颜色在 51,中间只传一个 状态码 0~5。 这样改阈值不用重烧固件,改颜色不用改判定 —— 两边可以独立演进。
3. 串口设计:三条链路,三种取舍
3.1 先把三条链路摆清楚
| 链路 | 物理层 | 速率 | 帧格式 | 方向 | 校验 | 谁产生的 |
|---|---|---|---|---|---|---|
| 传感器 → 51 | 单总线 / ADC | — | 40 bit / ADC 计数 | 单向 | 校验和(AM2321) | 传感器 |
| 51 ↔ CC2530 | TTL UART | 9600 8N1 | Modbus RTU,6 字节问 / 9 字节答 | 双向问答 | CRC16 Modbus(0xA001) | 51 |
| CC2530 ↔ 协调器 | 802.15.4 无线 | 250 kbps,信道 16 | ZCL 属性上报 | 双向 | MAC 层 CRC | 协议栈 |
| 协调器 ↔ PC | USB-TTL UART | 38400 8N1 | AA 55 二进制帧 | 双向(上行数据 + 下行命令) | CRC16-CCITT(0x1021) | 协调器 |
三条链路、两套 CRC、两种字节序 —— 这是本项目最主要的认知负担,也是本实验要练的核心。
| 易混点 | 正确 | 错误后果 |
|---|---|---|
| 两种 CRC 混用 | Modbus 用 0xA001(反射),AA 55 帧用 0x1021 | 帧永远校验不过,但「长度看着对」 |
| 波特率 | 51↔节点 9600;协调器↔PC 38400 | 收到一堆乱码,或者干脆收不到 |
| 字节序 | 51 帧大端,AA 55 帧小端 | 温度 25.30 变成 253.0 或 6451.2 |
3.2 二进制还是 ASCII?—— 先算账,再选
同一条「光照 590」,两种编码的实际差别:
| 编码 | 内容 | 字节数 |
|---|---|---|
| 二进制 | AA 55 07 01 01 00 D1 99 4E 02 91 3A | 12 |
| ASCII(紧凑) | 0x99D1,590\n | 11 |
| ASCII(自解释) | NODE=0x99D1,LIGHT=590\n | 22 |
单帧长度居然差不多 —— 所以「二进制省带宽」在这个项目里不是主要理由。真正的差别在这四行:
| 维度 | 二进制 | ASCII |
|---|---|---|
| 定界 | 靠长度字段,切帧绝对确定 | 靠 \n,但 \n 可能出现在数据里(温度低字节恰好 0x0A) |
| 校验 | CRC16 覆盖全帧,错一位必被发现 | 通常没有校验;有也就是个异或和,检错能力弱 |
| 解析 | 固定偏移 + struct.unpack,不会「差不多对」 | atoi/sscanf,少个逗号就静默出错值 |
| 扩展 | 加一个掩码位即可 | 加字段就得改所有解析方,还得考虑兼容 |
| 排错 | 必须对着协议表看 | 肉眼可读,串口助手直接看懂 |
本项目的取舍:
- 链路上用二进制:可靠、可扩展、解析确定。
- 调试时把二进制「翻译」成人话:靠上位机日志栏、
--rec落盘、以及协调器的 Terminal I/O 打印。 - 绝不在链路上混用:不要「出问题时临时加一句
printf发到 UART0」—— 那会把二进制流冲乱(实验五 3.6 节的铁律)。
教学要点:很多同学以为「二进制高级、ASCII 低级」。真正的判断标准是 这条链路要不要跨越不可靠介质、要不要长期演进。 板上两根杜邦线直连、协议永不改,用 ASCII 完全合理; 一旦要过无线、要加传感器、要让别人写解析器,二进制 + 掩码 + CRC 就是必需品。
3.3 帧同步三件套:帧头 + 长度 + CRC
三个字段各防一种错,缺一不可:
| 只做到 | 会出的错 | 现象 |
|---|---|---|
只有帧头 AA 55 | 数据区恰好出现 AA 55 → 假同步 | 偶发解析出天文数字,且「过一会儿自己好了」 |
| 帧头 + 长度 | 长度字节被干扰 → 切错位置,后面所有帧连续错位 | 一帧坏,后面几十帧全坏,必须重启才好 |
| 帧头 + 长度 + CRC | 切错能被发现并丢弃,最坏丢一帧 | 单帧丢失,下一帧自动恢复 |
再加一条范围检查,这是从「教科书协议」到「能跑的协议」的分界线:
total = buf[2] + 5 # 长度字段 + 帧头2 + 长度1 + CRC2
if total < 10 or total > 64: # ★ 范围检查
del buf[:1] # 这个"长度"不可信,把它当成噪声丢掉,重新找帧头
continue
if len(buf) < total: # 半包,等更多字节
break如果不做
total范围检查会怎样? 噪声让长度字节变成0xFF,total就是 260 —— 程序会一直等 260 个字节。 而真实帧只有 14 字节,于是永远等不到,表现就是「串口明明在收数据,页面却不动」。 这是串口解析里最典型的「死等」bug。
3.4 粘包与半包:Python 端的三个坑(都有真实代码为证)
串口给你的不是「帧」,是字节流。一帧可能分两次到(半包),三帧也可能一次到(粘包)。 上位机用「缓冲区 + 循环切帧」处理,代码只有 20 行,但三个坑全在这里:
坑 1:ser.read(64) 会等到收满 64 字节
# ❌ 错:一帧只有 12 字节,每帧白等 200ms(timeout)
chunk = ser.read(64)
# ✅ 对:内核缓冲里有多少收多少,没有才阻塞等 1 个字节
waiting = ser.in_waiting
chunk = ser.read(waiting if waiting else 1)后果:不改的话,一帧数据要等 200 ms 超时才返回,页面看起来「一卡一卡的」。
坑 2:find 失败就 buf.clear(),会把半截帧头丢掉
# ❌ 错:0xAA 和 0x55 很可能落在两次 read 里
if s < 0:
buf.clear() # 把末尾那个孤零零的 0xAA 一起扔了 → 帧头永远拼不回来
# ✅ 对:只保留"可能是帧头首字节"的那一个字节
if s < 0:
if buf and buf[-1] == 0xAA:
del buf[:-1]
else:
buf.clear()
break后果:这个 bug 的表现极具欺骗性 —— rx 一直是 0,但串口确实在收数据。本次开发中真实踩过。
坑 3:len(buf) < 3 就去读 buf[2]
# ❌ 错:只收到 1 个字节时 buf[2] 直接 IndexError
total = buf[2] + 5
# ✅ 对:先确认够读长度字段
if len(buf) < 3:
break
total = buf[2] + 53.5 长度字段的两种用法:切帧 + 交叉验证
切帧是最低要求。更高一层的用法是用掩码反推长度,和 LEN 字段互相验证:
期望长度 = 5 + 2×bit0 + 1×bit1 + 2×bit3 + 2×bit4
│ 光敏 门禁 温度 湿度
└── 版本1 + 掩码2 + 地址2举例:掩码 0x18(温度+湿度)→ 5 + 2 + 2 = 9,整帧 9 + 5 = 14 字节。
expect = 5
if mask & 0x01: expect += 2
if mask & 0x02: expect += 1
if mask & 0x08: expect += 2
if mask & 0x10: expect += 2
if expect != vlen:
return None # 长度与掩码矛盾 → 这一帧不可信为什么值得加这 5 行:CRC 只能证明「比特没坏」,证明不了「这帧属于这套协议」。 如果一个旧版本固件(没有 mask 高位)和一个新版本上位机对接,长度对不上会被立刻发现, 而不是解析出一个「温度 0、湿度 0」的假数据。
这是协议自描述带来的额外红利:同一个字段被两个独立来源约束,比任何单点校验都强。
3.6 同一个系统里有两套字节序(最容易错的一处)
拿「湿度 61.4 %RH、温度 25.3 ℃」这一份读数,看它在两条链路上分别长什么样:
| 链路 | 字节 | 字节序 | 单位 | 怎么读 |
|---|---|---|---|---|
| 51 → CC2530 | 01 03 04 00 FD 02 66 DA 15 | 大端(高字节在前) | 0.1℃ / 0.1%RH | 00FD=253→25.3℃,0266=614→61.4% |
| 协调器 → PC | AA 55 09 01 18 00 13 0B E2 09 8C 17 xx xx | 小端(低字节在前) | 0.01℃ | E2 09=2530→25.30℃ |
同一份 25.3 ℃,在两条链路里:
- 51 帧:
00 FD(大端,单位 0.1) AA 55帧:E2 09(小端,单位 0.01)
三个量同时变了 —— 字节顺序、单位、以及「有没有做 ×10」。所以:
不要拿两条链路的字节直接比对。 要比对就先统一到十进制:
00FD→ 253 → 25.3℃;E2 09→ 2530 → 25.30℃。先在纸上算,再去代码里找。
PC 端小端解码:
# 小端 uint16
humi = buf[d] | (buf[d+1] << 8)
# 小端 int16(温度必须是有符号!零下会被解成 65000 多)
temp = struct.unpack("<h", bytes(buf[d:d+2]))[0]3.7 下行帧:命令字 + 地址 + 短 payload
下行帧和上行帧共用同一套帧头 / 长度 / CRC,只把「掩码 + 数据」换成「命令字 + 地址 + payload」:
[0] AA [1] 55 [2] LEN [3] 版本 [4] CMD [5] 地址低 [6] 地址高 [7..] payload [..] CRC16
└──────────── LEN 字节 ────────────────────────────────┘| CMD | 名称 | payload | 协调器实际动作 |
|---|---|---|---|
0x01 | On/Off | [0] 0/1 | ZCL 簇 0x0006 的 Off/On → 蜂鸣器 |
0x02 | Level | [0] 0~254 | ZCL 簇 0x0008 MoveToLevel(0 用写属性,见协议文档 7.6) |
0x03 | Color | [0][1][2] R,G,B | ZCL 簇 0xFC10 写属性 |
0x04 | AllOff | 无 | Off + Level=0 |
0x05 | SetState | [0] 状态码 0~5 | 写执行器 0xFC11 属性,执行器再串口发给 51 |
地址字段可以是 0,这是本项目一个巧妙的设计:
协调器不认识「谁是执行器」这件事本来需要 PC 来维护,但 PC 恰好不知道执行器什么时候入网、什么时候换地址。 于是让执行器入网后主动上报一帧
mask=0的空帧,协调器借这一帧记下它的地址; PC 下发时地址填 0 = 「发给协调器记着的那个执行器」。设计原则:谁掌握信息,谁负责维护。协调器掌握「谁入网了」,就让它记地址。
已验证的示例帧(CRC 已核算,可直接抄进串口助手):
| 动作 | 完整帧 |
|---|---|
| 状态码 0(正常) | AA 55 05 01 05 00 00 00 18 07 |
| 状态码 1(光照不足) | AA 55 05 01 05 00 00 01 39 17 |
| 状态码 2(温度超限) | AA 55 05 01 05 00 00 02 5A 27 |
| 状态码 3(湿度超限) | AA 55 05 01 05 00 00 03 7B 37 |
| 状态码 4(门开) | AA 55 05 01 05 00 00 04 9C 47 |
| 状态码 5(火警) | AA 55 05 01 05 00 00 05 BD 57 |
| 指定地址 0x1234 的门开 | AA 55 05 01 05 34 12 04 E8 3B |
注意最后一行的对比:只改了两个地址字节,CRC 就全变了(
9C 47→E8 3B)。 手工改帧必须重算 CRC —— 这是串口调试里最常犯的错。
3.8 串口异常:把「英文报错」变成「下一步动作」
上位机里有一张翻译表,这是「可用性」的核心,不是锦上添花:
| 底层报错 | 真实原因 | 给人看的提示 |
|---|---|---|
errno 31 / ERROR_GEN_FAILURE | CH340 驱动不接受「刚打开就设 38400」 | 用 9600 预热再切 38400;拔插 / 换口 / 禁用再启用设备 |
PermissionError / Access is denied | 端口被串口助手或另一个上位机占着 | 关掉占用的程序再连接 |
could not open port ... not exist | 设备已拔出 | 点「重新检测」 |
打开成功但 rx=0 | 波特率不对 / TX RX 接反 / 没共地 | 见 5.1 决策树 |
CH340 那个 errno 31 值得单独讲:它不是「端口被占用」,报错文本里却带 PermissionError, 很容易误导人。实测结论(驱动 3.9.2024.9):在刚 CreateFile 的句柄上直接把波特率设成 38400 会失败; 先设 9600(成功),再改成 38400(成功)。于是代码固定这么写:
def open_serial(port, baud=38400, timeout=0.2):
ser = serial.Serial(port, 9600, timeout=timeout) # 预热:9600 必定能开
if baud != 9600:
ser.baudrate = baud # 再切到目标波特率
return ser拔线与重连:串口线程退出时必须做三件事,否则会出现「页面以为还连着」的假象:
finally:
ser.close()
with self.lock:
if self.ser is ser: # ★ 防止旧线程把新连接的状态清掉
self.ser = None
self.err = "串口已断开(USB 线被拔下或端口被其他程序占用)"if self.ser is ser 这一句是并发细节:重连时新线程可能已经起来了, 旧线程退出时若无条件把 self.ser = None,就会把新连接标记成断开。
4. 多传感器组网
4.1 一张网、四个节点:参数从哪来
| 参数 | 值 | 在哪配 | 改了会怎样 |
|---|---|---|---|
| PAN ID | 0x1234 | Projects/zstack/Tools/CC2530DB/f8wConfig.cfg | 改了就「另立山头」,老节点全部入不了网 |
| 信道 | 16(2430 MHz) | 同上 DEFAULT_CHANLIST=0x00010000 | 换了信道,老节点找不到网络 |
| 入网窗口 | 255 s,每 30 s 自动重开 | 协调器 NLME_PermitJoiningRequest(255) | 窗口关了,新节点怎么上电都进不来 |
| 短地址 | 协调器分配 | 协议栈自动 | 重烧/擦 NV 后会变 —— 见 4.4 |
| 节点表容量 | 8 | ZC_PROTO_MAX_NODES | 改大会链接溢出(XDATA 不够) |
为什么入网窗口要「周期性重开」?
因为最初只在 Init 里调了一次 NLME_PermitJoiningRequest(255),而那时网络还没建好, 请求可能直接失败。表现就是「协调器能建网,但节点永远入不了网」。 现在改成:建网成功后再开一次,并且心跳里每 30 s 重开一次,同时打印返回码:
[NWK] permit join (init) -> 0x00
[NWK] network formed, permit join -> 0x00 ON
[HB] permit join re-open -> 0x00 ← 0x00 才是真开成功调试哲学:一个「设置类」的 API,如果失败时不打印返回码,你在现场就永远分不清 「没开」和「开了但没人来」。能打印返回码的地方一律打印。
4.2 一份源码,编出四个固件
四个节点共用同一套源码,靠头文件里的宏切换:
| 宏 | 板 | 簇 | 上行 mask | 采样周期 | 死区 |
|---|---|---|---|---|---|
ZCL_SENSOR_TYPE_ILLUMINANCE | 光敏 | 0x0400 | 0x01 | 300 ms | 10(约 1% 量程) |
ZCL_SENSOR_TYPE_TEMP_HUMI | 温湿度 | 0x0402 / 0x0405 | 0x18 | 500 ms | 温度 5(0.5℃)、湿度 20(2%RH) |
ZCL_SENSOR_TYPE_DOOR | 门禁 | 0xFC00 | 0x02 | 200 ms | 连续 2 次一致才认(400 ms 去抖) |
ZCL_SENSOR_TYPE_ACTUATOR | 执行器 | 0x0006/0xFC10/0xFC11 | 0x00 | 1000 ms | — |
三条必须记住的规矩(都吃过亏):
- 只开一个宏。
#if链只认第一个匹配的分支,同时开两个宏会编出一个「身份混乱」的固件。 - 改完宏必须
Rebuild All。增量编译会漏掉部分文件,编出「宏改了但没生效」的产物。 - 四个宏全关 ≠ 干净。
zcl_sampletemperaturesensor_data.c里有个兜底分支#else /* 按光敏板处理 */,全关等于「伪装成光敏板」,InClusterList里照样带0x0400。
一个不用拆机的验货技巧:Intel HEX 是 ASCII 文本,固件里的字符串原样存在,直接 grep 就能验身份:
cd 固件
for f in *.hex; do
echo -n "$f LIGHT=$(grep -c '4C49474854' $f) DOOR=$(grep -c '444F4F52' $f) \
ACT=$(grep -c '414354' $f) COORD=$(grep -c '434F4F5244' $f)"; echo
done正确结果应该是:LightNode.hex 只有 LIGHT、DoorNode.hex 只有 DOOR、ActuatorNode.hex 只有 ACT、 CoordinatorNode.hex 只有 COORD。如果某个 hex 里混进了别的身份串,说明宏开错了。 这个技巧能把「烧错固件」这类事故从「查两小时」缩短到「看一行」。
4.3 掩码聚合:为什么不是固定结构体
假设不用掩码,每条记录都带全部字段:版本1 + 地址2 + 光2 + 门1 + 温2 + 湿2 = 10 字节,恒定。 用掩码则是 10 ~ 14 字节。也就是说,掩码方案每帧最多多花 4 字节 —— 在 3840 B/s 的容量面前毫无意义。
那为什么还要掩码?因为四个真实收益:
| 收益 | 说明 |
|---|---|
| 能区分「没有」和「是 0」 | 固定结构里,没有温度的节点发 0,PC 会显示 0.0℃(看着像坏了)。掩码里 bit3 不置位,PC 就知道「这个节点没有温度」 |
| 加传感器不改格式 | 新增 bit5 就多一个属性,旧代码解析时自然跳过(因为它按掩码走) |
| 一帧带全量 | 丢一帧不影响,下一帧照样是全量状态 |
| 解析确定 | 按 bit 从低到高累加偏移,长度自洽(3.5 节的交叉验证就靠它) |
扩展的唯一纪律:新位只能往高位追加,不能插队。 因为值是「按掩码位从低到高」排列的,插在中间会把所有已有字段的位置挤乱, 所有旧解析器立刻全错 —— 而且 CRC 还是通过的(典型「静默数据错」)。
顺带算一笔带宽账(这个数字应该让你放心):
稳态:3 个采集节点 × 1 帧/10 秒(心跳)= 0.3 帧/s
每帧约 14 字节 → 约 4.2 B/s
38400 8N1 的容量 ≈ 3840 B/s
占用率 ≈ 0.1%所以真正的瓶颈从来不是带宽,而是上报策略的时机(4.6 节)。
4.4 节点表的主键陷阱:短地址会变(真实事故)
协调器与上位机都用短地址做节点表的键。这在正常情况下没问题,但有一个致命前提被忽略了: 短地址由协调器分配,节点重烧(尤其擦了 NV)后可能拿到一个不同的地址。
本次调试中,上位机节点表里真实存在过这样 5 条记录:
| 地址 | 值 | 最后上报 |
|---|---|---|
0x99D1 | 光照 590 | 8 秒前 ← 真正在工作的那个 |
0x2B15 | 光照 589 | 2045 秒前 ← 同一块板重烧前的旧地址 |
0x0B13 | 30.00℃ / 90.70% | 2047 秒前(已掉线) |
0x8F08 | 门关 | 3518 秒前(已掉线) |
0x8F3B | —(mask=0) | 2046 秒前 |
五个地址,只有一个是「活人」。而这直接导致了 5.5 节那个诡异故障(数据流在刷、卡片不动)。
三种解法的取舍:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| A. 按地址取 | nodes.filter(...)[0] | 一行代码 | 错:数组按地址排序,[0] 恰好是旧地址(本项目犯的错) |
| B. 按时间戳取 | 先按 ts 降序再取第一条 | 简单,对「重烧换地址」天然免疫 | 需要后端提供 ts/age |
| C. 用 MAC 当设备号 | 协议里加 8 字节 IEEE 地址 | 唯一且永久,能真正区分设备 | 要改协议、要改节点上报、帧变长 |
| D. 回收 | 超时条目从表里删掉 | 表不会越长越大 | 只能治表,不能治「哪个才是当前节点」 |
本项目 B + D 一起用:
NODE_STALE = 60.0 # 60 秒没上报 → 标记 stale(不参与判定、卡片压暗)
NODE_KEEP = 1800.0 # 30 分钟没上报 → 从表里删掉(不再显示)4.5 判定放哪:三个位置的取舍(本项目的架构决策)
「温度超 30℃ 就报警」这句话写在哪里,决定了整个系统的可维护性:
| 位置 | 改动成本 | 实时性 | 全局上下文 | 现场可调 |
|---|---|---|---|---|
| 写在节点固件里 | 改阈值要重烧 1 块板 | 最快(本地判断) | 没有,看不到别的节点 | 不能 |
| 写在协调器里 | 改阈值要重烧协调器 | 快 | 有(能看全部节点) | 不能 |
| 写在上位机(本项目) | 改一个 THRESH 字典 | 慢 250 ms(无所谓) | 有 | 能,界面上直接改 |
本项目的结论:协调器一律不做阈值判断,只做转发。 由此产生两个连锁结果:
- 下行命令的地址可以填 0(协调器代记执行器地址,见 3.7)。
- 判定逻辑集中在一个函数里,优先级一眼看清:
def judge(self):
# 优先级:门开 > 温度 > 湿度 > 光照 > 正常
if door_open: return STATE_DOOR, "..."
if temp >= THRESH["temp_high"]: return STATE_TEMP, "..."
if humi >= THRESH["humi_high"]: return STATE_HUMI, "..."
if light is not None and light < 150: return STATE_LIGHT, "..."
return STATE_NORMAL, "全部正常"为什么是「门开」最高? 因为它时效性最强、误判代价最大。 而「光照不足」最轻 —— 天黑了本来就会触发,只是提示。
架构原则:判断放在「信息最全、改动最便宜」的那一层。 节点信息最少但最快;上位机信息最全但最慢。对 30 秒级的环境量,「慢 250 ms」根本不算代价; 而「改阈值要重烧 4 块板」是每天都要付的代价。
4.6 上报策略:三个参数决定「多久能看到变化」
#define ZCL_SENSOR_SAMPLE_PERIOD 300 /* 多久问一次 51 —— 决定最快能多快发现 */
#define ZCL_SENSOR_LIGHT_DEADBAND 10 /* 变化 ≥10 才算真变化 —— 决定抗噪 */
#define ZCL_SENSOR_HB_REPORT 10000 /* 一直不变也 10 秒补报一次 —— 决定"看起来活着" */| 节点 | 采样周期 | 死区 | 端到端延时(含网页 250 ms) |
|---|---|---|---|
| 光敏 | 300 ms | ADC 变化 ≥ 10 | 0.35 ~ 0.65 s |
| 门禁 | 200 ms,去抖 2 次 | 状态变化 | 0.5 ~ 0.9 s |
| 温湿度 | 500 ms | 温度 ≥ 0.5℃ / 湿度 ≥ 2%RH | 2.0 ~ 2.7 s(受 51 的 2 s 采样缓存限制) |
| 执行器 | 1000 ms | — | 下发 → 灯亮约 1 s 内 |
为什么温湿度天生慢? 因为 51 端每 2 秒才读一次 AM2321(传感器有最小间隔要求), CC2530 问得再勤也只是拿到「最多旧 2 秒」的缓存值。这不是 bug,是物理限制。
为什么必须有心跳? 因为死区意味着「不变就不发」。如果温度恰好稳定在 25.0℃ 半小时, 上位机会认为这个节点掉线了(stale),卡片变灰、判定把它排除。 心跳用 10 s 兜底,而上位机 60 s 才判 stale —— 留了 6 倍余量,即使丢几帧也不会误判。
这两个数不能反过来:如果心跳 60 s、
stale也是 60 s,那么一丢帧就报警,页面天天闪灰。
4.7 两层超时:一层管「算不算数」,一层管「记不记得」
| 参数 | 值 | 管什么 | 后果 |
|---|---|---|---|
NODE_STALE | 60 s | 数据算不算数(参与判定吗、卡片压暗吗) | 超时的节点不再参与判定,免得断电的门禁一直挂着「门开」 |
NODE_KEEP | 1800 s | 记录还留不留(表里还有这条吗) | 超时的条目从节点表删除,幽灵节点自动消失 |
见过太多项目只做一层(要么数据永远算数、要么记录立刻删),结果要么满屏假报警,要么节点表越堆越乱。 「算不算数」和「记不记得」是两个独立问题,必须分开处理。
5. 调试技巧:把「猜」变成「看」
5.1 分层二分:先定位层,再看代码
任何故障,第一个问题都不是「哪行代码错了」,而是「最远能确认到哪一层」。照这张树走,三分钟内必落到一层:
页面有数据吗?
│
├─ 没有(红条 / rx=0 / 节点 0)
│ │
│ ├─ 协调器 Terminal I/O 里有 [JOIN] 吗?
│ │ ├─ 没有 ────────────→ L2/L3:节点根本没入网
│ │ │ → 擦 NV 重烧(组网文档 3.4.1)
│ │ └─ 有
│ │ └─ 有 [RX] 行吗?
│ │ ├─ 没有 ────→ L2:入网了但不报数
│ │ │ → 节点侧看 NO FRAME / 宏是否开对 / 51 是否上电
│ │ └─ 有 ──────→ L4:协调器发得出去,PC 收不到
│ │ → 波特率 38400?TX/RX 交叉?共地?被别的软件占用?
│ │
├─ 有数据但不更新(数值冻住)
│ └─ /api/state 里有几条节点?卡片取的是「最新那条」吗?(4.4 / 5.5)
│
└─ 有数据但值不对(量级错 / 负数 / 满量程)
└─ 单位?字节序?符号位?(3.6)
2530→25.30(单位) E2 09→2530(小端) 0xFFCB→-53(int16)为什么一定要「先分层」:这套系统有四块 51、四个 CC2530、一个协调器、一个 Python 程序。 盲目从「我觉得是这里」开始改,最常见的结局是改了三个地方,问题还在,但多了两个新问题。
5.2 五条观测通道,各有盲区
| 通道 | 能看到什么 | 看不到什么 | 什么时候用 |
|---|---|---|---|
| 51 侧串口助手(9600) | 9 字节响应原文 | 一看就断开了 CC2530 的 RX,节点立刻收不到数据 | 单独验证 51 是否在应答 |
| 节点 Terminal I/O | 解析结果、入网状态、NO FRAME 自检 | 必须 Download and Debug,退出调试器就断 | 验证「节点有没有收到 51 的帧」 |
| 协调器 Terminal I/O | [JOIN]、[HB] rx/peers、[RX]、[TX] | 同上;且打印多了会拖慢主循环 | 验证「有没有入网、有没有上报」 |
PC 日志栏 + /api/state | 结构化数据、CRC 校验后的结果 | 只反映「入库之后」,看不到被丢弃的坏帧 | 验证「PC 解析对不对」 |
--rec 抓包文件 | 每一帧原始十六进制 + 时间 | 需要提前开;不含坏帧 | 事后比对、写报告、算延时 |
一条铁律:用串口助手接 51 时,必须先把 CC2530 那根 TX 拔掉(或者干脆给 51 断电重来)。 两个设备同时往同一根线上发,会互相打架,甚至烧引脚。这不是「可能有问题」,是「一定有问题」。
5.3 打印的三条纪律
| 纪律 | 反例 | 正确做法 |
|---|---|---|
| 带前缀 | printf("ok") | [51 TH] T=25.3 C H=61.4 %RH —— 一眼看出是哪一层、哪个模块 |
| 可关闭 | 调试打印写死在业务里 | TH_DEBUG_PRINTF / ZC_DBG_*,一个宏全关 |
| 只在异常时打 | 每帧都打,窗口刷屏,真正的问题被冲走 | NO FRAME 只在「5 秒一帧都没成功」时打 |
NO FRAME 就是按第三条设计的,它把「静默故障」变成了可诊断信号:
[51 TH] NO FRAME tx=10 rxB=0 last=0x00
│ │ └── 最后收到的字节(0x00 = 一个都没收到)
│ └──────── 本窗口收到多少字节(不管成不成帧)
└────────────── 本窗口发了几条读指令| 现象 | 判读 |
|---|---|
tx=10 rxB=0 | 一个字节都没回来 → 线没接 / 51 没上电 / 波特率不对 |
tx=10 rxB>0 last=0x.. | 有字节但拼不成帧 → 帧头对不上 / 51 回了别的格式(如原厂 printf 例程) |
完全不出现 NO FRAME | 解析一直成功(正常),或者固件根本没跑起来(看有没有 [BOOT]) |
5.4 抓包与回放:两个开关,省掉一半联调时间
--rec:把现场留下来
python smart_home_ui.py COM4 --rec capture.log每收到一帧就追加一行(UTF-8,可直接用记事本/Excel 打开):
2026-10-18 21:10:40 AA 55 07 01 01 00 D1 99 6C 02 15 5A 节点 0x99D1 光敏=620
2026-10-18 21:10:40 AA 55 09 01 18 00 13 0B 60 09 38 18 E2 30 节点 0x0B13 温度=24.00°C 湿度=62.00%
2026-10-18 21:10:40 AA 55 06 01 02 00 08 8F 00 34 AD 节点 0x8F08 门禁=关三个用途:
- 算端到端延时:手指挡住光敏,看相邻两条
光敏=620 → 120的时间差(精确到秒,够用于 1.4 节的量级验证)。 - 写报告:原始字节 + 解析结果并列,比截图有说服力。
- 事后比对:出问题时把日志发给别人,对方不用猜你的现场。
--fake:没有硬件也能把界面跑起来
python smart_home_ui.py --fake # 不打开串口,用合成帧驱动界面
python smart_home_ui.py --fake --rec sim.log关键点(也是最容易被误解的一点):它不是「直接往界面上塞假数字」,而是
build_up(mask, addr, light=...) → 一帧真实的 AA 55 帧 → parse_upload() → ingest()
↑ 合成 ↑ 和硬件来的帧一模一样 ↑ 同一套 CRC 校验也就是说:合成帧必须能通过 CRC 与长度校验才会入库。 所以 --fake 同时是一个协议自测(回归测试):
练习:把
zb_host_demo.py里build_up()的struct.pack("<h", ...)改成">h"(大小端写反), 重跑--fake。你会发现温度变成-7936.00°C之类的诡异值 —— 但 CRC 依然通过。 这正好回答 0.4 节第 4 问:CRC 只保证比特没坏,不保证你解释对了。
三个使用场合:
| 场合 | 怎么用 |
|---|---|
| 主讲理论课,没有硬件 | 直接演示「判阈值 → 下发状态码 → 灯变色」,40 秒走完全部 4 个状态 |
| 改了网页样式 | 不用接板子,改完刷新就能看效果 |
| 怀疑「是硬件还是软件」 | 先跑 --fake:正常 → 问题在硬件侧;也不正常 → 问题在软件侧 |
5.5 故障复盘:一个「数据流在刷、卡片数值不动」的完整案例
这是本实验最值得精读的一段 —— 它演示了如何用证据而不是直觉定位问题。
现象:网页右侧「实时数据流」一直在刷新新行,但左上「光照」卡片的数字、进度条、地址标签 十几分钟一动不动,而光敏板明显在手边被遮挡过。
第 1 步:不要看网页,直接问后端
curl -s http://127.0.0.1:8765/api/state拿到的节点表是关键证据:
| 地址 | 值 | 最后上报 |
|---|---|---|
0x99D1 | 光照 590 | 8 秒前 |
0x2B15 | 光照 589 | 2045 秒前 |
0x0B13 | 30.00℃ / 90.70% | 2047 秒前 |
0x8F08 | 门关 | 3518 秒前 |
0x8F3B | — | 2046 秒前 |
结论 1:后端没问题 —— 它老老实实收了 5 条记录,最新那条就是 8 秒前的 590。
第 2 步:问题在前端取数逻辑
// ❌ 原代码
var ln = nodes.filter(function(n){ return n.light !== null; })[0];nodes 是按地址升序排的数组,[0] 命中 0x0B13…不,0x0B13 没有 light。 真正命中的是 0x2B15 —— 也就是重烧前的旧地址,它的值是 2045 秒前的 589。 所以卡片永远显示 589,永远不动。
第 3 步:为什么会有两个地址?
光敏节点擦 NV 重烧后,协调器分配了新的短地址 0x99D1。旧地址 0x2B15 那条记录没有任何机制会删除它 (4.4 节)。于是表里同时存在「死人」和「活人」,而代码恰好取了死人。
第 4 步:修复要动两处,只改一处不算修好
| 位置 | 改动 |
|---|---|
后端 smart_home_ui.py | snapshot() 给每个节点加 age(多久没上报)和 stale;link 加 nlive/ntotal;新增 NODE_KEEP 定期回收历史条目 |
前端 smart_home.html | 新增 pickNode(fields):按 ts 降序取第一条;地址标签显示「地址 · 多久前」;stale 的卡片压暗、标签变琥珀色 |
function pickNode(fields){
if (typeof fields === "string") fields = [fields];
var cand = nodes.filter(function(n){
return fields.some(function(f){ return n[f] !== null && n[f] !== undefined; });
});
if (!cand.length) return null;
cand.sort(function(a,b){ return (b.ts || 0) - (a.ts || 0); }); // ★ 按新鲜度
return cand[0];
}第 5 步:验证(不是「看起来对了」)
用现场那 5 条真实数据造假数据源,浏览器渲染后读 DOM:
| 取值 | 修复前 | 修复后 |
|---|---|---|
lightVal | 589(死值) | 595(实时) |
lightAddr | 0x2B15 | 0x99D1 · 13s 前 |
climateAddr | 0x0B13 | 0x0B13 · 3m 前(琥珀色,卡片变暗) |
footNode | 节点:5 | 在线节点:1 / 4 |
这个案例的三条教训:
- 「有数据」和「数据对」是两件事。数据流在刷只证明「串口在收」,不证明「卡片取对了」。
- 表的主键选错,会在最不相干的地方发作。短地址一变,问题出现在「网页不刷新」——离根因隔了两层。
- 修复要覆盖两端。只改前端(按 ts 取)能治症状;只改后端(回收)也会有 30 分钟的窗口期。两处都改才叫修好。
5.6 常见故障速查
| 现象 | 最可能的原因 | 下一步 |
|---|---|---|
| 双击 bat 提示「端口 8765 已被占用」 | 已经有一个上位机在跑(Windows 允许两个实例抢同一端口,数据会一半新一半旧) | 关掉旧窗口;或 --http 8766 换端口 |
| 页面红条「上位机未响应」 | 直接双击了 html,没跑 Python | 用 启动智能家居界面.bat |
串口未连接 + errno 31 | CH340 不接受直接设 38400 | 已内置 9600 预热;仍失败就拔插/换口 |
端口被占用 | 串口助手还开着 | 关掉它,点「连接」 |
rx=0,但串口灯在闪 | 波特率不对 / TX RX 接反 / 没共地 | 万用表量 GND;确认 38400 |
节点 state=2↔10 反复 | NV 里是旧网络 | IAR 勾 Erase flash 烧一次 |
协调器 peers=0 但节点 state=6 | 入网了但没上报(应用层) | 看节点 NO FRAME |
NO FRAME tx=10 rxB=0 | 51 没上电 / 线松 / 波特率 | 串口助手单独测 51 |
| 光照值恒定 1023 或 0 | ADC 通道选错(P1.1) | 检查 P1ASF 与 ADC_CH1 |
| 温度是 6000 多 | 用了 uint16 解 int16 | 改 struct.unpack("<h") / int16 |
| 卡片数值不动 | 取到了旧地址/旧记录 | 4.4、5.5 |
| 卡片变灰显示「3m 前」 | 节点真的掉线了(不是界面问题) | 看协调器 [HB] peers |
| 状态码一直 0,但明显超限 | 阈值方向反了(光照越小越暗) | 看 THRESH 注释 |
| 执行器不响应 | 协调器没记到执行器地址 | 让执行器重上报(重启执行器) |
5.7 破坏性测试清单(做完才算「跑通」)
| 动作 | 预期现象 | 恢复方式 |
|---|---|---|
| 拔协调器 USB | 页面红条 + 串口已断开;判定线程仍在算 | 插回 → 点「重新检测」→「连接」 |
| 关掉某个节点电源 60 s | 该卡片变灰、在线节点 2/4;判定不再含它的旧值 | 上电,state=6 后自动恢复 |
| 重烧光敏节点(擦 NV) | 出现新地址 + 旧地址变成「xx m 前」;卡片自动切到新的 | 无需操作,30 分钟后旧条目被回收 |
| 拔 51 的 TX(P3.1) | 节点 5 s 后打 NO FRAME rxB=0 | 插回,10 s 内恢复 |
| 手快速反复遮挡光敏 | 状态码在 0/1 之间跳,但不会「卡死」 | — |
| 三个传感器同时超限 | 判定按优先级取一个(门 > 温度 > 湿度 > 光照) | 逐个恢复,看状态码回退 |
| 关掉协调器再开 | [JOIN] 重新出现;执行器地址重新记录 | 等执行器下一次上报(≤30 s) |
| 把 51 波特率改成 4800 | 节点 NO FRAME rxB>0(收得到字节但拼不成帧) | 改回 9600 |
最后一行是故意制造「有字节但不成帧」,用来区分「线断」和「协议不对」—— 这两种故障在
NO FRAME里的表现完全不同,学会看这个区别,排障速度差三倍。
6. 实验步骤(任务单)
总原则:一次只加一个变量。每加一个节点,先确认它单独工作正常,再加下一个。 四个节点一起上电的后果是——出问题时你分不清是谁的问题。
阶段 0:环境自检(✅ 检查点 0,已在 1.4 节完成)
| 项 | 要求 |
|---|---|
| 上位机 | --fake 跑通,四个状态码 0→1→2→4 出现 |
| 结论 | 「显示链路」已排除,后续任何异常都不必怀疑网页 |
阶段 1:单节点全链路(光敏)
| 步 | 操作 | 预期 |
|---|---|---|
| ① | 烧 固件/LightNode.hex(勾 Erase flash) | 节点 [BOOT] LIGHT pan=0x1234 → state=2→3→5→6 |
| ② | 烧 51 光敏 hex(STC-ISP,冷启动) | — |
| ③ | 接线:CC2530 P0_3(TX)→51 P3.0(RXD);P0_2(RX)←51 P3.1(TXD);GND 共地 | — |
| ④ | 打开协调器 Terminal I/O | [JOIN] 新节点入网 nwk=0x....、[HB] peers=1 |
| ⑤ | 启动 启动智能家居界面.bat | 光照卡片出现地址,数值随遮挡变化 |
✅ 检查点 1(判据)
| 检查项 | 期望 |
|---|---|
| 协调器 | [HB] rx5s≥1 rx≥N peers=1 st=9 |
| 上位机 | rx 递增、在线节点 1/4 |
| 手挡光敏 | 1 秒内卡片数值变化,--rec 里相邻两帧时间差 ≤ 1 s |
| 帧格式 | --rec 里是 AA 55 07 01 01 00 xx xx ...(长度 07,掩码 01) |
常见失败:peers=0 但节点 state=6 → 查 NO FRAME;state 卡 2↔10 → 擦 NV 重烧。
阶段 2:第二个节点(温湿度)—— 看「多节点并发」
| 步 | 操作 | 预期 |
|---|---|---|
| ① | 改 .h 为 ZCL_SENSOR_TYPE_TEMP_HUMI,Rebuild All,烧 THNode.hex(擦 NV) | [BOOT] TH pan=0x1234 |
| ② | 烧 51 温湿度 hex(AM2321.hex),接线同阶段 1 | — |
| ③ | 观察协调器 | [HB] peers=2;[RX] node=... mask=0x18 |
✅ 检查点 2(判据)
| 检查项 | 期望 |
|---|---|
| 节点表 | 两行,光敏 mask=0x01、温湿度 mask=0x18,地址不同 |
| 页面 | 光照卡片与温湿度卡片各自显示自己的地址;在线节点 2/4 |
| 延时差异 | 手挡光敏 < 1 s 响应;对温湿度哈气约 2~3 s 才变(4.6 节) |
| 交叉验证 | 用掩码反推长度:0x18 → 5+2+2=9 → 整帧 14 字节,与 --rec 实际长度一致 |
这一步真正要体会的:两个节点同时上报,协调器不会串味 —— 因为每帧都带地址, PC 按地址分行。如果这里看到「温度出现在光照卡片上」,一定是掩码或解析偏移错了。
阶段 3:门禁 + 执行器 —— 闭环
| 步 | 操作 | 预期 |
|---|---|---|
| ① | 烧 DoorNode.hex(门禁,擦 NV)+ 51 门禁 hex | 协调器 peers=3,页面门禁卡片出现 |
| ② | 烧 ActuatorNode.hex + 51 BuzzerRGB.hex | 协调器 [ACT] actuator online, addr=0x....;页面 在线节点 4/4 |
| ③ | 遮挡红外对射 | 门禁卡片「门已打开」→ 约 0.5 s 后执行器 红蓝交替 + 5 声 |
| ④ | 松开(恢复通光) | 状态码回 0,灯灭静音 |
| ⑤ | 手动点状态码 0~5 六个按钮 | 灯色与声响逐一对上(附录 A.3) |
✅ 检查点 3(判据)
| 检查项 | 期望 |
|---|---|
| 闭环 | 触发 → 页面变化 → 执行器动作,总耗时 ≤ 1.5 s |
--rec | 能找到 AA 55 05 01 05 00 00 04 9C 47 这类下行帧(dir=tx) |
| 优先级 | 同时「门开」+「温度超限」时,只有门开状态码 4 生效(4.5 节) |
| 手动 | 六个按钮都能立刻改变 51 的灯色与声响 |
阶段 4:异常注入(✅ 检查点 4)
按 5.7 清单逐项做,每项都要记录:现象 / 定位到哪一层 / 根因 / 恢复动作。
| 必做项 | 记录什么 |
|---|---|
| 拔协调器 USB 再插回 | 页面多久恢复?rx 是否从 0 继续累加? |
| 关一个节点 70 s 再开 | 卡片何时变灰?nlive 怎么变?判定是否还含旧值? |
| 重烧光敏节点(擦 NV) | 新旧两个地址在表里共存多久?卡片自动切到新的了吗? |
| 拔 51 的 TX | NO FRAME 多久出现?rxB 是几? |
| 教师随机注入 1 个故障 | 你在几分钟内定位到哪一层(G5 的验收) |
教师注入建议(任选其一,不要提前告知): ① 把 51 的 TX/RX 对调;② 把协调器波特率当成 9600;③ 关掉某个节点的电源; ④ 把
THRESH["temp_high"]改成 5.0(看起来「到处报警」);⑤ 拔掉共地线。
阶段 5:扩展第 5 个传感器(选做,✅ 检查点 5)
任务:把一块新传感器(土壤湿度 / 光照强度 / 继电器 / 人体红外)接入系统,要求页面上出现独立的一张卡片或一行。
改造清单(每一步都能独立验证):
| 步 | 改哪里 | 验证方法 |
|---|---|---|
| ① | 51 端:把新读数放进 CH0(沿用 01 03 00 2A 问答,9 字节响应) | 串口助手手发指令能收到正确值 |
| ② | 节点端:新增一个宏分支(或复用现有分支),把 CH0 换算成自己的单位 | [51 XXX] 打印正确 |
| ③ | 协议端:在 ZC_ATTR_* 里追加 0x20(bit5),不要插队 | 协调器 [RX] mask=0x20 |
| ④ | 协调器:zcSendFrame() 里按 bit5 追加 2 字节,长度同步 +2 | --rec 里长度从 14 变 16 |
| ⑤ | 上位机:parse_upload() 加 bit5 分支,页面加一个显示位 | 卡片出现并随时间变化 |
| ⑥ | 文档:在《协调器-PC-二进制协议.md》掩码表加一行 | 别人能照着接 |
✅ 检查点 5(判据):新数据与原有四路同时刷新,互不干扰;拔掉新节点,其余四路不受影响。
这一步是本实验的「毕业设计」。能做到,说明你已经掌握了「加一路传感器要动哪五个地方」, 也就真正理解了这套系统的程序结构。
7. 实验报告要求与考核
7.1 必交内容
| 序号 | 内容 | 具体要求 |
|---|---|---|
| 1 | 系统拓扑图 | 标出四层、每层的速率与帧长;用两句话说明「为什么判定放在上位机」 |
| 2 | 程序结构图 | 51 的超级循环 + CC2530 的事件流 + PC 的三线程;在图上标出阻塞点 |
| 3 | 协议表 | 一张上行帧表(字段/长度/字节序/校验)+ 一张下行命令字表 |
| 4 | capture.log | 用 --rec 抓的原始帧,需覆盖:三个采集节点各 ≥ 5 帧、一次状态码下行 |
| 5 | 端到端延时 | 光敏 / 门禁各测 3 次,给平均值与测量方法(截图或时间戳) |
| 6 | 故障注入表 | 5 个故障:现象 / 定位层 / 根因 / 恢复动作 / 耗时 |
| 7 | 界面截图 | 含「在线节点 3/4」、一个非 0 状态码、一个 stale 卡片 |
| 8 | 思考题 | 8 题全做,每题不少于 100 字(要有数字或代码,不要空话) |
7.2 思考题
- CRC 都通过了,数据为什么还可能是错的? 举出本系统里至少两种「CRC 通过但值错」的场景。
- 上行帧为什么不把 LEN 字段省掉(反正掩码能推出长度)?反过来,既然有 LEN,为什么还要 CRC?
- 短地址为什么不能当设备唯一编号?如果要真正区分设备,需要改哪三处?
- 本项目用「网页每 250 ms 轮询」而不是 WebSocket 推送。列出两个好处和两个代价,并说明什么场景下必须换推送。
ZC_PROTO_MAX_NODES从 8 改到 16 会链接失败。如果不增加 XDATA,有什么办法支持 16 个节点?(提示:想想哪些字段可以不存)- 如果在掩码中间插入一个新位(比如把 bit2 复活成「火焰」),会发生什么?为什么 CRC 发现不了?
--fake模式下合成帧要过parse_upload(),这个设计有什么好处?如果直接hub.nodes[addr] = {...}会丢掉什么保护?- 心跳 10 s、
NODE_STALE60 s。如果把心跳改成 60 s 会怎样?如果改成 5 s 又会怎样?给出你的取值理由。
7.3 评分表(100 分)
| 项目 | 分值 | 评分要点 |
|---|---|---|
| 单节点全链路跑通 | 15 | 检查点 1 全过 |
| 多节点并发(≥3 节点) | 20 | 检查点 2、3 全过;节点表无串味 |
| 闭环(判定 → 下发 → 执行器) | 15 | 状态码 0~5 全部能正确驱动 51 |
| 协议表与结构图 | 15 | 字段齐全、字节序正确、能指出阻塞点 |
| 故障定位能力 | 20 | 教师注入的故障在 5 min 内定位到层(10 分);记录表完整(10 分) |
| 报告与思考题 | 15 | 有数据、有代码、有取舍分析 |
| 扩展任务(选做) | +10 | 阶段 5 全流程完成且不影响原有四路 |
附录 A 速查卡
A.1 上行帧(协调器 → PC,38400 8N1)
AA 55 | LEN | 01 | MASK_L MASK_H | ADDR_L ADDR_H | 值... | CRC_L CRC_H
↑帧头 ↑长度 ↑版本 ↑掩码(小端) ↑短地址(小端) 按位序 ↑CRC16-CCITT 小端
└── LEN 字节 ─────────────────────────────────────┘
整帧长度 = LEN + 5 LEN = 5 + 2×bit0 + 1×bit1 + 2×bit3 + 2×bit4
CRC 范围 = [3] 起、共 LEN 个字节| 位 | 属性 | 类型 | 单位 | 字节 |
|---|---|---|---|---|
bit0 0x01 | 光照 | uint16 | ADC 0~1023 | 2 |
bit1 0x02 | 门禁 | uint8 | 0 关 / 1 开 | 1 |
bit2 0x04 | 已废弃(原火焰) | — | 不复用 | 0 |
bit3 0x08 | 温度 | int16 | 0.01 ℃ | 2 |
bit4 0x10 | 湿度 | uint16 | 0.01 %RH | 2 |
| bit5+ | 预留(只能往高位追加) | — | — | — |
四条已核算的真实帧(可直接用来验证解析器):
| 场景 | 帧 | 长度 |
|---|---|---|
| 光敏 590,addr 0x99D1 | AA 55 07 01 01 00 D1 99 4E 02 91 3A | 12 |
| 温湿度 25.30℃ / 61.40%,addr 0x0B13 | AA 55 09 01 18 00 13 0B E2 09 FC 17 CD 2B | 14 |
| 温湿度 −5.30℃ / 45.00%,addr 0x0B13 | AA 55 09 01 18 00 13 0B EE FD 94 11 28 B9 | 14 |
| 门禁开,addr 0x8F08 | AA 55 06 01 02 00 08 8F 01 15 BD | 11 |
| 执行器上线(mask=0),addr 0x8F3B | AA 55 05 01 00 00 3B 8F 55 02 | 10 |
第三条是用来练「有符号」的:
EE FD小端 =0xFDEE= −530 = −5.30℃。 用uint16解会得到 65006 → 650.06℃,一个漂亮的「反例」。
A.2 下行帧(PC → 协调器,同波特率)
AA 55 | LEN=4+N | 01 | CMD | ADDR_L ADDR_H | payload(N) | CRC_L CRC_H| CMD | 名称 | payload | 说明 |
|---|---|---|---|
| 01 | OnOff | 1 字节 0/1 | 蜂鸣器 |
| 02 | Level | 1 字节 0~254 | 亮度(0 = 灭) |
| 03 | Color | 3 字节 R G B | 颜色 |
| 04 | AllOff | 无 | 全关 |
| 05 | SetState | 1 字节 0~5 | 本项目主用:地址可填 0 |
| 动作 | 帧 |
|---|---|
| 状态码 0/1/2/3/4/5 | AA 55 05 01 05 00 00 0S xx xx(完整见 3.7 表) |
| 指定 0x1234 状态码 4 | AA 55 05 01 05 34 12 04 E8 3B |
| 0x8F3B 蜂鸣器响 | AA 55 05 01 01 3B 8F 01 3A E3 |
A.3 状态码与执行器表现
| 码 | 含义 | 灯色 | 声响 | 谁触发 |
|---|---|---|---|---|
| 0 | 正常 | 全灭 | 静音 | — |
| 1 | 光照不足 | 黄 | 嘀 1 声 | 光照 ADC < 150 |
| 2 | 温度超限 | 红 | 嘀·嘀 2 声 | ≥ 30.0 ℃ |
| 3 | 湿度超限 | 蓝 | 3 声快 | ≥ 80 %RH |
| 4 | 门开 / 入侵 | 红蓝交替 | 5 声急促 | 门禁 = 1 |
| 5 | 火警 | 红快闪 | 长鸣 | 本套件无火焰传感器,仅手动触发 |
优先级:4 门开 > 2 温度 > 3 湿度 > 1 光照 > 0。
A.4 阈值与超时(都在上位机,改完重启即生效)
| 参数 | 值 | 位置 |
|---|---|---|
THRESH["light_low"] | 150(ADC 越小越暗) | smart_home_ui.py 顶部 |
THRESH["temp_high"] | 30.0 ℃ | 同上 |
THRESH["humi_high"] | 80.0 %RH | 同上 |
NODE_STALE | 60 s | 同上 |
NODE_KEEP | 1800 s | 同上 |
| 判定周期 / 补发 | 300 ms / 10 s | _judge_loop() |
| 网页轮询 | 250 ms | smart_home.html 末尾 |
A.5 固件身份(不用拆机,grep 就知道烧的是谁)
| 固件 | 应含 | 不应含 |
|---|---|---|
LightNode.hex | 4C49474854(LIGHT) | DOOR / ACT / COORD |
THNode.hex | (无专用串,看 BOOT 打印 TH) | DOOR / ACT / COORD |
DoorNode.hex | 444F4F52(DOOR) | LIGHT / ACT / COORD |
ActuatorNode.hex | 414354(ACT) | LIGHT / DOOR / COORD |
CoordinatorNode.hex | 434F4F5244(COORD) | LIGHT / DOOR / ACT |
A.6 三条最容易错的地方
- 改宏不 Rebuild All → 编出「宏改了但没生效」的固件,且不报错。
- 改帧不重算 CRC → 手工改一帧的地址/状态码,CRC 必须一起改(
build_down()会自动算)。 - 取节点不看时间戳 → 重烧换地址后,卡片可能永远显示一个旧值(5.5 节的完整案例)。
附录 B 文件与命令索引
B.1 关键文件
| 路径 | 作用 |
|---|---|
pc上位机示例/smart_home_ui.py | 上位机后端:串口线程 / 判定线程 / HTTP / 快照 |
pc上位机示例/zb_host_demo.py | 协议库:crc16_ccitt / build_up / build_down / parse_upload |
pc上位机示例/smart_home.html | 大屏(单文件,无依赖) |
pc上位机示例/启动智能家居界面.bat | 正常启动(接硬件) |
pc上位机示例/启动模拟演示.bat | --fake 启动(无硬件) |
协调器-PC-二进制协议.md | 帧格式权威定义(已并入本页第三部分) |
四板四节点-组网与烧录说明.md | 烧录、入网、state 判读(已并入本页第三部分) |
固件/*.hex | 5 个固件(4 节点 + 协调器) |
工具/编译全部固件.bat | 一键重编 5 个固件(避免源码与 hex 错位) |
B.2 常用命令
# 正常启动(自动挑串口)
python smart_home_ui.py
# 指定串口 + 抓包
python smart_home_ui.py COM4 --rec capture.log
# 无硬件演示
python smart_home_ui.py --fake --rec sim.log
# 只看后端状态(调试最常用)
curl -s http://127.0.0.1:8765/api/state
# 命令行下发一个状态码(不用网页)
python -c "import zb_host_demo as z, serial; s=serial.Serial('COM4',38400); s.write(z.build_down(0x05,0,bytes([4])))"
# 校验固件身份
cd 固件 && for f in *.hex; do echo "$f L=$(grep -c 4C49474854 $f) D=$(grep -c 444F4F52 $f) A=$(grep -c 414354 $f) C=$(grep -c 434F4F5244 $f)"; done
# 串口体检(列端口、试打开、看是不是被占用)
python 检查串口.py附录 C 术语
| 术语 | 含义 |
|---|---|
| PAN ID | ZigBee 网络号,本项目 0x1234。不同 PAN 的设备互相看不见 |
| 短地址 | 协调器分配的 16 位网络内地址,重烧/重入网可能变 |
| 掩码(mask) | 用位标记「这一帧里哪些属性有效」,实现自描述与向后兼容 |
| 死区(deadband) | 变化小于此值视为噪声,不上报,防刷屏、防误判 |
| 心跳(heartbeat) | 数据不变时定时补报一帧,用于证明「节点还活着」 |
| stale | 超过 NODE_STALE 没上报,数据视为过期,不参与判定 |
| 聚合 | 协调器把节点最新值存表后整帧打包,而非透传原始报文 |
| 快照(snapshot) | 把内部可变状态一次性拷成不可变 JSON,供外部读取 |
| 粘包 / 半包 | 串口字节流里多帧连在一起 / 一帧分多次到达 |
| ADR | 本指南里的「上行/下行」:上行 = 节点→PC 数据,下行 = PC→执行器命令 |
附录 D 思考题参考答案要点(教师用)
| 题 | 要点 |
|---|---|
| 1 | ① 单位/量纲错(0.1 当 0.01);② 字节序错(大端当小端);③ 掩码位解释错(bit3 与 bit4 互换);④ 长度与掩码不一致但仍落在合法范围。CRC 只保证比特未翻转 |
| 2 | LEN 是切帧用(在解析出 mask 之前就要知道多长),mask 是解释用;而且 LEN 与 mask 互相验证能发现「协议版本不匹配」。CRC 防传输误码,LEN 防切帧错误,两者防的不是同一件事 |
| 3 | 短地址由协调器分配、可复用、重烧即变;要唯一需改:① 协议加 8 字节 IEEE/MAC;② 节点上报时带上;③ PC 侧改用 MAC 做主键(并用短地址做路由) |
| 4 | 好处:调试极简(curl 就能看)、无长连接状态需维护;代价:250 ms 延迟、每秒 4 次请求。必须换推送的场景:亚 100 ms 联动(如安防)、几十个节点以上、需要服务端主动事件 |
| 5 | ① 不存 light,改存「最近一帧原始帧」(最长 14 字节)按需解析;② 把 mask 缩成 8 位、temp 用 0.1 单位存 int16 省不下多少,但可把表项从结构体改成「指针+紧凑数组」;③ 只保留在线节点(配合 PC 侧回收);④ 换更大 RAM 的芯片(CC2530 只有 8 KB XDATA,这是硬约束) |
| 6 | 值的排列按「掩码位从低到高」,中间插位会把后面所有字段整体后移;旧解析器仍按旧偏移读 → 静默错值。CRC 覆盖的是字节,不覆盖「语义」,所以照样通过。正确做法:追加到最高位 + 升版本号 |
| 7 | 好处:合成帧必须过 CRC 与长度校验才入库,等于每次跑 --fake 都做了一次协议回归;直接写 hub.nodes[addr]=... 会绕过 CRC、长度、掩码一致性检查,一旦协议改动,模拟模式仍然「正常」,失去发现问题的能力 |
| 8 | 心跳 60 s = NODE_STALE → 丢一帧就变灰、误报掉线;心跳 5 s → 无线帧量增加一倍(可接受)但日志刷得快、意义不大。10 s : 60 s = 1:6 的余量,允许连丢 5 帧仍不误判,是「可靠」与「安静」的折中 |
一句话总结本实验: 采集系统真正的难点不在「读传感器」,而在四层之间怎么约定数据、怎么定位故障、怎么让改动不牵连别处。 这份指南里的每一条规矩(掩码、CRC、长度、心跳、stale、状态码、快照), 都是在真实故障上长出来的,不是从教科书抄的。
第二部分 配套资料一:四板四节点 —— 组网、烧录与单节点调试
硬件结构(2026-10 改版):四块板 = 四个独立 ZigBee 节点,一板一 CC2530,不再"三合一"。
板号 板子 51 工程 节点固件 采集/控制 板1 光敏板 51单片机光敏部分/GuangMin.uvprojLightNode.hex光敏 ADC → 簇 0x0400板2 温湿度板 STC单片机读取温湿度传感器程序/AM2321.uvprojTHNode.hex温度/湿度 → 簇 0x0402/0x0405板3 门禁板 51单片机门禁部分/HongWaiDuiShe.uvprojDoorNode.hex红外对射 → 簇 0xFC00板4 执行器板 51单片机RGB蜂鸣器部分/BuzzerRGB.uvprojActuatorNode.hex状态码 → RGB5050 + 有源蜂鸣器 板4 是反向的:它不采数据,只接收状态码。判定在上位机做,协调器只转发。详见同目录《RGB蜂鸣器板-51工程与协议.md》。 | — | 协调器 | — |
CoordinatorNode.hex| 汇聚 + 转 PC |
1. 拓扑与接线
┌──────────────┐ USB(虚拟串口, 38400) ┌─────────┐
│ CC2530 协调器 │◀────────────────────────▶│ PC 上位机 │
└──────┬───────┘ └─────────┘
│ ZigBee 无线(自动组网,2.4GHz)
┌─────────────────┼──────────────────┬──────────────────┐
│ │ │ │
┌──▼───┐ ┌───▼──┐ ┌────▼───┐ ┌────▼────┐
│ 板1 │ │ 板2 │ │ 板3 │ │ 板4 │
│光敏节点│ │温湿度 │ │门禁节点 │ │执行器节点│
└──┬───┘ └───┬──┘ └────┬───┘ └─────────┘
│ UART 9600 │ UART 9600 │ UART 9600
┌──▼───┐ ┌───▼──┐ ┌────▼───┐
│51光敏板│ │51温湿度│ │51门禁板 │
└──────┘ └──────┘ └────────┘三种串口,别混:
| 链路 | 波特率 | 说明 |
|---|---|---|
| 协调器 ↔ PC | 38400 8N1 | 二进制协议 + 下行命令 |
| 51 板 ↔ 各自 CC2530 节点 | 9600 8N1 | 9 字节采集帧(见《51单片机-采集板串口协议.md》) |
| 51 板 ↔ 本板传感器 | 板上已接好 | 光敏=P1.1(ADC1)、红外对射=P1.0(ADC0)、AM2321=P1.1 单总线 |
51 ↔ CC2530 只接 3 根线(交叉 + 共地):
51 TXD(P3.1) ──▶ CC2530 P0_2 (RX)
51 RXD(P3.0) ◀── CC2530 P0_3 (TX)
51 GND ──── CC2530 GND1.1 固定的 PAN ID = 0x1234
网段号(PAN ID)已固定,配置在 cc2530部分/Z-Stack 3.0.1/Projects/zstack/Tools/CC2530DB/f8wConfig.cfg:
-DZDAPP_CONFIG_PAN_ID=0x1234- 协调器建网用它;所有节点也只会加入这个 PAN ID 的网络(其他班的实验箱即使在同一教室也串不进来)
- 上电横幅会打出运行时实际生效的值:
[BOOT] COORD pan=0x1234/[BOOT] LIGHT pan=0x1234 - ⚠️ 工程开了
NV_RESTORE,PAN ID 也存了一份在 NV 里。改了 cfg 必须先把协调器全片擦除, 否则它仍会恢复旧 PANID(横幅会打出旧值,那一刻就是它没擦干净)
1.2 CC Debugger / IAR 下载时,旧 NV 到底擦没擦
结论:CC Debugger 只是"能擦",它不会自动帮你擦。擦不擦由 IAR 里那个勾选框决定。
| 下载方式 | 默认行为 | NV 里的旧 PAN ID |
|---|---|---|
IAR Download and Debug(Ctrl+D) | 不勾 Erase flash,只覆盖 image 涉及的页 | 仍然保留,旧值会被 NV_RESTORE 还原 |
IAR + 勾选 Erase flash | 整片擦除(chip erase) | 清空 |
| SmartRF Flash Programmer | Erase and program 全片擦 | 清空 |
所以光把 hex 换成新固件不够,必须显式让 IAR 擦一次:
Project → Options(Alt+F7)→Debugger→Texas Instruments→Download页签;- 勾上
[x] Erase flash; Download and Debug(Ctrl+D)下载一次 → 关闭调试(Ctrl+Shift+D);- 回到第 2 步把勾去掉(否则以后每次调试都把 NV 清掉,节点每次都要重新入网,调试很痛苦)。
验证只看一个地方:终端横幅
[BOOT] COORD pan=0x1234 ← 对了
[BOOT] COORD pan=0x0416 ← 旧 NV 没擦,回到第 2 步重来另外两点现场经验:
- 调试状态下程序停在断点 / 刚 Download 完还没点
Go,协调器是不组网的。要观察组网,先Ctrl+Shift+D停止调试让板子独立跑,再看[HB]。- 协调器改 PAN ID 后,四块节点也各擦一次最省事。节点 NV 里记着旧网络的 PANID 和密钥,不擦会先去 rejoin 旧网络,失败几次才回退重新入网,现象就是"上电半天没数据"。
2. 固件编译
方式 A:一键编译(推荐)
双击 工具/编译全部固件.bat,它会:
- 自动依次把
zcl_sampletemperaturesensor.h的类型宏切成四种; - 编译
EndDeviceEB,把产物改名收集; - 再编译
CoordinatorEB; - 最后把宏复位成"板1 光敏"。
产物在 固件/:
| 文件 | 烧到哪块板 |
|---|---|
CoordinatorNode.hex | 协调器模块(插 PC 的那个) |
LightNode.hex | 板1 光敏节点 |
THNode.hex | 板2 温湿度节点 |
DoorNode.hex | 板3 门禁节点 |
ActuatorNode.hex | 板4 执行器节点 |
脚本里 IAR 路径写死为
C:\Program Files (x86)\IAR Systems\Embedded Workbench 8.0\..., 换了安装位置就改脚本第 1 段那一行。
方式 B:IAR 里手动编
打开 cc2530部分/Z-Stack 3.0.1/Projects/zstack/HomeAutomation/SampleTemperatureSensor/CC2530DB/SampleTemperatureSensor.eww:
- 改
Source/zcl_sampletemperaturesensor.h里的节点类型宏(只开一个):
#define ZCL_SENSOR_TYPE_ILLUMINANCE /* 板1 光敏板 */
/* #define ZCL_SENSOR_TYPE_TEMP_HUMI */ /* 板2 温湿度板 */
/* #define ZCL_SENSOR_TYPE_DOOR */ /* 板3 门禁板 */
/* #define ZCL_SENSOR_TYPE_ACTUATOR */ /* 板4 执行器板 */- 选配置
EndDeviceEB(传感器/执行器)或CoordinatorEB(协调器); - Rebuild All(改宏后必须全量重编,否则增量编译会漏掉部分文件)。
2.1 编协调器时,要不要把四个传感器宏关掉?——不用
这是 2026-10-18 用两组对照编译实测出来的结论,不是推理:
| 编译条件 | 协调器 hex | 结果 |
|---|---|---|
| 四个宏全关 | 681,452 字节 | 0 Error / 0 Warning |
| 开 光敏宏 | 681,562 字节 | 0 Error / 0 Warning |
| 两者差异 | 110 字节 | 全部落在 SimpleDescriptor 里 |
为什么只差 110 字节,而且不影响功能
- 协调器的业务逻辑全在
#ifdef ZDO_COORDINATOR分支里,跟四个类型宏毫无关系; - 那 110 字节只来自
zcl_sampletemperaturesensor_data.c的SimpleDescriptor——deviceId(ON_OFF_LIGHT/TEMPERATURE_SENSOR/SIMPLE_SENSOR)和InClusterList会跟着宏变; - 但协议栈
af.c里根本没用InClusterList过滤入站报文(grep -n InClusterList af.c零命中), 所以协调器宣告哪些输入簇,完全不影响它收节点上报。
反例:千万别"全关"来编
zcl_sampletemperaturesensor_data.c 第 604 行有个兜底分支:
#else /* 兜底:未定义类型时按光敏板处理,保证可编译 */也就是说四个宏全关 不等于"什么都没有",而是等于"光敏板" —— 反而会把协调器编成光敏身份 (InClusterList 里带着 0x0400)。想"编干净"的人往往在这里翻车。
正确做法:宏保持最后一种状态都无所谓,直接编 CoordinatorEB 就行。 真正要盯的是源码和 hex 别错位:改完 Source/ 就立刻重跑一次 编译全部固件.bat, 不要出现"源码 19:57、hex 还是 19:54"这种半新半旧的组合(这类错位比宏开错更坑,因为它不报错)。
唯一需要留意
InClusterList的场景:用 Z-Tool / Match Descriptor 做服务发现时, 协调器只会报出当前宏对应的那几个簇。日常组网收数据用不到这个接口。
3. 单节点调试(当前:板2 温湿度)
单个板子先跑通,再叠加下一个。顺序建议:温湿度 → 光敏 → 门禁 → 执行器。
📘 教学版逐步指导见《实验指南-传感器数据收发.md》 —— 那份文档从 AM2321 时序讲到 CC2530 上报, 含 6 个检查点、
printf与串口两种调试手段的对比、以及故障排查决策树。本节是给"已经跑通过一次"的人看的速查版。📘 多节点 + PC 上位机的教学版见《实验指南-基于PC的多传感器数据采集.md》 —— 讲四层程序结构、串口协议设计(帧同步/粘包/大小端)、多节点组网(掩码聚合、短地址陷阱)、 调试技巧(分层二分、
--rec抓包、--fake模拟)与 5 个检查点的任务单。本节与第 4 节是它的速查版。
3.1 准备
| 步骤 | 操作 |
|---|---|
| ① | 温湿度 51 板烧 STC单片机读取温湿度传感器程序/out/AM2321.hex(STC-ISP,冷启动下载) |
| ② | 该板 CC2530 烧 固件/THNode.hex(SmartRF Flash Programmer) |
| ③ | 协调器烧 固件/CoordinatorNode.hex,插 PC USB |
| ④ | 接线:51 P3.0/P3.1 交叉接 CC2530 P0_3/P0_2,GND 共地 |
| ⑤ | 三块板全部上电(51 板 5V、CC2530 3.3V) |
⚠️ 节点固件带
NV_RESTORE:如果这块 CC2530 之前入过别的网络,上电会一直尝试恢复旧网络而连不上。 烧录前先 Erase(全片擦除),或烧录后按一下复位键重新入网。 用 CC Debugger + IAR 下载时怎么擦,见 1.2 节。
3.2 看什么
(1) IAR Terminal I/O —— 看节点侧收到的原始数据
要看到 printf 输出,必须用 IAR Download and Debug 跑(不是烧 hex 独立运行):
- 打开工程 → 确认
.h里是ZCL_SENSOR_TYPE_TEMP_HUMI(当前已是这个状态)→Download and Debug→View → Terminal I/O - 正常应该每 0.5 秒出现一行(采样周期 500ms;
[RPT]只在数值变化时才跟着出现):
[51 TH] T=25.3 C H=61.4 %RH- 如果出现:
[51 TH] sensor read FAIL (no report)说明 51 那边回了哨兵值(0x7FFF/0xFFFF)——AM2321 读取失败,先查 51 板本身。
- 如果一行都没有:CC2530 完全没收到 51 的帧 → 查接线 / 波特率 / 共地。
(2) PC 串口助手 —— 看协调器转出的二进制帧
串口助手打开协调器虚拟串口,38400 8N1,切 HEX 显示: 数值稳定时每 10 秒一帧(心跳兜底),数值一变立刻来一帧。
以 25.3℃ / 61.4%RH 为例,完整帧是:
AA 55 09 01 18 00 34 12 E2 09 FC 17 crcL crcH| 字节 | 值 | 含义 |
|---|---|---|
AA 55 | — | 帧头 |
09 | 长度 | 1(版本) + 2(掩码) + 2(地址) + 2(温度) + 2(湿度) = 9 |
01 | 版本 | 固定 0x01 |
18 | mask 低字节 | bit3(温度) + bit4(湿度) = 0x18 |
00 | mask 高字节 | 预留 |
34 12 | 短地址 | 0x1234(小端,每台节点不同,重新组网可能变) |
E2 09 | 温度 | 小端 int16 = 0x09E2 = 2530 = 25.30℃(0.01℃ 单位) |
FC 17 | 湿度 | 小端 uint16 = 0x17FC = 6140 = 61.40%RH(0.01%RH 单位) |
crcL crcH | CRC | CRC16-CCITT,范围从 [3] 起共 长度 个字节 |
关键:温度是 int16,零下要走补码(-5.3℃ → -530 →
EE FD)。 湿度是 uint16,两种都按小端排列(低字节在前)。
(3) 直接用 Python 示例脚本看人读结果(推荐,最省事)
最省事:双击 pc上位机示例/启动上位机.bat,脚本会自动找 CH340 那个口,不用记 COM 号。 (前提:串口助手之类正在看数据的软件要先关掉,否则串口被独占。)
⚠️ 注意别被这个报错误导:
PermissionError(13, '连到系统上的设备没有发挥作用。', None, 31)多数情况不是"被占用",而是 CH340 驱动不接受"在新句柄上第一次就把波特率设成 38400" (Windows 错误 31 = ERROR_GEN_FAILURE)。两个上位机脚本都已内置规避:先用 9600 打开, 再把波特率切到 38400,实测稳定成功。详见 3.5 节。
也可以手动指定:
cd pc上位机示例
pip install pyserial
python zb_host_demo.py COM5 # 带参数就用指定的口;不带参数自动挑正常输出:
[节点 0x1234] 温度=25.30C 湿度=61.40%RH按 l 回车可以列出所有节点当前值。
(4) 图形界面版:智能家居控制中心(投屏演示用)
双击 pc上位机示例/启动智能家居界面.bat,浏览器会自动打开 http://127.0.0.1:8765/。
界面分四块,对应四个板子:
| 卡片 | 显示什么 | 数据来源 |
|---|---|---|
| 光照 | ADC 数值 + 亮度条 + 「很暗/偏暗/适中/明亮/强光」判读 | 光敏节点,簇 0x0400 |
| 门禁 | 门开/门关大字,开门时整张卡片转红告警 | 门禁节点,簇 0xFC00 |
| 温湿度 | 温度(°C)+ 湿度(%RH)+ 舒适度评价 | 温湿度节点,簇 0x0402/0x0405 |
| 执行器 | 自动判定状态码(大字)+ 判定理由 + 6 个手动状态按钮 + 自动开关 | 上位机按阈值判定 → 协调器 0x05 转发 → 执行器 → 51 |
下半区是环境趋势(光照 + 温度折线,滚动画布)和实时数据流(每条 AA 55 原始帧的十六进制 + 解析结果,下行命令是琥珀色)。
要点:
- 不用选执行器地址:状态码命令的地址字段填 0,协调器发给它自己记下的执行器(执行器入网时上报过
0x0006)。 - 阈值在上位机:
smart_home_ui.py顶部的THRESH。光照 <150、温度 ≥30℃、湿度 ≥80%RH、门开即报警;改完存盘重启程序即可,不用重烧固件。 - 优先级:门开 > 温度 > 湿度 > 光照。火警(状态码 5)没有对应传感器,只能点按钮触发。
- 界面上只有真实数据,没有任何模拟值。串口不通时:
- 顶部出现红色告警条,写明具体原因(端口被占用 / 设备已拔出 / 驱动没接受波特率 / 没检测到串口);
- 四个卡片变暗、读数全部显示
--,并写「串口未连接」; - 趋势图显示「串口未连接,无数据可绘」;
- 此时点蜂鸣器/颜色/亮度不会发命令,只弹一句「串口未连接,命令未发出」。
- 联通后如果 3 秒内一帧都没收到,告警条转琥珀色:「串口已连接,但还没收到任何数据帧」—— 这时问题在协调器(没上电 / 没组网 / 没节点入网),而不是串口。
- 超过 20 秒没有新帧也会转琥珀色并提示「已经 N 秒没有新数据」,卡片保留最后一次收到的真实值。
- 告警条右侧可以直接选串口 + 点「连接」,不用重启程序;**「重新检测」重新扫描设备;顶部「换串口」**可手动改口。
- 关掉启动器的黑窗口 = 停止服务。
⚠️ 运行前先关掉串口助手;万一界面一直显示"未连接",先双击
串口体检.bat, 它会直接告诉你是被占用、驱动怪癖还是设备没枚举(见 3.5 节),不用猜。
3.3 温湿度这一路的常见问题
| 现象 | 原因 / 处理 |
|---|---|
| Terminal I/O 一行都没有 | ①51 板没上电 ②TX/RX 没交叉 ③波特率不是 9600 ④51 晶振不是 11.0592MHz |
已入网(state=6)但一行 [51 TH] 都没有 | 51 没在回。看 [51 TH] NO FRAME 那行(见 3.3.1):rxB=0 = 51 没回;rxB>0 = 回了但帧不对(多半烧的是原厂例程) |
有 [51 TH] 但值一直是 sensor read FAIL | AM2321 单总线(P1.1)没应答:上拉电阻、供电、两次读取间隔是否 ≥2s |
| 温度显示 60 多度 / 6553 度 | 符号位没处理,或 PC 端按 uint16 解了 int16 |
| 湿度恒定 0xFFFF | AM2321 校验和错,多半是延时不准(晶振不对) |
帧里 mask 是 08 只有温度没有湿度 | 湿度上报被裁掉了,检查 CC2530 端是否同时上报了两个簇 |
| 协调器一个字节都收不到 | 节点没入网:先 Erase 再上电;或协调器没开 Permit Join |
| 帧头对但 CRC 错 | PC 端 CRC 计算范围错:应是 [3] 起、长度 [2] 个字节 |
3.3.1 [51 TH] NO FRAME —— 串口自检(2026-10-18 新增)
以前 51 不回帧时固件是完全静默的,"入网了但没数据"根本分不清是网的问题还是串口的问题。 现在 TH 节点每 5 秒(10 次采样)体检一次,只有这 5 秒一帧都没解析成功才打印一行:
[51 TH] NO FRAME tx=10 rxB=0 last=0x00
│ │ └── 最后收到的那个字节
│ └──────────── 这 5 秒收到的字节总数
└─────────────────── 这 5 秒发出去的读指令条数(正常应该是 10)| rxB | last | 结论 | 处理 |
|---|---|---|---|
| 0 | 0x00 | CC2530 一个字节都没收到 | 51 没上电/TX-RX 没交叉/51 烧的不是 AM2321 程序 |
| >0 | 0x54('T')等 ASCII | 收到的是 ASCII 文本 | 51 里跑的还是原厂例程(自己 printf 温湿度),要烧 out/AM2321.hex |
| >0 | 0x01 | 帧头对但拼不成 9 字节 | 波特率偏差/丢字节,核对两边是否都是 9600 8N1、51 晶振 11.0592MHz |
| >0 | 其它 | 帧结构不符 | 51 端 [1] 功能码或 [2] 长度不是 0x03/0x04 |
正常出数据时这行完全不出现,不影响
[51 TH] T=..和[RPT]日志。
3.4 先用 [BOOT] / [NWK] / [HB] 定位(2026-10-18 新增自检信息)
现在两边上电都会先打一行横幅,带着固件标识,烧错 hex 一眼就能看出来:
[BOOT] COORD pan=0x1234 ← 协调器
[BOOT] LIGHT pan=0x1234 ← 光敏节点(另有 DOOR / TH / ACTUATOR)组网状态变化时会打 [NWK],协调器还会每 5 秒打一行 [HB] 心跳:
[NWK] COORD state=9 ← 9 = DEV_ZB_COORD,协调器组网成功
[HB] rx5s=1 rx=37 peers=1 st=9 ← 5 秒内收到 1 条上报,累计 37 条,表里 1 个节点state 就是 Z-Stack 的 devStates_t,对照表:
| 值 | 枚举 | 含义 | 谁会出现 |
|---|---|---|---|
| 1 | DEV_INIT | 初始化完,还没连网 | 两边都可能 |
| 2 | DEV_NWK_DISC | 正在扫描可加入的网络 | 节点 |
| 3 | DEV_NWK_JOINING | 正在关联入网(全新入网必经此态) | 节点 |
| 4 | DEV_NWK_SEC_REJOIN_CURR_CHANNEL | 在当前信道做安全重连(NV 里有旧网络) | 节点 |
| 5 | DEV_END_DEVICE_UNAUTH | 已关联,等信任中心认证 | 节点(中间态) |
| 6 | DEV_END_DEVICE | 已入网 | 节点 ✅ |
| 8 | DEV_COORD_STARTING | 正在建网 | 协调器(中间态) |
| 9 | DEV_ZB_COORD | 建网完成 | 协调器 ✅ |
| 10 | DEV_NWK_ORPHAN | 和父节点失联 / 重连旧网络失败 | 节点 ⚠️ |
| 11 | DEV_NWK_KA | 正在给父节点发保活 | 节点(正常) |
| 12 | DEV_NWK_BACKOFF | 重连前的退避等待 | 节点 ⚠️ |
| 13/14/15 | DEV_NWK_SEC/TC_REJOIN_ALL_CHANNEL | 全信道安全重连 / 信任中心重连 | 节点 |
这张表照
Components/stack/zdo/ZDApp.h里devStates_t的实际顺序排的(0 起算)。 Z-Stack 3.0 里没有DEV_NWK_REJOIN这个名字,早期教材里的 4 是另一个枚举,别照抄老资料。
协调器正常流程是 state=8 → 8 → … → 8 → [NWK] network formed, permit join ON → state=9, 中间那串 8 是 BDB 建网过程中的重复状态通知,不是故障。
⚠️
peers=不是关联设备数,它是"上报过数据的节点数"(zcNodes[]里used的条目)。 所以peers=0和rx=0是同一件事,不能用来判断"节点到底有没有关联上来"。
判读顺序(从上看第一条不对的)
| 看到的现象 | 结论 / 处理 |
|---|---|
连 [BOOT] COORD 都没有 | 不是新固件,或没在调试器里跑(Terminal I/O 只在 Download and Debug 下可见) |
只有 [BOOT] COORD,无 [HB] | 初始化里挂了(多半在 bdb_StartCommissioning 之后),重新全片擦除再烧 |
有 [HB] 但 st 停在 8 不往 9 走 | 建网没完成:周围有另一个同 PANID(0x1234)的协调器在抢,或射频模块没插好 |
[BOOT] 里 pan 不是 0x1234 | NV 里还存着旧 PANID:协调器全片擦除后重烧 |
st=9 但 rx5s=0 peers=0 长时间不变 | 先别慌:现在只在"数值变化"时才上报,静止时偶尔 rx5s=0 是正常的,最多 10 秒会有一次心跳帧。持续 1 分钟都是 0 才是真的没节点 |
[HB] rx5s=1 peers=1 + [RX] node=... | 全链路正常 |
从 2026-10-18 起改成变化触发上报(详见 3.6),所以"安静"不再等于"故障": 判断节点在不在,看 1 分钟内是否出现过
rx递增即可。
节点侧(在节点自己的 IAR 里看):
| 现象 | 结论 / 处理 |
|---|---|
[BOOT] LIGHT | 烧的是光敏节点固件,正确 |
无 [NWK] ... state=6 | 没入网。协调器上电后 30 秒内会自动重开一次 Permit Join;仍不行就全片擦除节点再烧 |
state=2 ↔ 10 来回跳,从没出现 3 | 卡在"重连旧网络":NV 里还记着旧 PANID/旧父节点,Z-Stack 只做 rejoin、不做新关联 → 擦 NV(见下方 3.4.1) |
state=2/3/4 反复跳 | 在找网络但加不进去:① 协调器还没到 state=9;② 节点 NV 里记着旧网络(state=4 rejoin);③ 节点固件 PANID 不是 0x1234 |
state=6 但 [RPT] af 非 0 | 上报没交出去:0x10=无路由、0xb7=父节点找不到,仍是入网问题 |
[RPT] cl=0x0400 af=0 但协调器 rx5s=0 | 帧发出去了但没到:两块板子不在同一信道/PANID(核对两边 [BOOT] 的 pan),通常还是没真正入网 |
一行 [RPT] 都没有,但有 [51 LIGHT] | ZCL_SENSOR_REPORT_EVT 没跑起来,检查 .h 里传感器类型宏是否与实际板子一致 |
一直打 [SKIP] d=… < deadband | 正常:读数在噪声范围内抖动,被死区吃掉了,不发包(见 3.6) |
3.4.1 卡在 state=2 ↔ 10 怎么救(2026-10-18 TH 节点实测)
现象长这样 —— state=3(正在关联)从来没出现过,说明它压根没去尝试新入网,只在"找旧父节点":
[BOOT] TH pan=0x1234
[NWK] TH state=2 ← DISC:扫描信道
[NWK] TH state=10 ← ORPHAN:没找到父节点,重连失败
[NWK] TH state=2 ← 再扫
[NWK] TH state=10 ← 再失败……无限循环原因:CC2530 的 NV 区(ZCD_NV_NIB 等)里存着上一次入网的网络参数 —— 旧 PANID、旧 EPID、旧父节点短地址。 协调器一旦换了 PANID 或者被全片擦除重新建网,这些参数全部作废;而 Z-Stack 3.0 的 BDB 对"自认为还在网上"的设备只做 rejoin, 不会自动降级成"重新关联",于是就在 DISC/ORPHAN 之间死循环。跟入网窗口、距离、天线都没关系。
救法(30 秒,二选一)
擦掉节点 NV 再烧(推荐)
- IAR 打开节点工程 → 右键工程
Options→Debugger→Download页 - 勾上
Erase flash→Download and Debug→ 烧完Stop Debugging - 回来取消勾选
Erase flash(否则下次调试会连你的断点/变量都一起清) - 复位后应看到
state=2 → 3 → 5 → 6,这就是正常入了
- IAR 打开节点工程 → 右键工程
把协调器临时改回旧 PANID(不想动节点时才用)
- 只适合"确认节点 NV 里是
0x0416那套旧网络"的情况:f8wConfig.cfg里-DZDAPP_CONFIG_PAN_ID=0x0416, 协调器全片擦除重烧 → 节点能重连成功 → 再统一改回0x1234。绕一圈,不如直接擦节点。
- 只适合"确认节点 NV 里是
光敏/门禁/执行器板同理。判断"烧过之后 NV 干不干净"最快的办法:看第一次入网有没有
state=3和state=5—— 有就是干净的,直接 6;没有就是 NV 没擦。
3.4.2 刚烧完协调器,st=9 但 rx5s=0 rx=0 peers=0 一直不动
这是正常现场,不是故障。 典型日志(2026-10-18 20:0x,重烧 CoordinatorNode.hex 后):
[HB] rx5s=0 rx=0 peers=0 st=8 ← 建网中
[NWK] network formed, permit join ON ← 建网完成,入网窗口已开
[NWK] COORD state=9
[HB] rx5s=0 rx=0 peers=0 st=9 ← 之后一直这样20:19 之后的协调器固件改了这两行的长相(把"调了不管"的返回值打出来了):
[NWK] permit join (init) -> 0x00 ← 建网前那次,0x00 才是真开成功 [NWK] network formed, permit join -> 0x00 ON [HB] permit join re-open -> 0x00 ← 每 30 秒重开一次,顺带报返回码 [JOIN] 新节点入网 nwk=0x2B15 ← 有节点真的关联上来才会出现只要
permit join -> 0x00且每 30 s 有re-open -> 0x00,就说明窗口一直是开的, 节点进不来只可能是节点自己的问题(NV/距离/固件面板不符),不必再怀疑协调器。
逐条读:
| 字段 | 值 | 说明 |
|---|---|---|
st | 8 → 9 | 建网完成,协调器侧一切正常,不用再动它 |
permit join ON | 已打印 | 入网窗口开着(255 s),而且每 30 s 自动重开一次 |
rx5s | 0 | 最近 5 秒没收到任何无线帧 |
rx | 0 | 开机以来一帧都没收到 → 说明从上电到现在还没有任何节点入网 |
peers | 0 | 同上(peers 就是 rx 的另一种说法,见上方提示) |
[JOIN] | 一条都没有 | 没有任何节点完成过关联(这是 20:19 版新增的硬判据) |
为什么一个节点都不回来:协调器重烧时如果勾了 Erase flash,网络是全新建的(新网络密钥 + 新的 nwkKey), 原来三个采集节点 NV 里记的还是旧网络,全部落入 3.4.1 那个 2↔10 死循环 —— 它们不会自己回来。
处理顺序(不要一次全上,一个个来)
- 先烧执行器(它还没入过网,是最干净的验证对象)
- IAR 烧
固件/ActuatorNode.hex,勾Erase flash→Stop Debugging→ 取消勾选 - 预期节点侧:
[BOOT] ACT pan=0x1234→state=2 → 3 → 5 → 6→[ACT] init OK: UART0 9600 -> 51 - 预期协调器侧:
[ACT] actuator online, addr=0x....← 一出现就说明"节点在网 + 协调器能收发"两件事都成立
- IAR 烧
- 再依次擦 NV 重烧三个采集节点(门禁 → 光敏 → 温湿度),每个都要看到
state=3和state=5 - 三个都回来后:
[HB] rx=…会持续增长,peers=3
反过来,如果连刚擦过 flash 的执行器都入不了网(它
state卡在 2/4 不上 6), 那问题就不在节点 NV,而是协调器没真正把入网窗口打开 —— 此时重启一次协调器再看。
3.4.3 「是不是改代码把入网改坏了?」——不会,三个判据就能排除
被问过好几次,记在这里省得每次重新推:
| 判据 | 结论 |
|---|---|
入网发生在 MAC 层(state=2/3/5/6 是 ZDO/NWK 任务在跑),和应用层采集/上报代码是两个任务 | 就算把 zclSensor_* 的业务逻辑全删光,节点照样能入网到 state=6。入不了网跟业务代码无关 |
四个节点 hex 的字节数改了执行器代码后一模一样(LightNode 474,727 / THNode 478,418 / DoorNode 475,605 / ActuatorNode 475,468,20:13–20:16 重编后仍是这四组数) | 说明新增的 #if defined(ZCL_SENSOR_TYPE_ACTUATOR) 代码对另外三个分支零影响,产物逐字节同尺寸 |
| 一次能编出 6 个固件,全部 0 Error / 0 Warning | 工具\编译四节点固件.ps1 会依次切宏重编,任一分支编不过就会立刻红字退出 |
唯一真实的坑:zcl_sampletemperaturesensor.h 里的节点类型宏只有一个能开。 编译某个节点前必须确认开的是它自己那一行,编完立刻把 hex 改名存档(脚本已自动化)。 如果拿执行器固件烧到光敏板上,板子仍能入网(照样 state=6),但上报的是 0x0006 而不是 0x0400, 现场表现是"连上了但界面没光敏数据" —— 这也不是入网问题。
顺带记一条:
固件/里的 hex 和Source/里的源码要同时更新。 2026-10-18 19:57 源码又改过而 19:54 的协调器 hex 没跟上,就会出现"源码是对的、烧进去的是旧的"这种错位。 每次改动后统一跑一遍编译全部固件.bat,比手工编译可靠。
3.5 串口打不开?先跑「串口体检」
双击 pc上位机示例/串口体检.bat(或 python 检查串口.py),它会把每个串口都试一遍并给结论:
端口 设备 结论
COM3 USB-SERIAL CH340 (COM3) 需预热
[COM3] 直接 38400 报 PermissionError(..., 31);9600 预热后成功切到 38400
可用端口:COM3 ← 说明能用,直接去界面里点「连接」结论对照
| 体检结论 | 含义 | 处理 |
|---|---|---|
正常 | 直接 38400 就能开 | — |
需预热 | 驱动不接受"新句柄上第一次设 38400" | 不用管,两个上位机脚本都内置了 9600 → 38400 的规避 |
打不开 + 错误码 5 | 真被占用 | 关掉串口助手 / 另一个上位机窗口 |
打不开 + 错误码 31 | 设备存在但无法工作 | 拔插 USB / 换 USB 口 / 设备管理器里禁用再启用 CH340 / 换线(有些线只供电不通信) |
打不开 + 错误码 2 | 端口不存在 | 设备已拔出;点「重新检测」 |
| 根本没有端口 | 设备未枚举 | 换 USB 口、装 CH340 驱动 |
这次的实例(2026-10-18 16:40):反复重插 USB 仍然报 errno 31, 用 CreateFile 裸探测发现端口能独占打开(错误码 0),说明根本没被占用; 逐步调 API 定位到 GetCommState 成功、SetCommState(38400) 失败、SetCommState(9600) 成功 → 是 CH340 驱动在 38400 这个速率上首次配置失败。改用"先 9600 再切 38400"后立即成功, 随后真实帧 AA 55 07 01 01 19 2B 62 02 80 50(节点 0x2B15,光敏 610)正常收到。
3.6 上报策略:变化才发、微扰不发(2026-10-18 调整)
原来的问题:每 10 秒无条件上报一次 —— 手都摸完了网页才动;而且数值静止时也一直在发,数据流里全是重复值。
现在的规则:节点高频采样,但只有"真的变了"才发无线包。
| 参数 | 门禁板 | 光敏板 | 温湿度板 | 含义 |
|---|---|---|---|---|
ZCL_SENSOR_SAMPLE_PERIOD | 200ms | 300ms | 500ms | 多久问一次 51 单片机(采样,不发包) |
ZCL_SENSOR_*_DEADBAND | 无(0/1 两态) | 10(ADC,约 1%) | 5 = 0.5℃ / 20 = 2.0%RH | 变化量小于它视为"微扰",只更新属性不发包 |
ZCL_SENSOR_DOOR_DEBOUNCE | 2 次(400ms) | — | — | 连续 2 次读数一致才采信;51 侧另有 ADC 回差(400/600),双重防抖 |
ZCL_SENSOR_HB_REPORT | 10s | 10s | 10s | 一直不变也补报一次,防网页误判掉线 |
参数都在 cc2530部分/.../Source/zcl_sampletemperaturesensor.h,一个块里改完即可。
触发条件(满足任一就发)
- 上电后第一次读到有效值;
- 与"上次真正发出去的值"相比,变化量 ≥ 死区;
- 距上次上报 ≥ 10 秒(心跳)。
不满足时只在 Terminal 里打一行,不发包:
[51 LIGHT] ADC=607
[SKIP] d=3 < deadband ← 抖了 3 个数,被死区吃掉,页面数字不会乱跳门禁去抖期间:
[51 DOOR] STATE=1 (OPEN)
[SKIP] debounce 1/2 ← 第 1 次读到"开",再确认一次才上报端到端延时(实测 < 1 秒)
| 环节 | 耗时 | 说明 |
|---|---|---|
| 采样周期 | 0~300ms | 最大头,决定了最快多久能发现变化 |
| 51 应答 | ≈16ms | 读指令 6 字节 + 回复 9 字节,9600 下本身就要这么久 |
| 空口 ZigBee | ≈30ms | 单跳,MAC 层有 ACK |
| 协调器转串口 | ≈5ms | 11 字节 @38400 |
| Python 收帧 | <50ms | 已改成"有多少收多少",不再死等 64 字节 |
| 网页刷新 | ≤250ms | 轮询 /api/state 的间隔 |
四条注意
- 温湿度天然就慢半拍到 2 秒:AM2321 手册规定两次读取间隔 ≥2s,所以 51 端自己每 2 秒采一次并存缓存(
TH_READ_PERIOD),CC2530 只是取缓存。问得再密也只是多几次串口问答,不会压坏传感器;上限是"缓存最多旧 2 秒 + 51 主循环 100ms 节拍",端到端约 2~2.5 秒。 - 死区别设成 0:设 0 等于"变一点就发",光敏 ADC 的 ±2~3 个噪声就能让页面数字一直跳。
- 改了
.h必须重编对应节点的 hex(工具/编译全部固件.bat会自动带上)。协调器不用重编。 - 想让门禁更快可以把采样压到 100ms、去抖改 2 次(=200ms 确认);再快没意义, 因为 51 一次问答本身就要 16ms。
3.7 网页「实时数据流在刷、卡片数值不动」——2026-10-18 已修
现场(20:37 真实快照,curl http://127.0.0.1:8765/api/state 抄下来的):
| 节点 | 值 | 多久前上报 | 说明 |
|---|---|---|---|
0x99D1 | 光敏 590 | 8 秒 | 现在真正在工作的光敏节点 |
0x2B15 | 光敏 589 | 2045 秒 | 重烧(擦 NV)前的旧短地址 |
0x0B13 | 30.00℃ / 90.70% | 2047 秒 | 已掉线 |
0x8F08 | 门禁关 | 3518 秒 | 已掉线 |
0x8F3B | — | 2046 秒 | 入网但没上报 |
根因:后端的节点表是按短地址累积的。节点重烧后协调器会分配新短地址, 旧地址那条记录一直留在表里(掉线的也留着)。而网页取节点用的是:
var ln = nodes.filter(function(n){ return n.light !== null })[0]; // ← 旧写法nodes 按地址升序,[0] 取到的是 0x2B15 那个 34 分钟前的 589, 所以卡片永远显示 589 不动;而"实时数据流"读的是另一个数组,所以照常刷新。
修法(smart_home.html + smart_home_ui.py 都改了)
- 网页改按最后上报时间取最新节点:
pickNode()先sort((a,b)=>b.ts-a.ts)再取[0]; - 后端
snapshot()给每个节点加age(多久没上报)和stale(超过 60 秒), 并给出link.nlive/link.ntotal; - 卡片右上角地址标签带上新鲜度:
0x99D1 · 13s 前/0x0B13 · 3m 前; 过期节点标签转琥珀色、整张卡片数值压暗(.card.stale), 一眼就能分清"这是实时的"还是"这是半小时前的旧值"; - 底部节点数从
节点:5改成在线节点:1 / 4; - 后端加了 30 分钟的历史清理(
NODE_KEEP),重烧留下的旧地址会自动消失。
改完必须重启上位机(启动智能家居界面.bat)—— 前端是每次请求现读文件, 但 smart_home_ui.py 改了要重开进程。重启后节点表从零开始,只剩当前在线的。
这条修复解决的是"看不清",不解决"节点掉线"。 同一次快照里温湿度 34 分钟、门禁 58 分钟没上报 —— 那是节点真的没在发数据, 按 3.4.1 擦 NV 重烧,不是界面问题。
3.8 上位机两个新开关:--rec 抓包 / --fake 模拟(2026-10-18 新增)
| 开关 | 用法 | 作用 |
|---|---|---|
--rec FILE | python smart_home_ui.py COM4 --rec capture.log | 每收到一帧就往 FILE 追加一行:时间 + 原始十六进制 + 解析结果。写报告、事后比对、算延时都靠它 |
--fake | python smart_home_ui.py --fake(或双击 启动模拟演示.bat) | 不打开串口,用 build_up() 合成帧 → 走 parse_upload() 同一套解析与 CRC 校验。40 秒一循环:正常 → 光照不足 → 温度超限 → 门开 |
--fake 不是"直接往界面塞假数字":合成帧必须通过 CRC 与长度校验才会入库, 所以它同时是一次协议自测(改了帧格式忘了改解析,模拟模式立刻露馅)。
合成帧 build_up() ──► AA 55 帧 ──► parse_upload()(CRC/长度校验)──► ingest() ──► 界面
↑ 和硬件来的帧走同一条路另外,--fake 会自动把 link.port 显示成 FAKE、日志里下行帧带"(模拟)"后缀, 方便区分「这是演示」还是「这是真数据」。
附带修掉一个 Windows 上的坑:
HTTPServer默认带SO_REUSEADDR, 两个上位机实例能同时绑 8765,请求随机落到其中一个,表现为"页面数据一半新一半旧"。 现在启动前会先探测端口,占用就打印明确提示并退出(也可--http 8766换端口)。
4. 全部四板联调顺序
- 温湿度(本文第 3 节)→ 记录
[节点 0x____]的短地址; - 光敏:烧
LightNode.hex+GuangMin.hex,Terminal I/O 应出现[51 LIGHT] ADC=xxx; - 门禁:烧
DoorNode.hex+out/HongWaiDuiShe.hex,Terminal I/O 应出现[51 DOOR] STATE=0 (CLOSED),用手挡一下红外对射光路应变OPEN; - 执行器:烧
ActuatorNode.hex+51单片机RGB蜂鸣器部分/out/BuzzerRGB.hex(51 用 STC-ISP 烧)。 上电会先自检(红→绿→蓝 + 一短鸣),然后待命。 接线:CC2530 P0_3(TX) → 51 P3.0(RXD)、CC2530 P0_2(RX) ← 51 P3.1(TXD)、GND 共地。 入网后 IAR 应打印[ACT] actuator online, addr=0x....;网页上出现状态码后即可测试。
四块都入网后,协调器 [HB] peers=3(执行器不计入节点表,只记地址), zb_host_demo.py 按 l 列出 3 个采集节点。
5. 文件位置速查
| 内容 | 路径 |
|---|---|
| 五份固件 | 固件/*.hex |
| 一键编译脚本 | 工具/编译全部固件.bat |
| CC2530 源码 | cc2530部分/Z-Stack 3.0.1/Projects/zstack/HomeAutomation/SampleTemperatureSensor/Source/ |
| 51 光敏板 | 51单片机光敏部分/ |
| 51 温湿度板 | STC单片机读取温湿度传感器程序/ |
| 51 门禁板 | 51单片机门禁部分/ |
| 51↔CC2530 串口协议 | 51单片机-采集板串口协议.md |
| 协调器→PC 协议 | 协调器-PC-二进制协议.md |
| 执行器命令说明 | 执行器节点-ZCL命令控制说明.md |
| PC 上位机示例 | pc上位机示例/zb_host_demo.py |
6. 本次改版清单(相对"三合一"版本)
| 项目 | 改前 | 改后 |
|---|---|---|
| 板子数量 | 3 块(光敏+门磁+火焰三合一 / 温湿度 / 执行器) | 4 块(光敏 / 温湿度 / 门禁 / 执行器),一板一节点 |
| 火焰传感器 | 三合一板上的第 3 路(簇 0xFC01,上行掩码 bit2) | 整体移除(簇、属性、掩码、文档全删,bit2 保留不复用) |
| 门磁 | 与光敏共用一块 51 板 | 独立 51 板 51单片机门禁部分,独立簇 0xFC00 |
| CC2530 节点类型宏 | 3 个 | 4 个(新增 ZCL_SENSOR_TYPE_DOOR) |
| InClusterList | 光敏板 5 簇(含门磁/火焰,会广播不存在的簇) | 每种板只列自己的簇:光敏 3 / 温湿度 4 / 门禁 3 / 执行器 6 |
| 设备类型 | 采集板统一 TEMPERATURE_SENSOR | 温湿度=TEMPERATURE_SENSOR,光敏/门禁=SIMPLE_SENSOR,执行器=ON_OFF_LIGHT |
| 51 帧格式 | 9 字节,[3]~[6] 各板自行解释 | 9 字节,CH0 固定在 [3][4]、CH1 固定在 [5][6],三块板一套代码 |
| 协调器节点表 | zcNodes[16] | zcNodes[8](16 格会让 XDATA 溢出,链接报 Error[e16]) |
| UART DMA 缓冲 | 128 / 128 | 64 / 64(为腾出 XDATA;本实验帧最长 20 字节,够用) |
| EndDevice 配置 | 不产出 hex | 已开启 extra output,产出 SampleTemperatureSensorEndDev.hex |
| 51 工程包含路径 | 空(STC12C5A60S2.h 找不到,三个 51 工程都编不过) | 显式加 C:\Keil_v5\C51\INC;C:\Keil_v5\C51\INC\STC |
| 编译方式 | 手动改宏 + 手动改名 | 工具/编译全部固件.bat 一键出 5 个 hex |
第三部分 配套资料二:执行器节点(蜂鸣器 + RGB 灯)ZCL 命令控制说明
本文件说明如何在同一套 SampleTemperatureSensor 源码里新增一个"执行器节点"角色, 由 PC → 协调器 → 执行器 的链路下发标准 ZCL 命令来控制蜂鸣器与 RGB 灯。
1. 三种板子角色一览
| 角色 | 开关宏 | 采集/执行器件 | 簇(SimpleDescriptor 的 InClusterList) |
|---|---|---|---|
| 采集板 | ZCL_SENSOR_TYPE_ILLUMINANCE | 光敏 + 门磁 + 火焰(经 51 单片机串口) | 0x0000 / 0x0003 / 0x0400 / 0x0402 / 0x0405 / 0xFC00 / 0xFC01 |
| 温湿度板 | ZCL_SENSOR_TYPE_TEMP_HUMI | DHT11 | 同上(温湿度为主) |
| 执行器板 | ZCL_SENSOR_TYPE_ACTUATOR | 蜂鸣器 + RGB 灯 | 0x0000 / 0x0003 / 0x0006 / 0x0008 / 0xFC10 |
开关在 Source/zcl_sampletemperaturesensor.h 里,只允许打开一个:
/* #define ZCL_SENSOR_TYPE_ILLUMINANCE */ /* 采集板:光敏+门磁+火焰 */
/* #define ZCL_SENSOR_TYPE_TEMP_HUMI */ /* 温湿度板 */
#define ZCL_SENSOR_TYPE_ACTUATOR /* 执行器板:蜂鸣器 + RGB */协调器不用改开关:协调器固件里执行器/采集器的业务代码都被
ZDO_COORDINATOR排除, 只有"上行打包 / 下行解析"这一段始终编译。
2. ZCL 簇与属性映射
2.1 On/Off 簇 0x0006(Server)→ 蜂鸣器
| 项目 | 值 |
|---|---|
| 属性 | OnOff 0x0000,BOOLEAN,可读可写可上报 |
| 命令 | Off 0x00 / On 0x01 / Toggle 0x02 |
| 语义 | 1 = 蜂鸣器响,0 = 停 |
2.2 Level Control 簇 0x0008(Server)→ RGB 灯亮灭
| 项目 | 值 |
|---|---|
| 属性 | CurrentLevel 0x0000,UINT8,可读可写可上报 |
| 命令 | MoveToLevel 0x00(level 1~254,transitionTime 忽略) |
| 关灯方式 | level = 0 不能用 MoveToLevel(库校验 MIN=0x01,0 会被丢弃),改用 Write Attributes 写 CurrentLevel = 0 |
| 语义 | 0 = 灯灭,1~254 = 灯亮(亮度档位仅记录 + 可回读) |
这是 Z-Stack 3.0.1
zcl_general.c的硬约束,不是本工程的特殊设计。 协调器已对 PC 隐藏该细节:CMD=0x02, payload=00会自动转成一次属性写。
2.3 厂商自定义颜色簇 0xFC10(Server)→ RGB 颜色
| 属性 ID | 含义 | 类型 | 访问 |
|---|---|---|---|
0x0000 | R | UINT8 0~255 | 读 / 写 |
0x0001 | G | UINT8 0~255 | 读 / 写 |
0x0002 | B | UINT8 0~255 | 读 / 写 |
用标准 Write Attributes (0x02) 下发,一次写三个属性。
2.4 最终输出逻辑
蜂鸣器 = OnOff
灯亮 = (CurrentLevel > 0) && ((R | G | B) != 0)
单通道 = 对应颜色分量 != 03. 硬件引脚(★ 必须按实验箱实际接线核对 ★)
#define ZCL_ACT_BUZZER_PIN P0_6 /* 蜂鸣器 */
#define ZCL_ACT_LED_R_PIN P1_4 /* 红 */
#define ZCL_ACT_LED_G_PIN P1_5 /* 绿 */
#define ZCL_ACT_LED_B_PIN P1_6 /* 蓝 */
#define ZCL_ACT_BUZZER_P0_MASK 0x40 /* P0_6 */
#define ZCL_ACT_LED_P1_MASK 0x70 /* P1_4 | P1_5 | P1_6 */
/* 极性:1 = 高电平亮/响;共阳 RGB 或低电平触发的蜂鸣器改成 0 */
#define ZCL_ACT_BUZZER_ACTIVE_HIGH 1
#define ZCL_ACT_LED_ACTIVE_HIGH 1选脚原因:避开 P0_2/P0_3(UART0)与 P1_0/P1_1(CC2530EB 板载 LED1/LED2)。 换脚时三处要同步改:引脚宏、端口掩码、以及需要时改 P1SEL/P1DIR 语句。
4. 工程配置改动(IAR)
执行器用到了 Z-Stack 的标准 On/Off 与 Level Control 命令分发,这两个模块由编译宏控制, 已在本工程的 SampleTemperatureSensor.ewp 的 三个配置(CoordinatorEB / RouterEB / EndDeviceEB) 的 CCDefines 里各加了两行:
<state>ZCL_ON_OFF</state>
<state>ZCL_LEVEL_CTRL</state>若你手工重建工程,请确认 Preprocessor 里存在这两个宏,否则协调器的 On/Off 命令 会以
UNSUP_CLUSTER_COMMAND默认响应被丢弃,执行器收不到任何动作。
5. 编译与组网
- 协调器:
CoordinatorEB→ 编译下载(不用改开关)。 - 执行器节点:打开
ZCL_SENSOR_TYPE_ACTUATOR,选RouterEB或EndDeviceEB→ 编译下载。 - 采集节点:改回
ZCL_SENSOR_TYPE_ILLUMINANCE,选RouterEB/EndDeviceEB→ 编译下载。 - 上电后执行器自动入网(
bdb_StartCommissioning(NWK_STEERING | FINDING_BINDING))。 - 执行器入网成功后会主动上报一次 OnOff 属性(
zclActuator_ReportState())。 协调器收到后会给 PC 发一帧mask = 0x00的"纯地址帧"—— PC 端看到一个新地址(其余字段为-)就知道那是执行器,可直接用它下发命令。 - 若 PC 没抓到这一帧,也可以用 Z-Tool 查网络拓扑,或在 IAR 里看协调器的
[RX] node=...打印。
6. 下发命令
下行帧格式见 协调器-PC-二进制协议.md 第 7 章。三条常用命令:
| 目标 | CMD | payload |
|---|---|---|
| 蜂鸣器响 / 停 | 0x01 | 01 / 00 |
| 灯亮(亮度 200)/ 灭 | 0x02 | C8 / 00 |
| 设成红色 | 0x03 | FF 00 00 |
| 全部关闭 | 0x04 | 无 |
典型"火焰报警"联动序列(PC 侧检测到 F=1 时):
ser.write(build_down(0x01, addr, bytes([1]))) # 蜂鸣器响
ser.write(build_down(0x03, addr, bytes([255, 0, 0]))) # 红灯
ser.write(build_down(0x02, addr, bytes([200]))) # 点亮
# 火焰消失后
ser.write(build_down(0x04, addr)) # 全部关闭7. 调试输出(IAR Terminal I/O,不占串口)
执行器上电:
[ACT] init OK: buzzer=P0_6 RGB=P1_4/P1_5/P1_6每次状态变化:
[ACT] buzzer=1 level=200 rgb=(255,0,0) light=1协调器侧:
[TX] OnOff=1 -> node 4660
[TX] Color=(255,0,0) -> node 4660
[TX] Level=200 -> node 46608. 已知限制与可选改进
| 限制 | 说明 | 改进方向 |
|---|---|---|
| 亮度不可真正调节 | GPIO 直驱 LED 只有亮/灭,CurrentLevel 仅作档位记录 | RGB 换到 Timer1 的 PWM 通道(P1_0~P1_4 支持 alt2),或用三极管 + PWM |
| 无持续状态上报 | 只在入网时上报一次 OnOff,之后不主动上报 | 在 zclActuator_Apply() 里调用 zclSampleTemperatureSensor_ReportAttr() 上报 OnOff/CurrentLevel |
| Z-Tool 不可用 | 协调器为接收下行命令重开了 UART0,覆盖了 MT 的串口回调 | 若需要 Z-Tool,把下行改到 UART1(P0_4/P0_5) |
| 属性写入靠巡检 | Write Attributes 改属性时 zcl.c 不通知应用,靠 200ms 巡检发现 | 改用自定义簇命令(cluster-specific command)在 API 层回调里直接处理 |
| 同一份源码只能选一个角色 | ZCL_SENSOR_TYPE_* 只能打开一个 | 若要"采集+执行"一体,把两段 #if 改成独立的布尔开关,并把两个簇集合并起来 |
9. 代码位置索引
| 功能 | 文件 | 关键符号 |
|---|---|---|
| 类型宏开关 | zcl_sampletemperaturesensor.h | ZCL_SENSOR_TYPE_ACTUATOR |
| 属性变量定义 | zcl_sampletemperaturesensor_data.c | zclSampleTemperatureSensor_OnOff、zclSensor_LevelCurrentLevel、zclSensor_ColorR/G/B |
| 属性表注册 | 同上 | zclSampleTemperatureSensor_Attrs[] |
| 输入簇列表 | 同上 | zclSampleTemperatureSensor_InClusterList[](执行器分支 5 个簇) |
| 引脚初始化 / 输出刷新 | zcl_sampletemperaturesensor.c | zclActuator_Init()、zclActuator_Apply()、zclActuator_RefreshPoll() |
| 命令回调 | 同上 | zclActuator_OnOffCB()、zclActuator_MoveToLevelCB() |
| 入网上报地址 | 同上 | zclActuator_ReportState()(在 ProcessCommissioningStatus 里调用) |
| 下行组帧/下发 | 同上(#ifdef ZCL_REPORT 块) | zcDownApply()、zcDownWriteLevel()、zcDownWriteColor()、zcDownParseFrame()、zcDownRxCB()、zcDownUartInit() |
| PC 端示例程序 | pc上位机示例/zb_host_demo.py | 上行解析 + 下行命令 + 交互式 CLI |
第四部分 配套资料三:协调器 → PC 上位机 二进制数据协议
本协议定义 CC2530 协调器通过 UART0 向 PC 上位机发送的二进制数据帧。 PC 端只需按此协议解析,即可实时展示各节点的 光敏 / 门禁 / 温度 / 湿度 / 执行器状态 数据。
支持三类上报节点(同一份协调器固件同时兼容,靠掩码区分;执行器不参与上行采集):
节点(板号) 上报的簇 帧里出现的掩码位 板1 光敏节点 0x0400bit0 板3 门禁节点 0xFC00bit1 板2 温湿度节点(AM2321) 0x0402/0x0405bit3 / bit4 板4 执行器节点(蜂鸣器 + RGB)不采集,只在入网后发一帧
mask=0的"纯地址帧", 让 PC 拿到它的短地址用于下发命令。bit2 已废弃:原"火焰"通道随三合一采集板一起取消(2026-10 拆板),协议位保留不复用。
代码位置:
cc2530部分/Z-Stack 3.0.1/.../Source/zcl_sampletemperaturesensor.c(协调器端#ifdef ZCL_REPORT块内:zcSendFrame()/zcCrc16()/zcFindNode()/zcNodes[])
1. 连接与通信参数
| 项目 | 值 | 说明 |
|---|---|---|
| 物理接口 | UART0(CC2530 P0_2 = RX,P0_3 = TX) | 使用 CC2530 的 UART0 |
| 波特率 | 38400 8N1 | 协调器端在 zcDownUartInit() 里显式 HalUARTOpen(..., HAL_UART_BR_38400, ...),收发双向都是 38400 |
| 数据格式 | 8 数据位,无校验,1 停止位 | 8N1 |
| 字节序 | 小端(LSB first) | 多位数值低位在前 |
| 校验 | CRC16-CCITT(多项式 0x1021,初值 0xFFFF) | 帧尾 2 字节 |
⚠️ 请务必确认协调器端 UART0 的实际波特率(若在协调器端又单独
HalUARTOpen过,以那个为准),并在 PC 端串口助手/程序中设置相同值。
2. 帧结构(字节布局)
协调器为每个节点维护一份"最新状态表",收到该节点任一簇上报后立即更新,并打包发送该节点的全量状态帧(用掩码标识当前哪些属性有效)。
偏移 长度 字段
------ ------ --------------------------------
[0] 1 帧头1 0xAA
[1] 1 帧头2 0x55
[2] 1 长度 = 从 [3] 到 CRC 前的字节数
[3] 1 协议版本 0x01
[4] 1 属性掩码低 bit0=光敏 bit1=门禁 (bit2 保留) bit3=温度 bit4=湿度
[5] 1 属性掩码高 (预留,后期扩展新传感器)
[6] 1 节点短地址 低字节
[7] 1 节点短地址 高字节
[8] N 属性值 (小端,按掩码位从低到高)
[..] 1 CRC16 低字节
[..] 1 CRC16 高字节- 长度字段 [2] = 从
[3]到 CRC 前所有字节数。 基础值 = 1(版本) + 2(掩码) + 2(地址) = 5;每有一个有效属性按所占字节数累加 (光敏 2 / 门禁 1 / 温度 2 / 湿度 2)。 单个节点正常只会命中 1~2 个位(一块板只有一种传感器), 例如温湿度节点 = 5 + 2 + 2 = 9,整帧 3 + 9 + 2 = 14 字节(帧头2 + 长度1 + 数据9 + CRC2)。 - CRC16 计算范围:从
[3]到数据末尾(不含帧头 [0][1],也不含 CRC 本身)。
3. 字段详解
3.1 帧头
固定 0xAA 0x55,用于同步帧起始。PC 端可用它做帧搜索。
3.2 长度 / 协议版本
[2]长度:便于 PC 端跳过/校验。[3]协议版本:当前固定0x01。将来协议更版时递增,PC 端可据此兼容。
3.3 属性掩码(mask)
一个 16 位掩码,[4] 为低 8 位([5] 为高 8 位,当前恒为 0)。
| bit | 含义 | 数值单位 / 长度 |
|---|---|---|
| bit0 (0x01) | 光敏可用 | uint16,ADC 0 ~ 1023(占 2 字节) |
| bit1 (0x02) | 门禁可用 | uint8,0 门关闭 / 1 门打开(占 1 字节) |
| bit2 (0x04) | 已废弃 | 原火焰通道,不出现;保留不复用 |
| bit3 (0x08) | 温度可用 | int16,0.01℃,可负(占 2 字节) |
| bit4 (0x10) | 湿度可用 | uint16,0.01 %RH(占 2 字节) |
| bit5+ | 预留 | 后期扩展新传感器 |
掩码置位的属性,其值会按 bit 从低到高依次出现在数据区。未置位的属性不占字节、也不出现在帧中。
3.4 节点短地址
16 位,小端。即传感器节点在 ZigBee 网络内的短地址(0x0000 是协调器自身)。
3.5 属性值(按掩码位顺序)
数值均为小端,不同属性字节数不同:
若 mask 含 bit0(光敏):[值低字节][值高字节] (uint16, ADC 0~1023,占2字节)
若 mask 含 bit1(门禁):[值] (uint8, 0 门关闭 / 1 门开,占1字节)
(bit2 已废弃,不会出现,解析时跳过即可)
若 mask 含 bit3(温度):[值低字节][值高字节] (int16, 0.01℃,占2字节)
若 mask 含 bit4(湿度):[值低字节][值高字节] (uint16, 0.01%RH,占2字节)因属性长度不一,解析时必须按 bit 顺序逐个累加偏移,不能统一按 2 字节步进。 温度是有符号的,PC 端要用
int16解析,否则零下温度会变成一个很大的正数。
3.6 CRC16-CCITT
- 多项式:
0x1021,初值:0xFFFF。 - 计算对象:从字节
[3]到最后一个属性字节。 - 结果小端存放(低位字节在前)。
4. 解析示例(C 语言伪代码)
#include <stdint.h>
// CRC16-CCITT
uint16_t crc16(uint8_t *p, uint16_t len) {
uint16_t crc = 0xFFFF;
for (uint16_t i = 0; i < len; i++) {
crc ^= (uint16_t)((uint16_t)p[i] << 8);
for (int j = 0; j < 8; j++) {
if (crc & 0x8000) crc = (uint16_t)((uint16_t)(crc << 1) ^ 0x1021);
else crc <<= 1;
}
}
return crc;
}
void on_rx_frame(uint8_t *buf, int len) { // buf 为收到的完整帧
if (len < 10) return; // 太短
if (buf[0] != 0xAA || buf[1] != 0x55) return; // 帧头
uint8_t vlen = buf[2]; // 长度 = 从[3]到CRC前
uint8_t ver = buf[3];
uint16_t mask = (uint16_t)(buf[4] | (buf[5] << 8)); // 16 位掩码,别只取低字节
uint16_t addr = (uint16_t)(buf[6] | (buf[7] << 8)); // 小端
uint8_t *d = &buf[8];
uint16_t crc = (uint16_t)(buf[3 + vlen] | (buf[4 + vlen] << 8)); // 帧尾 CRC
if (crc16(&buf[3], vlen) != crc) return; // CRC 对象:buf+3 起 vlen 个字节
uint16_t light = 0; uint8_t door = 0;
int16_t temp = 0; uint16_t humi = 0;
if (mask & 0x01) { light = (uint16_t)(d[0] | (d[1] << 8)); d += 2; } // 光敏
if (mask & 0x02) { door = d[0]; d += 1; } // 门禁(0 关 / 1 开)
/* bit2 已废弃(原火焰),不解析;保留偏移不变 */
if (mask & 0x08) { temp = (int16_t)(d[0] | (d[1] << 8)); d += 2; } // 温度(有符号!)
if (mask & 0x10) { humi = (uint16_t)(d[0] | (d[1] << 8)); d += 2; } // 湿度
printf("node=0x%04X", addr);
if (mask & 0x01) printf(" L=%u", light);
if (mask & 0x02) printf(" D=%s", door ? "门开" : "门关");
if (mask & 0x08) printf(" T=%d.%02d C", temp / 100, abs(temp % 100));
if (mask & 0x10) printf(" H=%u.%02u %%RH", humi / 100, humi % 100);
printf("\n");
}5. 扩展新传感器(后期)
- 在协调器端
zcl_sampletemperaturesensor.c的协议区新增一个掩码位(例如#define ZC_ATTR_CO2 0x20)。 - 在
ProcessInReportCmd中,对应新簇/属性上更新状态表字段并node->mask |= ZC_ATTR_CO2;。 - 在
zcSendFrame()中按掩码位追加值(注意按该属性实际字节数推进长度与偏移)。 - 修改本协议文档:掩码表加一行 + 属性值说明加一项。
- PC 端按新掩码位解析即可。
由于属性是按掩码位顺序排列的,新增位时请追加到掩码高位(bit5、bit6…),不要插入中间,否则会改变已有数据的排列,破坏旧帧解析。
6. 协调器端调试信息(不占用 UART0)
协调器在发送二进制帧的同时,还会通过 printf 输出一份人读的调试信息到 IAR C-SPY 的 Terminal I/O,格式示例:
[RX] node=4660 mask=0x01 L=512
[RX] node=4661 mask=0x02 D=0
[RX] node=4662 mask=0x18 T=25.30 H=61.40- 第一行:板1 光敏节点(mask 低字节
0x01= bit0,光敏 ADC 512) - 第二行:板3 门禁节点(mask 低字节
0x02= bit1,D=0表示门关闭) - 第三行:板2 温湿度节点(mask 低字节
0x18= bit3|bit4,T=25.30是 0.01℃ 单位下的 2530)
mask是该节点累计出现过的属性位,正常一块板只有一个(温湿度两个)位, 所以看到0x01/0x02/0x18分别对应三块采集板,出现别的组合说明接错或代码没对上。 这份调试信息只用于开发观察,不发到 UART0,因此不会干扰 PC 端的二进制数据流。
7. 反向通道:PC → 协调器 → 执行器(下行控制帧)
除上报外,UART0 还支持反向下发控制命令:PC 发一帧命令 → 协调器解析 → 用标准 ZCL 命令 (On/Off、Level Control、Write Attributes)发给执行器节点(蜂鸣器 + RGB 灯)。
7.1 帧结构
偏移 长度 字段
---------- ---- --------------------------------
[0] 1 帧头1 0xAA
[1] 1 帧头2 0x55
[2] 1 长度 LEN = 从 [3] 到 CRC 前的字节数 = 4 + N
[3] 1 协议版本 0x01
[4] 1 命令字 CMD(见下表)
[5] 1 目标节点短地址 低字节
[6] 1 目标节点短地址 高字节
[7..] N payload(可为 0 字节)
[3+LEN] 1 CRC16-CCITT 低字节
[4+LEN] 1 CRC16-CCITT 高字节- 整帧长度 = LEN + 5
- CRC16 计算范围:
[3]起、共LEN个字节(与上行完全一致,同一个zcCrc16())
7.2 命令字(CMD)
| CMD | 名称 | payload | 协调器实际下发的 ZCL 报文 |
|---|---|---|---|
0x01 | On/Off | [0]:0 = 停,1 = 响 | 簇 0x0006 命令 Off(0x00) / On(0x01) → 蜂鸣器 |
0x02 | Level | [0]:0 ~ 254 | 簇 0x0008:1~254 用命令 MoveToLevel(0x00);0 改用 Write Attributes 写 CurrentLevel=0(见 7.6)→ RGB 灯亮/灭 |
0x03 | Color | [0]=R [1]=G [2]=B,各 0~255 | 簇 0xFC10 的 Write Attributes,写属性 0x0000/0x0001/0x0002 |
0x04 | AllOff | 无 | 先 Off 再 MoveToLevel(0):蜂鸣器停 + 灯灭 |
0x05 | SetState | [0]:状态码 0~5 | 簇 0xFC11 的 Write Attributes,写属性 0x0000(StateCode)→ 执行器再从 UART0 发给 51 |
0x05的地址字段可以填 0:表示「发给协调器自己记住的那个执行器」。 协调器在执行器上报(簇0x0006,含mask=0的纯地址帧)时记下它的短地址, PC 因此不必自己维护执行器地址 —— 执行器什么时候入网、什么时候换地址,PC 都不用管。状态码含义见 7.7。
执行器端"灯最终状态" =
CurrentLevel > 0且R|G|B不全为 0。 所以先发 Color 再发 Level;若 Color 已非 0,只发 Level 也能点亮。
7.3 典型帧示例(十六进制,已验证 CRC)
目标节点短地址取 0x1234(小端 = 34 12):
| 动作 | 完整帧 |
|---|---|
| 蜂鸣器响 | AA 55 05 01 01 34 12 01 BC A1 |
| 蜂鸣器停 | AA 55 05 01 01 34 12 00 9D B1 |
| 灯亮,亮度 200 | AA 55 05 01 02 34 12 C8 05 72 |
| 灯灭(亮度 0) | AA 55 05 01 02 34 12 00 41 2A |
| 颜色设为纯红 (255,0,0) | AA 55 07 01 03 34 12 FF 00 00 0B 3B |
| 颜色设为纯绿 (0,255,0) | AA 55 07 01 03 34 12 00 FF 00 97 F7 |
| 全部关闭 | AA 55 04 01 04 34 12 96 D5 |
拆解第一行为例:
AA 55 05 01 01 34 12 01 BC A1
│ │ │ │ │ └─┬─┘ │ └─┬─┘
│ │ │ │ │ │ │ └── CRC16-CCITT(小端)= 0xA1BC
│ │ │ │ │ │ └────── payload:1 = 蜂鸣器响
│ │ │ │ │ └────────── 目标短地址 0x1234(小端)
│ │ │ │ └─────────────── CMD = 0x01 (On/Off)
│ │ │ └────────────────── 协议版本 0x01
│ │ └───────────────────── LEN = 5(=4+1,从 [3] 到 CRC 前)
│ └──────────────────────── 帧头 0x55
└─────────────────────────── 帧头 0xAA换节点地址时必须重算 CRC,不能只改地址字节。
7.4 PC 端组帧函数(Python)
import struct
def crc16_ccitt(data: bytes) -> int:
crc = 0xFFFF
for b in data:
crc ^= b << 8
for _ in range(8):
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if (crc & 0x8000) else (crc << 1) & 0xFFFF
return crc
def build_down(cmd: int, addr: int, payload: bytes = b"") -> bytes:
body = bytes([0x01, cmd, addr & 0xFF, (addr >> 8) & 0xFF]) + payload
c = crc16_ccitt(body)
return b"\xAA\x55" + bytes([len(body)]) + body + struct.pack("<H", c)
# 蜂鸣器响
ser.write(build_down(0x01, 0x1234, bytes([1])))
# 灯亮(亮度 200)
ser.write(build_down(0x02, 0x1234, bytes([200])))
# 红色
ser.write(build_down(0x03, 0x1234, bytes([255, 0, 0])))
# 全部关闭
ser.write(build_down(0x04, 0x1234))7.5 调试信息
协调器每下发一条命令,会在 IAR Terminal I/O 打印一行:
[TX] OnOff=1 -> node 4660
[TX] Level=200 -> node 4660
[TX] Color=(255,0,0) -> node 4660
[TX] AllOff -> node 4660CRC 校验失败时打印:
[RX-PC] CRC error (calc=0x1234 frame=0x5678)7.6 为什么 Level=0 不用 MoveToLevel
Z-Stack 3.0.1 的 zcl_general.c 对 MoveToLevel 有硬性范围校验:
#define ATTR_LEVEL_MIN_LEVEL 0x01
#define ATTR_LEVEL_MAX_LEVEL 0xFE
if ((cmd.level >= ATTR_LEVEL_MIN_LEVEL) && (cmd.level <= ATTR_LEVEL_MAX_LEVEL)) { ... }
else { stat = ZCL_STATUS_INVALID_VALUE; } // 回调根本不会被调用也就是说 level = 0 会被直接丢掉。所以协调器对 Level=0 改用 Write Attributes 写 CurrentLevel(该属性带 ACCESS_CONTROL_WRITE,zcl.c 会直接写内存), 执行器端由 200ms 的属性巡检发现变化后关灯。对 PC 来说协议不变,仍是 CMD=0x02, payload=00。
详细协议实现见
zcl_sampletemperaturesensor.c中#ifdef ZCL_REPORT块内的zcDownApply()/zcDownWriteLevel()/zcDownWriteColor()/zcDownParseFrame()/zcDownRxCB()/zcDownUartInit()。
7.7 状态码(CMD=0x05)与已验证帧
判定在 PC,不在协调器。 上位机 smart_home_ui.py 的 Hub.judge() 按当前节点快照 算出状态码(优先级:门开 > 温度 > 湿度 > 光照 > 正常),再用 0x05 下发给执行器; 执行器把状态码通过 UART0 发给 51 的 RGB/蜂鸣器板。
| 码 | 含义 | 触发条件(上位机侧阈值) | 51 端表现 |
|---|---|---|---|
0 | 正常 | 全部未超限 | 全灭、静音 |
1 | 光照不足 | ADC < 150(越小越暗) | 黄灯,嘀 1 声 |
2 | 温度超限 | ≥ 30.0 ℃ | 红灯,嘀·嘀 2 声 |
3 | 湿度超限 | ≥ 80 %RH | 蓝灯,3 声快 |
4 | 门开 / 入侵 | 门禁 = 1 | 红蓝交替,5 声急促 |
5 | 火警 | 本套件无火焰传感器,仅界面手动触发 | 红快闪,长鸣 |
完整帧(地址填 0,CRC16-CCITT 小端,已核算):
| 状态码 | 完整帧 |
|---|---|
| 0 | AA 55 05 01 05 00 00 00 18 07 |
| 1 | AA 55 05 01 05 00 00 01 39 17 |
| 2 | AA 55 05 01 05 00 00 02 5A 27 |
| 3 | AA 55 05 01 05 00 00 03 7B 37 |
| 4 | AA 55 05 01 05 00 00 04 9C 47 |
| 5 | AA 55 05 01 05 00 00 05 BD 57 |
指定地址时(如
0x1234,状态码 4):AA 55 05 01 05 34 12 04 E8 3B。 只改地址字节,CRC 就必须重算(9C 47→E8 3B),不要手改。
本页组成:第一部分=实验指南主体(四层链路、程序结构、串口设计、多节点组网、调试技巧、任务单、报告考核、速查卡); 第二部分=组网、烧录与单节点调试;第三部分=执行器 ZCL 命令控制;第四部分=协调器-PC 二进制协议。 同目录配套但未并入本页:《51单片机-采集板串口协议.md》《RGB蜂鸣器板-51工程与协议.md》。