Skip to content

一句话理解

从"四块板各自能跑"到"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 Debugger1配 IAR EW8051 8.0
USB-TTLCH340 / CP21021调 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 开始前自检(答不出就先回看) ​

  1. 掩码 0x18 是哪两位?代表什么? → bit3 | bit4,温度 + 湿度。(0x18 = 0001 1000)
  2. 38400 8N1 传 14 个字节要多久? → 14 × 10 bit ÷ 38400 ≈ 3.6 ms。
  3. 短地址 0x99D1 在 AA 55 帧里怎么排? → D1 99(小端,低字节在前)。
  4. 一帧 CRC 校验通过,数据有没有可能还是错的? → 可能。CRC 只保证「这一串比特没在传输中翻转」,不保证「字段解释对了」。 把 0x18 当成光照、把 mask 的高字节漏读,CRC 一样通过。
  5. 一块板重烧后短地址会变。那么上位机能不能拿短地址当「设备唯一编号」? → 不能。这是本实验最容易吃亏的一个认知,见 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 计数5901 LSB ≈ 1/1024 Vref2 字节51 的 ADC
② 51 帧Modbus 响应01 03 04 02 4E 00 00 1D xx仍是 ADC 计数9 字节51 原样装帧
③ ZCL 属性0x0400 的 MeasuredValue590lux(仅名义)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 ms51 采 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 msPC 串口线程收到整帧,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~62024 → 29 ℃关全部正常0
20~26 s120~29 ℃关光照 ADC 120 < 1501
26~32 s~62030 → 32 ℃关温度 30.72℃ ≥ 30.0℃2
32~40 s~62032 → 33 ℃开门开 / 有人进入4
40 s 起循环回 00

同时确认:

  • 底部显示 「在线节点 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 上没有操作系统,用最朴素的「主循环 + 中断」:

c
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(;;) {            │
                            │    找最高优先级任务   │
                            │    有事件 → 调处理函数│
                            │    没事件 → 睡到中断  │
                            │  }                    │
                            └───────────────────────┘

应用层只需要实现两件事:注册事件、处理事件。

c
/* ① 定时器事件:自己给自己定时,不占用 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_EVTosal_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,而是更新自己的一张表,然后把该节点的全量状态打包发出去:

c
#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 序列化一次,节点多时略费

快照是并发编程里最省事的一招,值得单独记住:

python
def snapshot(self):
    with self.lock:                     # 只在拷贝时持锁,序列化在锁外
        return {"link": {...}, "nodes": [...], "log": [...], "auto": {...}}

反面教材:把 hub.nodes 直接交给渲染函数,在渲染过程中串口线程又往里写 —— 轻则数字跳变,重则 RuntimeError: dictionary changed size during iteration。 这类 bug 一晚上只能复现一次,非常难查。

2.5 四条可以迁移到别的项目的结构原则 ​

  1. 采集与通信分离:51 只管读数,CC2530 只管收发。任一侧坏了,另一侧仍可单独验证。
  2. 中断只搬运,决策放主循环/任务:中断里 flag=1,不要在中断里算 CRC、不要在里面发长帧。
  3. 状态集中,输出快照:一个 Hub、一把锁、一个 snapshot()。不要有第二个地方缓存节点数据。
  4. 判定与呈现分离:阈值在 PC,颜色在 51,中间只传一个 状态码 0~5。 这样改阈值不用重烧固件,改颜色不用改判定 —— 两边可以独立演进。

3. 串口设计:三条链路,三种取舍 ​

3.1 先把三条链路摆清楚 ​

链路物理层速率帧格式方向校验谁产生的
传感器 → 51单总线 / ADC—40 bit / ADC 计数单向校验和(AM2321)传感器
51 ↔ CC2530TTL UART9600 8N1Modbus RTU,6 字节问 / 9 字节答双向问答CRC16 Modbus(0xA001)51
CC2530 ↔ 协调器802.15.4 无线250 kbps,信道 16ZCL 属性上报双向MAC 层 CRC协议栈
协调器 ↔ PCUSB-TTL UART38400 8N1AA 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 3A12
ASCII(紧凑)0x99D1,590\n11
ASCII(自解释)NODE=0x99D1,LIGHT=590\n22

单帧长度居然差不多 —— 所以「二进制省带宽」在这个项目里不是主要理由。真正的差别在这四行:

维度二进制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切错能被发现并丢弃,最坏丢一帧单帧丢失,下一帧自动恢复

再加一条范围检查,这是从「教科书协议」到「能跑的协议」的分界线:

python
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 字节

python
# ❌ 错:一帧只有 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(),会把半截帧头丢掉

python
# ❌ 错: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]

python
# ❌ 错:只收到 1 个字节时 buf[2] 直接 IndexError
total = buf[2] + 5

# ✅ 对:先确认够读长度字段
if len(buf) < 3:
    break
total = buf[2] + 5

3.5 长度字段的两种用法:切帧 + 交叉验证 ​

切帧是最低要求。更高一层的用法是用掩码反推长度,和 LEN 字段互相验证:

期望长度 = 5 + 2×bit0 + 1×bit1 + 2×bit3 + 2×bit4
             │  光敏     门禁     温度     湿度
             └── 版本1 + 掩码2 + 地址2

举例:掩码 0x18(温度+湿度)→ 5 + 2 + 2 = 9,整帧 9 + 5 = 14 字节。

python
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 → CC253001 03 04 00 FD 02 66 DA 15大端(高字节在前)0.1℃ / 0.1%RH00FD=253→25.3℃,0266=614→61.4%
协调器 → PCAA 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 端小端解码:

python
# 小端 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协调器实际动作
0x01On/Off[0] 0/1ZCL 簇 0x0006 的 Off/On → 蜂鸣器
0x02Level[0] 0~254ZCL 簇 0x0008 MoveToLevel(0 用写属性,见协议文档 7.6)
0x03Color[0][1][2] R,G,BZCL 簇 0xFC10 写属性
0x04AllOff无Off + Level=0
0x05SetState[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_FAILURECH340 驱动不接受「刚打开就设 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(成功)。于是代码固定这么写:

python
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

拔线与重连:串口线程退出时必须做三件事,否则会出现「页面以为还连着」的假象:

python
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 ID0x1234Projects/zstack/Tools/CC2530DB/f8wConfig.cfg改了就「另立山头」,老节点全部入不了网
信道16(2430 MHz)同上 DEFAULT_CHANLIST=0x00010000换了信道,老节点找不到网络
入网窗口255 s,每 30 s 自动重开协调器 NLME_PermitJoiningRequest(255)窗口关了,新节点怎么上电都进不来
短地址协调器分配协议栈自动重烧/擦 NV 后会变 —— 见 4.4
节点表容量8ZC_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光敏0x04000x01300 ms10(约 1% 量程)
ZCL_SENSOR_TYPE_TEMP_HUMI温湿度0x0402 / 0x04050x18500 ms温度 5(0.5℃)、湿度 20(2%RH)
ZCL_SENSOR_TYPE_DOOR门禁0xFC000x02200 ms连续 2 次一致才认(400 ms 去抖)
ZCL_SENSOR_TYPE_ACTUATOR执行器0x0006/0xFC10/0xFC110x001000 ms—

三条必须记住的规矩(都吃过亏):

  1. 只开一个宏。#if 链只认第一个匹配的分支,同时开两个宏会编出一个「身份混乱」的固件。
  2. 改完宏必须 Rebuild All。增量编译会漏掉部分文件,编出「宏改了但没生效」的产物。
  3. 四个宏全关 ≠ 干净。zcl_sampletemperaturesensor_data.c 里有个兜底分支 #else /* 按光敏板处理 */,全关等于「伪装成光敏板」,InClusterList 里照样带 0x0400。

一个不用拆机的验货技巧:Intel HEX 是 ASCII 文本,固件里的字符串原样存在,直接 grep 就能验身份:

bash
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光照 5908 秒前 ← 真正在工作的那个
0x2B15光照 5892045 秒前 ← 同一块板重烧前的旧地址
0x0B1330.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 一起用:

python
NODE_STALE = 60.0      # 60 秒没上报 → 标记 stale(不参与判定、卡片压暗)
NODE_KEEP  = 1800.0    # 30 分钟没上报 → 从表里删掉(不再显示)

4.5 判定放哪:三个位置的取舍(本项目的架构决策) ​

「温度超 30℃ 就报警」这句话写在哪里,决定了整个系统的可维护性:

位置改动成本实时性全局上下文现场可调
写在节点固件里改阈值要重烧 1 块板最快(本地判断)没有,看不到别的节点不能
写在协调器里改阈值要重烧协调器快有(能看全部节点)不能
写在上位机(本项目)改一个 THRESH 字典慢 250 ms(无所谓)有能,界面上直接改

本项目的结论:协调器一律不做阈值判断,只做转发。 由此产生两个连锁结果:

  1. 下行命令的地址可以填 0(协调器代记执行器地址,见 3.7)。
  2. 判定逻辑集中在一个函数里,优先级一眼看清:
python
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 上报策略:三个参数决定「多久能看到变化」 ​

c
#define ZCL_SENSOR_SAMPLE_PERIOD   300     /* 多久问一次 51 —— 决定最快能多快发现 */
#define ZCL_SENSOR_LIGHT_DEADBAND  10      /* 变化 ≥10 才算真变化 —— 决定抗噪 */
#define ZCL_SENSOR_HB_REPORT       10000   /* 一直不变也 10 秒补报一次 —— 决定"看起来活着" */
节点采样周期死区端到端延时(含网页 250 ms)
光敏300 msADC 变化 ≥ 100.35 ~ 0.65 s
门禁200 ms,去抖 2 次状态变化0.5 ~ 0.9 s
温湿度500 ms温度 ≥ 0.5℃ / 湿度 ≥ 2%RH2.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_STALE60 s数据算不算数(参与判定吗、卡片压暗吗)超时的节点不再参与判定,免得断电的门禁一直挂着「门开」
NODE_KEEP1800 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:把现场留下来 ​

bash
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  门禁=关

三个用途:

  1. 算端到端延时:手指挡住光敏,看相邻两条 光敏=620 → 120 的时间差(精确到秒,够用于 1.4 节的量级验证)。
  2. 写报告:原始字节 + 解析结果并列,比截图有说服力。
  3. 事后比对:出问题时把日志发给别人,对方不用猜你的现场。

--fake:没有硬件也能把界面跑起来 ​

bash
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 步:不要看网页,直接问后端

bash
curl -s http://127.0.0.1:8765/api/state

拿到的节点表是关键证据:

地址值最后上报
0x99D1光照 5908 秒前
0x2B15光照 5892045 秒前
0x0B1330.00℃ / 90.70%2047 秒前
0x8F08门关3518 秒前
0x8F3B—2046 秒前

结论 1:后端没问题 —— 它老老实实收了 5 条记录,最新那条就是 8 秒前的 590。

第 2 步:问题在前端取数逻辑

javascript
// ❌ 原代码
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.pysnapshot() 给每个节点加 age(多久没上报)和 stale;link 加 nlive/ntotal;新增 NODE_KEEP 定期回收历史条目
前端 smart_home.html新增 pickNode(fields):按 ts 降序取第一条;地址标签显示「地址 · 多久前」;stale 的卡片压暗、标签变琥珀色
javascript
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:

取值修复前修复后
lightVal589(死值)595(实时)
lightAddr0x2B150x99D1 · 13s 前
climateAddr0x0B130x0B13 · 3m 前(琥珀色,卡片变暗)
footNode节点:5在线节点:1 / 4

这个案例的三条教训:

  1. 「有数据」和「数据对」是两件事。数据流在刷只证明「串口在收」,不证明「卡片取对了」。
  2. 表的主键选错,会在最不相干的地方发作。短地址一变,问题出现在「网页不刷新」——离根因隔了两层。
  3. 修复要覆盖两端。只改前端(按 ts 取)能治症状;只改后端(回收)也会有 30 分钟的窗口期。两处都改才叫修好。

5.6 常见故障速查 ​

现象最可能的原因下一步
双击 bat 提示「端口 8765 已被占用」已经有一个上位机在跑(Windows 允许两个实例抢同一端口,数据会一半新一半旧)关掉旧窗口;或 --http 8766 换端口
页面红条「上位机未响应」直接双击了 html,没跑 Python用 启动智能家居界面.bat
串口未连接 + errno 31CH340 不接受直接设 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=051 没上电 / 线松 / 波特率串口助手单独测 51
光照值恒定 1023 或 0ADC 通道选错(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 的 TXNO 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协议表一张上行帧表(字段/长度/字节序/校验)+ 一张下行命令字表
4capture.log用 --rec 抓的原始帧,需覆盖:三个采集节点各 ≥ 5 帧、一次状态码下行
5端到端延时光敏 / 门禁各测 3 次,给平均值与测量方法(截图或时间戳)
6故障注入表5 个故障:现象 / 定位层 / 根因 / 恢复动作 / 耗时
7界面截图含「在线节点 3/4」、一个非 0 状态码、一个 stale 卡片
8思考题8 题全做,每题不少于 100 字(要有数字或代码,不要空话)

7.2 思考题 ​

  1. CRC 都通过了,数据为什么还可能是错的? 举出本系统里至少两种「CRC 通过但值错」的场景。
  2. 上行帧为什么不把 LEN 字段省掉(反正掩码能推出长度)?反过来,既然有 LEN,为什么还要 CRC?
  3. 短地址为什么不能当设备唯一编号?如果要真正区分设备,需要改哪三处?
  4. 本项目用「网页每 250 ms 轮询」而不是 WebSocket 推送。列出两个好处和两个代价,并说明什么场景下必须换推送。
  5. ZC_PROTO_MAX_NODES 从 8 改到 16 会链接失败。如果不增加 XDATA,有什么办法支持 16 个节点?(提示:想想哪些字段可以不存)
  6. 如果在掩码中间插入一个新位(比如把 bit2 复活成「火焰」),会发生什么?为什么 CRC 发现不了?
  7. --fake 模式下合成帧要过 parse_upload(),这个设计有什么好处?如果直接 hub.nodes[addr] = {...} 会丢掉什么保护?
  8. 心跳 10 s、NODE_STALE 60 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光照uint16ADC 0~10232
bit1 0x02门禁uint80 关 / 1 开1
bit2 0x04已废弃(原火焰)—不复用0
bit3 0x08温度int160.01 ℃2
bit4 0x10湿度uint160.01 %RH2
bit5+预留(只能往高位追加)———

四条已核算的真实帧(可直接用来验证解析器):

场景帧长度
光敏 590,addr 0x99D1AA 55 07 01 01 00 D1 99 4E 02 91 3A12
温湿度 25.30℃ / 61.40%,addr 0x0B13AA 55 09 01 18 00 13 0B E2 09 FC 17 CD 2B14
温湿度 −5.30℃ / 45.00%,addr 0x0B13AA 55 09 01 18 00 13 0B EE FD 94 11 28 B914
门禁开,addr 0x8F08AA 55 06 01 02 00 08 8F 01 15 BD11
执行器上线(mask=0),addr 0x8F3BAA 55 05 01 00 00 3B 8F 55 0210

第三条是用来练「有符号」的: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说明
01OnOff1 字节 0/1蜂鸣器
02Level1 字节 0~254亮度(0 = 灭)
03Color3 字节 R G B颜色
04AllOff无全关
05SetState1 字节 0~5本项目主用:地址可填 0
动作帧
状态码 0/1/2/3/4/5AA 55 05 01 05 00 00 0S xx xx(完整见 3.7 表)
指定 0x1234 状态码 4AA 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_STALE60 s同上
NODE_KEEP1800 s同上
判定周期 / 补发300 ms / 10 s_judge_loop()
网页轮询250 mssmart_home.html 末尾

A.5 固件身份(不用拆机,grep 就知道烧的是谁) ​

固件应含不应含
LightNode.hex4C49474854(LIGHT)DOOR / ACT / COORD
THNode.hex(无专用串,看 BOOT 打印 TH)DOOR / ACT / COORD
DoorNode.hex444F4F52(DOOR)LIGHT / ACT / COORD
ActuatorNode.hex414354(ACT)LIGHT / DOOR / COORD
CoordinatorNode.hex434F4F5244(COORD)LIGHT / DOOR / ACT

A.6 三条最容易错的地方 ​

  1. 改宏不 Rebuild All → 编出「宏改了但没生效」的固件,且不报错。
  2. 改帧不重算 CRC → 手工改一帧的地址/状态码,CRC 必须一起改(build_down() 会自动算)。
  3. 取节点不看时间戳 → 重烧换地址后,卡片可能永远显示一个旧值(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 判读(已并入本页第三部分)
固件/*.hex5 个固件(4 节点 + 协调器)
工具/编译全部固件.bat一键重编 5 个固件(避免源码与 hex 错位)

B.2 常用命令 ​

bash
# 正常启动(自动挑串口)
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 IDZigBee 网络号,本项目 0x1234。不同 PAN 的设备互相看不见
短地址协调器分配的 16 位网络内地址,重烧/重入网可能变
掩码(mask)用位标记「这一帧里哪些属性有效」,实现自描述与向后兼容
死区(deadband)变化小于此值视为噪声,不上报,防刷屏、防误判
心跳(heartbeat)数据不变时定时补报一帧,用于证明「节点还活着」
stale超过 NODE_STALE 没上报,数据视为过期,不参与判定
聚合协调器把节点最新值存表后整帧打包,而非透传原始报文
快照(snapshot)把内部可变状态一次性拷成不可变 JSON,供外部读取
粘包 / 半包串口字节流里多帧连在一起 / 一帧分多次到达
ADR本指南里的「上行/下行」:上行 = 节点→PC 数据,下行 = PC→执行器命令

附录 D 思考题参考答案要点(教师用) ​

题要点
1① 单位/量纲错(0.1 当 0.01);② 字节序错(大端当小端);③ 掩码位解释错(bit3 与 bit4 互换);④ 长度与掩码不一致但仍落在合法范围。CRC 只保证比特未翻转
2LEN 是切帧用(在解析出 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门禁板 │
└──────┘         └──────┘          └────────┘

三种串口,别混:

链路波特率说明
协调器 ↔ PC38400 8N1二进制协议 + 下行命令
51 板 ↔ 各自 CC2530 节点9600 8N19 字节采集帧(见《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 GND

1.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 ProgrammerErase and program 全片擦清空

所以光把 hex 换成新固件不够,必须显式让 IAR 擦一次:

  1. Project → Options(Alt+F7)→ Debugger → Texas Instruments → Download 页签;
  2. 勾上 [x] Erase flash;
  3. Download and Debug(Ctrl+D)下载一次 → 关闭调试(Ctrl+Shift+D);
  4. 回到第 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,它会:

  1. 自动依次把 zcl_sampletemperaturesensor.h 的类型宏切成四种;
  2. 编译 EndDeviceEB,把产物改名收集;
  3. 再编译 CoordinatorEB;
  4. 最后把宏复位成"板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:

  1. 改 Source/zcl_sampletemperaturesensor.h 里的节点类型宏(只开一个):
c
#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 执行器板 */
  1. 选配置 EndDeviceEB(传感器/执行器)或 CoordinatorEB(协调器);
  2. Rebuild All(改宏后必须全量重编,否则增量编译会漏掉部分文件)。

2.1 编协调器时,要不要把四个传感器宏关掉?——不用 ​

这是 2026-10-18 用两组对照编译实测出来的结论,不是推理:

编译条件协调器 hex结果
四个宏全关681,452 字节0 Error / 0 Warning
开 光敏宏681,562 字节0 Error / 0 Warning
两者差异110 字节全部落在 SimpleDescriptor 里

为什么只差 110 字节,而且不影响功能

  1. 协调器的业务逻辑全在 #ifdef ZDO_COORDINATOR 分支里,跟四个类型宏毫无关系;
  2. 那 110 字节只来自 zcl_sampletemperaturesensor_data.c 的 SimpleDescriptor —— deviceId(ON_OFF_LIGHT / TEMPERATURE_SENSOR / SIMPLE_SENSOR)和 InClusterList 会跟着宏变;
  3. 但协议栈 af.c 里根本没用 InClusterList 过滤入站报文(grep -n InClusterList af.c 零命中), 所以协调器宣告哪些输入簇,完全不影响它收节点上报。

反例:千万别"全关"来编

zcl_sampletemperaturesensor_data.c 第 604 行有个兜底分支:

c
#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
18mask 低字节bit3(温度) + bit4(湿度) = 0x18
00mask 高字节预留
34 12短地址0x1234(小端,每台节点不同,重新组网可能变)
E2 09温度小端 int16 = 0x09E2 = 2530 = 25.30℃(0.01℃ 单位)
FC 17湿度小端 uint16 = 0x17FC = 6140 = 61.40%RH(0.01%RH 单位)
crcL crcHCRCCRC16-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 节。

也可以手动指定:

bash
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 FAILAM2321 单总线(P1.1)没应答:上拉电阻、供电、两次读取间隔是否 ≥2s
温度显示 60 多度 / 6553 度符号位没处理,或 PC 端按 uint16 解了 int16
湿度恒定 0xFFFFAM2321 校验和错,多半是延时不准(晶振不对)
帧里 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)
rxBlast结论处理
00x00CC2530 一个字节都没收到51 没上电/TX-RX 没交叉/51 烧的不是 AM2321 程序
>00x54('T')等 ASCII收到的是 ASCII 文本51 里跑的还是原厂例程(自己 printf 温湿度),要烧 out/AM2321.hex
>00x01帧头对但拼不成 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,对照表:

值枚举含义谁会出现
1DEV_INIT初始化完,还没连网两边都可能
2DEV_NWK_DISC正在扫描可加入的网络节点
3DEV_NWK_JOINING正在关联入网(全新入网必经此态)节点
4DEV_NWK_SEC_REJOIN_CURR_CHANNEL在当前信道做安全重连(NV 里有旧网络)节点
5DEV_END_DEVICE_UNAUTH已关联,等信任中心认证节点(中间态)
6DEV_END_DEVICE已入网节点 ✅
8DEV_COORD_STARTING正在建网协调器(中间态)
9DEV_ZB_COORD建网完成协调器 ✅
10DEV_NWK_ORPHAN和父节点失联 / 重连旧网络失败节点 ⚠️
11DEV_NWK_KA正在给父节点发保活节点(正常)
12DEV_NWK_BACKOFF重连前的退避等待节点 ⚠️
13/14/15DEV_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 不是 0x1234NV 里还存着旧 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 秒,二选一)

  1. 擦掉节点 NV 再烧(推荐)

    • IAR 打开节点工程 → 右键工程 Options → Debugger → Download 页
    • 勾上 Erase flash → Download and Debug → 烧完 Stop Debugging
    • 回来取消勾选 Erase flash(否则下次调试会连你的断点/变量都一起清)
    • 复位后应看到 state=2 → 3 → 5 → 6,这就是正常入了
  2. 把协调器临时改回旧 PANID(不想动节点时才用)

    • 只适合"确认节点 NV 里是 0x0416 那套旧网络"的情况:f8wConfig.cfg 里 -DZDAPP_CONFIG_PAN_ID=0x0416, 协调器全片擦除重烧 → 节点能重连成功 → 再统一改回 0x1234。绕一圈,不如直接擦节点。

光敏/门禁/执行器板同理。判断"烧过之后 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/距离/固件面板不符),不必再怀疑协调器。

逐条读:

字段值说明
st8 → 9建网完成,协调器侧一切正常,不用再动它
permit join ON已打印入网窗口开着(255 s),而且每 30 s 自动重开一次
rx5s0最近 5 秒没收到任何无线帧
rx0开机以来一帧都没收到 → 说明从上电到现在还没有任何节点入网
peers0同上(peers 就是 rx 的另一种说法,见上方提示)
[JOIN]一条都没有没有任何节点完成过关联(这是 20:19 版新增的硬判据)

为什么一个节点都不回来:协调器重烧时如果勾了 Erase flash,网络是全新建的(新网络密钥 + 新的 nwkKey), 原来三个采集节点 NV 里记的还是旧网络,全部落入 3.4.1 那个 2↔10 死循环 —— 它们不会自己回来。

处理顺序(不要一次全上,一个个来)

  1. 先烧执行器(它还没入过网,是最干净的验证对象)
    • 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.... ← 一出现就说明"节点在网 + 协调器能收发"两件事都成立
  2. 再依次擦 NV 重烧三个采集节点(门禁 → 光敏 → 温湿度),每个都要看到 state=3 和 state=5
  3. 三个都回来后:[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_PERIOD200ms300ms500ms多久问一次 51 单片机(采样,不发包)
ZCL_SENSOR_*_DEADBAND无(0/1 两态)10(ADC,约 1%)5 = 0.5℃ / 20 = 2.0%RH变化量小于它视为"微扰",只更新属性不发包
ZCL_SENSOR_DOOR_DEBOUNCE2 次(400ms)——连续 2 次读数一致才采信;51 侧另有 ADC 回差(400/600),双重防抖
ZCL_SENSOR_HB_REPORT10s10s10s一直不变也补报一次,防网页误判掉线

参数都在 cc2530部分/.../Source/zcl_sampletemperaturesensor.h,一个块里改完即可。

触发条件(满足任一就发)

  1. 上电后第一次读到有效值;
  2. 与"上次真正发出去的值"相比,变化量 ≥ 死区;
  3. 距上次上报 ≥ 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
协调器转串口≈5ms11 字节 @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光敏 5908 秒现在真正在工作的光敏节点
0x2B15光敏 5892045 秒重烧(擦 NV)前的旧短地址
0x0B1330.00℃ / 90.70%2047 秒已掉线
0x8F08门禁关3518 秒已掉线
0x8F3B—2046 秒入网但没上报

根因:后端的节点表是按短地址累积的。节点重烧后协调器会分配新短地址, 旧地址那条记录一直留在表里(掉线的也留着)。而网页取节点用的是:

js
var ln = nodes.filter(function(n){ return n.light !== null })[0];   // ← 旧写法

nodes 按地址升序,[0] 取到的是 0x2B15 那个 34 分钟前的 589, 所以卡片永远显示 589 不动;而"实时数据流"读的是另一个数组,所以照常刷新。

修法(smart_home.html + smart_home_ui.py 都改了)

  1. 网页改按最后上报时间取最新节点:pickNode() 先 sort((a,b)=>b.ts-a.ts) 再取 [0];
  2. 后端 snapshot() 给每个节点加 age(多久没上报)和 stale(超过 60 秒), 并给出 link.nlive / link.ntotal;
  3. 卡片右上角地址标签带上新鲜度:0x99D1 · 13s 前 / 0x0B13 · 3m 前; 过期节点标签转琥珀色、整张卡片数值压暗(.card.stale), 一眼就能分清"这是实时的"还是"这是半小时前的旧值";
  4. 底部节点数从 节点:5 改成 在线节点:1 / 4;
  5. 后端加了 30 分钟的历史清理(NODE_KEEP),重烧留下的旧地址会自动消失。

改完必须重启上位机(启动智能家居界面.bat)—— 前端是每次请求现读文件, 但 smart_home_ui.py 改了要重开进程。重启后节点表从零开始,只剩当前在线的。

这条修复解决的是"看不清",不解决"节点掉线"。 同一次快照里温湿度 34 分钟、门禁 58 分钟没上报 —— 那是节点真的没在发数据, 按 3.4.1 擦 NV 重烧,不是界面问题。


3.8 上位机两个新开关:--rec 抓包 / --fake 模拟(2026-10-18 新增) ​

开关用法作用
--rec FILEpython smart_home_ui.py COM4 --rec capture.log每收到一帧就往 FILE 追加一行:时间 + 原始十六进制 + 解析结果。写报告、事后比对、算延时都靠它
--fakepython 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. 全部四板联调顺序 ​

  1. 温湿度(本文第 3 节)→ 记录 [节点 0x____] 的短地址;
  2. 光敏:烧 LightNode.hex + GuangMin.hex,Terminal I/O 应出现 [51 LIGHT] ADC=xxx;
  3. 门禁:烧 DoorNode.hex + out/HongWaiDuiShe.hex,Terminal I/O 应出现 [51 DOOR] STATE=0 (CLOSED),用手挡一下红外对射光路应变 OPEN;
  4. 执行器:烧 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 / 12864 / 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_HUMIDHT11同上(温湿度为主)
执行器板ZCL_SENSOR_TYPE_ACTUATOR蜂鸣器 + RGB 灯0x0000 / 0x0003 / 0x0006 / 0x0008 / 0xFC10

开关在 Source/zcl_sampletemperaturesensor.h 里,只允许打开一个:

c
/* #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含义类型访问
0x0000RUINT8 0~255读 / 写
0x0001GUINT8 0~255读 / 写
0x0002BUINT8 0~255读 / 写

用标准 Write Attributes (0x02) 下发,一次写三个属性。

2.4 最终输出逻辑 ​

蜂鸣器 = OnOff
灯亮   = (CurrentLevel > 0) && ((R | G | B) != 0)
单通道 = 对应颜色分量 != 0

3. 硬件引脚(★ 必须按实验箱实际接线核对 ★) ​

c
#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 里各加了两行:

xml
<state>ZCL_ON_OFF</state>
<state>ZCL_LEVEL_CTRL</state>

若你手工重建工程,请确认 Preprocessor 里存在这两个宏,否则协调器的 On/Off 命令 会以 UNSUP_CLUSTER_COMMAND 默认响应被丢弃,执行器收不到任何动作。


5. 编译与组网 ​

  1. 协调器:CoordinatorEB → 编译下载(不用改开关)。
  2. 执行器节点:打开 ZCL_SENSOR_TYPE_ACTUATOR,选 RouterEB 或 EndDeviceEB → 编译下载。
  3. 采集节点:改回 ZCL_SENSOR_TYPE_ILLUMINANCE,选 RouterEB/EndDeviceEB → 编译下载。
  4. 上电后执行器自动入网(bdb_StartCommissioning(NWK_STEERING | FINDING_BINDING))。
  5. 执行器入网成功后会主动上报一次 OnOff 属性(zclActuator_ReportState())。 协调器收到后会给 PC 发一帧 mask = 0x00 的"纯地址帧"—— PC 端看到一个新地址(其余字段为 -)就知道那是执行器,可直接用它下发命令。
  6. 若 PC 没抓到这一帧,也可以用 Z-Tool 查网络拓扑,或在 IAR 里看协调器的 [RX] node=... 打印。

6. 下发命令 ​

下行帧格式见 协调器-PC-二进制协议.md 第 7 章。三条常用命令:

目标CMDpayload
蜂鸣器响 / 停0x0101 / 00
灯亮(亮度 200)/ 灭0x02C8 / 00
设成红色0x03FF 00 00
全部关闭0x04无

典型"火焰报警"联动序列(PC 侧检测到 F=1 时):

python
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 4660

8. 已知限制与可选改进 ​

限制说明改进方向
亮度不可真正调节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.hZCL_SENSOR_TYPE_ACTUATOR
属性变量定义zcl_sampletemperaturesensor_data.czclSampleTemperatureSensor_OnOff、zclSensor_LevelCurrentLevel、zclSensor_ColorR/G/B
属性表注册同上zclSampleTemperatureSensor_Attrs[]
输入簇列表同上zclSampleTemperatureSensor_InClusterList[](执行器分支 5 个簇)
引脚初始化 / 输出刷新zcl_sampletemperaturesensor.czclActuator_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 语言伪代码) ​

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. 扩展新传感器(后期) ​

  1. 在协调器端 zcl_sampletemperaturesensor.c 的协议区新增一个掩码位(例如 #define ZC_ATTR_CO2 0x20)。
  2. 在 ProcessInReportCmd 中,对应新簇/属性上更新状态表字段并 node->mask |= ZC_ATTR_CO2;。
  3. 在 zcSendFrame() 中按掩码位追加值(注意按该属性实际字节数推进长度与偏移)。
  4. 修改本协议文档:掩码表加一行 + 属性值说明加一项。
  5. 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 报文
0x01On/Off[0]:0 = 停,1 = 响簇 0x0006 命令 Off(0x00) / On(0x01) → 蜂鸣器
0x02Level[0]:0 ~ 254簇 0x0008:1~254 用命令 MoveToLevel(0x00);0 改用 Write Attributes 写 CurrentLevel=0(见 7.6)→ RGB 灯亮/灭
0x03Color[0]=R [1]=G [2]=B,各 0~255簇 0xFC10 的 Write Attributes,写属性 0x0000/0x0001/0x0002
0x04AllOff无先 Off 再 MoveToLevel(0):蜂鸣器停 + 灯灭
0x05SetState[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
灯亮,亮度 200AA 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) ​

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 4660

CRC 校验失败时打印:

[RX-PC] CRC error (calc=0x1234 frame=0x5678)

7.6 为什么 Level=0 不用 MoveToLevel ​

Z-Stack 3.0.1 的 zcl_general.c 对 MoveToLevel 有硬性范围校验:

c
#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 小端,已核算):

状态码完整帧
0AA 55 05 01 05 00 00 00 18 07
1AA 55 05 01 05 00 00 01 39 17
2AA 55 05 01 05 00 00 02 5A 27
3AA 55 05 01 05 00 00 03 7B 37
4AA 55 05 01 05 00 00 04 9C 47
5AA 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》。

aioteer · 物联网专业课程群

💬AI 伴学配置