485-Modbus 通信与联动(v2简化版)
状态:设计稿 v2。当前
uart0_485仍只有半双工测试收发,本文约定用于后续固件实现。
目标:在没有固定 Modbus Master 的 RS485 总线上,完成属性读写、全局状态广播、规则安装、联动执行和新设备修改站号。
1. 设计原则
- RS485 半双工,任意时刻只允许一个发送端;所有节点必须做 CRC 校验、地址过滤和帧间隔判断。
- 报文层尽量使用 Modbus RTU:功能码
0x01/02/03/04/05/06/0F/10。 - 少量扩展功能码只用于新设备扫描和修改站号:
0x41/0x42。 - 简单属性、全局状态、规则启停和状态用寄存器;规则 JSON、批量配置和长文本统一走长包缓存。
- 广播帧不确认。事件源按周期重发当前值;接收端以最后写入的值为准,不需要版本去重。
- 新设备扫描回复带 8 字节唯一序列码;序列码只用于绑定/修改站号,不参与常规寄存器读写。
2. 设备站点地址/ID
| 地址 | 用途 |
|---|---|
0x00 |
广播地址。所有节点接收处理,但不回复。 |
0x01~0xEF |
单播站号,同一条总线必须唯一。 |
40(0x28) |
扫描应答伪地址。只在扫描回复帧中使用,不是可分配站号。 |
0xF1~0xFF |
预留,不使用。 |
节点身份沿用 data_485node:
| 字段 | 含义 |
|---|---|
pid |
产品品类,例如 rly4p3。 |
pid_index |
同品类对象数组下标。 |
addr485 |
当前 485 总线站号。 |
站号分配只发生在添加设备时完成。运行期间影子设备集合和站号映射固定,不自动增删。
3. 数据定义
3.1 字节序与类型
| 项目 | 约定 |
|---|---|
| 16 位寄存器 | 大端。 |
| 32 位整数 / float | 连续两个寄存器,大端;仅字段明确标注 float / 32 位时使用,本协议默认不用。 |
| 字符串 / JSON | 按字节流装入寄存器,高字节在前,尾部补 0x00。 |
| Coil | 1 / 0;写单线圈时 0xFF00=ON,0x0000=OFF。 |
| 小数(默认) | 16 位原始整数 × scale;物理值 = 原始整数 × scale(如 256 × 0.1 = 25.6 ℃、52 × 0.1 = 5.2 mg/L)。 |
| 负数 | 16 位有符号补码,按补码解释(如 -205 → 原始值 0xFF33);字段需在物模型字典中标明“有符号”。 |
物模型数值默认用 16 位寄存器 + scale 表达:寄存器存原始整数,物理值 = 原始整数 × scale,scale 在物模型字典里定义(
0.1/0.01/1…)。可为负的字段用 16 位补码(有符号)。规则引擎比较的是原始整数,不做 scale 换算(见 §9.1);网关读原始值后换算成云端 JSON 浮点。仅当字段明确标注 float / 32 位时才用连续两寄存器的 32 位大端浮点。
3.2 产品属性映射Modbus寄存器
最常用的 Modbus 寄存器地址划分表
| 类型区 | 逻辑地址 | 类型 | 读写 | 常用功能码 |
|---|---|---|---|---|
| 0 区 | 00001–09999 | 线圈 | 读写 | 01、05、15 |
| 1 区 | 10001–19999 | 离散输入 | 只读 | 02 |
| 3 区 | 30001–39999 | 输入寄存器 | 只读 | 04 |
| 4 区 | 40001–49999 | 保持寄存器 | 读写 | 03、06、16 |
设备使用全局唯一地址。
Coil、Discrete-Input、Input-Register、Holding-Register。
一个地址只能属于一个功能区,不同功能区之间不得重叠。
物模型 对应地址默认放在划区靠前的100以内的地址。
具体是(0-100)、(10001-10100)、(30001-30100)、(40001-40100)、
其它功能 含参数\选项\时间\缓冲等各类配置一律放Holding-Register区,
且开始地址40300起,这是考虑到某产品物模型属性过多时有统一的 其它功能 处理逻辑。
| 物模型类型 / 权限 | Modbus 区 | 读功能码 | 写功能码 |
|---|---|---|---|
| boolean,可写 | Coils | 0x01 |
0x05 / 0x0F |
| boolean,只读 | Discrete Inputs | 0x02 |
无 |
| number,只读 | Input Registers | 0x04 |
无 |
| number,可写 | Holding Registers | 0x03 |
0x06 / 0x10 |
| string / object / JSON / 长数组 | 长包缓存区 | 0x03 |
0x10 |
rly4p3 示例:
| 字段 | 类型 / 权限 | 区 | 地址 | scale | 单位 | 说明 |
|---|---|---|---|---|---|---|
switch1~switch4 |
boolean,W | Coil | 00001~00004 |
— | — | 开关。 |
water_temp |
number,R | Input Register | 30001 |
0.1 | ℃ | — |
air_temp |
number,R | Input Register | 30002 |
0.1 | ℃ | — |
air_pressure |
number,R | Input Register | 30003 |
0.1 | hPa | — |
oxygen |
number,R | Input Register | 30004 |
0.1 | mg/L | — |
voltage |
number,R | Input Register | 30005 |
0.1 | V | — |
current |
number,R | Input Register | 30006 |
0.1 | A | — |
signal |
number,R | Input Register | 30007 |
1 | dBm | 有符号(补码)。 |
物模型小数统一用“原始整数 × scale”表达(如
256 × 0.1 = 25.6 ℃),16 位输入寄存器即够,不需要 32 位浮点;每个字段占 1 个连续寄存器。
4. 寄存器地址范围表
物模型对应地址放在各划区靠前 100 个地址:Coil 0~100、Discrete Input 10001~10100、Input Register 30001~30100、Holding Register 40001~40100。
其它功能(参数、选项、时间、缓冲等各类配置)
一律放 Holding-Register 区,逻辑地址 40300 起。
相同功能按100对齐
帧内 Modbus 数据地址用 PDU 偏移 = 逻辑地址 − 40001。
下表为公共功能区总表;
| 逻辑地址 | 名称 | 权限 | 说明 |
|---|---|---|---|
40001~40100 |
产品属性 / 配置区 | 按产品 | 物模型属性与简单配置;不得越界到 40300 起的公共区。 |
40300~40307 |
设备身份区 | 只读 | 8 字:硬件码(4)、485 ID(1)、预留(3);详见 5.3。 |
40400~40415 |
日期时间区 | 只读 | 16 字:本机 RTC;当前定义 7 个字,其余保留。 |
40500~40563 |
全局状态区 | 广播写 / 单播读 | 64 个全局状态字 GS00~GS63。 |
40600~40727 |
规则暂存区 | 规则写 | 128 字;规则动作专用暂存;规则写不得落到产品配置区。 |
40800~40863 |
规则控制与状态表 | 控制写 / 状态读 | 64 字:固定 16 条规则。 |
40900~41155 |
长包缓存区 | 读写 | 256 个连续寄存器,512 字节;前 8 字为 LP_CTRL / LP_LEN / LP_CRC16 / LP_STATE / LP_ERR 控制字,40908 起 496 字节为数据区;三段式 + commit 流程详见第 8 节。 |
日期时间区使用本机 RTC,按普通 Modbus 寄存器读取;本区只读,写操作返回 0x02 Illegal Data Address。
| 逻辑地址 | PDU 偏移 | 名称 | 范围 | 说明 |
|---|---|---|---|---|
40400 |
0x018F |
RTC_YEAR |
2000~2099 |
完整年份,例如 2026。 |
40401 |
0x0190 |
RTC_MONTH |
1~12 |
月。 |
40402 |
0x0191 |
RTC_DAY |
1~31 |
日。 |
40403 |
0x0192 |
RTC_HOUR |
0~23 |
时,24 小时制。 |
40404 |
0x0193 |
RTC_MINUTE |
0~59 |
分。 |
40405 |
0x0194 |
RTC_SECOND |
0~59 |
秒。 |
40406 |
0x0195 |
RTC_WEEKDAY |
1~7 |
星期,1=周一,7=周日。 |
40407~40415 |
0x0196~0x019E |
保留 | 0 | 保留。 |
规则评估读取日期时间时,应在本轮条件评估开始时取一次 RTC 快照;同一轮 ifs 中的年、月、日、时、分、秒必须来自同一快照,避免跨秒解析出混合时间。
5. RTU 帧格式和收发过程
5.1 帧格式
+------+------+-------+--------+--------+
| ADDR | FUNC | DATA | CRC_LO | CRC_HI |
| 1B | 1B | NB | 1B | 1B |
+------+------+-------+--------+--------+
- 寄存器地址、数量、值按大端放入
DATA。 - CRC16 使用 Modbus RTU CRC,帧尾低字节在前、高字节在后。
- 是否回答应按地址区区分,不按功能码区分:
- 读请求一律回应答(
0x01/0x02/0x03/0x04)。 - 长包区
40900~41155的写必须回应答(0x10,见第 8 节)。该区是配置与规则装载通道,宿主需要逐步确认,写完不回无法判断是否落到目标设备。 - 单个寄存器写(
0x06)不回应答,只作用于产品属性 / 配置区,写完即发下一帧。总线上多个节点并发写时,不回 ACK 可避免应答帧互相碰撞挤占时隙。 - 广播帧一律不回:
ADDR = 0x00的普通广播帧、0x42改号帧静默丢弃;0x41扫描请求是唯一例外,各设备按各自时隙主动回扫描回复。 ADDR在请求帧中是目标节点;在响应帧中是应答节点。扫描回复固定使用伪地址40(0x28)。- CRC 错误、功能码不支持、地址不符、地址越界时:单播读与长包区写返回异常帧;
0x06单个寄存器写不回应答,异常也静默丢弃。
按区应答一览:
| 区 | 读 | 写 | 说明 |
|---|---|---|---|
| Coil / Discrete / Input 区 | 回应答 | — | 读回数据帧。 |
产品属性区 40001~40100 |
回应答 | 0x06 不回 / 0x10 回 |
单个寄存器写不回,成组写回。 |
身份区 40300~40307 |
回应答 | 不回 | 只读区,写静默丢弃,见 5.3。 |
日期时间区 40400~40415 |
回应答 | 不回 | 只读区。 |
全局状态区 40500~40563 |
回应答 | 不回 | 广播写不回复;单播写按本区约定不回。 |
规则暂存区 40600~40727 |
回应答 | 回 | 规则装载过程需确认。 |
规则控制表 40800~40863 |
回应答 | 回 | RULE_CTRL / RULE_STATUS 需确认,见 9.4。 |
长包区 40900~41155 |
回应答 | 必须回 | 见第 8 节三段式。 |
5.2 收发过程
发送:
- 组帧并计算 CRC16。
- 侦听总线空闲至少 5 ms。
- 打开 485 发送方向,发送完整帧,等待 UART 发送完成。
- 关闭发送方向。
- 读请求、长包区写、规则区写等待响应;扫描请求等待扫描时隙;单个寄存器写(
0x06)与所有广播帧发完即走,不等 ACK。
接收:
- 连续接收字节;静默 ≥ 5 ms 判定一帧结束。
- 校验最小长度、CRC16、目标地址、功能码、地址范围、数量和字节数。
- 单播帧在 5~20 ms 内回复;广播帧不回复。
- 业务层用自身事务号去重。
单播重试建议 2 次,退避 100 ms、300 ms。事件广播最多重发 3 次或按周期重发。
5.3 设备身份区(只读)
本区共 8 个寄存器,全部只读,供扫描绑定与改号后确认使用。
| 逻辑地址 | PDU 偏移 | 名称 | 权限 | 说明 |
|---|---|---|---|---|
40300 |
0x012B |
HW_CODE_0 |
只读 | 硬件码第 0 字(最高位字,bit15~bit0)。 |
40301 |
0x012C |
HW_CODE_1 |
只读 | 硬件码第 1 字。 |
40302 |
0x012D |
HW_CODE_2 |
只读 | 硬件码第 2 字。 |
40303 |
0x012E |
HW_CODE_3 |
只读 | 硬件码第 3 字(最低位字)。4 个 16 位寄存器合成 64 位硬件码,字序 0x4321、0x8765、0x4321、0x8765。 |
40304 |
0x012F |
NODE_ID |
只读 | 485 通信 ID:低 8 bit = 本机当前站号;高 8 bit 预留,固定读 0。 |
40305~40307 |
0x0130~0x0132 |
预留 | 只读 | 保留,固定读 0。 |
要点:
- 硬件码(
40300~40303)只能读:工厂写入的 64 位唯一硬件标识,由 4 个 16 位寄存器(每个寄存器 16 bit)拼合,按字序HW_CODE_0为最高字、HW_CODE_3为最低字,每字内高字节在前。写操作无效(不影响内容),不返回异常也不改变数值。 - 485 通信 ID(
40304)低 8 bit 绑定本机 485 站号,即节点在总线上的身份地址;高 8 bit 预留,恒为 0。 - 本区整体只读:对
40300~40307使用写功能码(0x06/0x10)一律静默丢弃,不改变内容、不返回异常帧、不产生任何副作用。按 5.1 的按区应答约定,本区读要回应答、写不回应答;写不回应答意味着上位机无法从写操作本身得到“地址只读”的反馈,如需确认请用0x03读回校验。 - 改号只能走扩展功能码:本区不可写,站号的绑定与修改只能用扩展功能码
0x41(扫描发现)与0x42(修改站号),见第 10 节;不得用标准写寄存器的方式改站号。 - 改号成功后,扫描方用新站号读本区确认:低 8 bit 应等于新站号(见 10.5)。
- 原 v2 稿中本区为
40300~40315(16 字,含PRODUCT_CODE/SERIAL8 字节 /STATION_ADDR/PROTO_VER),现统一收敛为 8 字:硬件码 4 字 + 485 ID 1 字 + 预留 3 字;产品码与序列码改由0x41扫描回复帧携带(见 10.3)。
6. 基础报文示例
6.1 读 4 个线圈
读取节点 01 的 switch1~switch4:
01 01 00 00 00 04 3D C9
假设 4 个开关为 OFF/ON/ON/ON,响应:
01 01 01 0F 11 8C
6.2 批量写 4 个线圈
把节点 01 的 4 个开关全部写 ON:
01 0F 00 00 00 04 01 0F 7E 92
成功响应:
01 0F 00 00 00 04 54 08
6.3 读输入寄存器
读取节点 01 的 4 个传感器寄存器:
01 04 00 00 00 04 F1 C9
示例响应:
01 04 08 01 00 00 7D 00 00 00 34 88 1C
按示例解释:
| 寄存器 | 原始值 | scale | 结果 |
|---|---|---|---|
0x0000(逻辑 30001) |
0x0100 |
0.1 | 25.6 |
0x0001(逻辑 30002) |
0x007D |
0.1 | 12.5 |
0x0002(逻辑 30003) |
0x0000 |
0.1 | 0.0 |
0x0003(逻辑 30004) |
0x0034 |
0.1 | 5.2 |
6.4 写单个保持寄存器
向节点 01 的 0x0020 写 500:
01 06 00 20 01 F4 88 17
按 5.1 的按区应答约定,0x06 写单个寄存器不回应答,节点收帧校验通过即执行,不回任何帧。
7. 全局共享状态区
7.1 寄存器定义
| 地址 | 名称 | 说明 |
|---|---|---|
40416~40499 |
保留 | 保留 0。 |
40500~40563 |
GS00~GS63 |
64 个 16 位全局状态字;未分配的保持 0。 |
系统级只保留 GS00 一个字,其余由网关分配:
| 全局字 | 含义 |
|---|---|
GS00 |
系统标志,位域定义见下表,由一个系统控制节点独占。 |
GS01~GS63 |
业务共享状态,由网关按 1:1 分配;用不完就一直保持 0。 |
GS00 只用于系统状态广播和上位机观察,不参与规则条件计算,也不作为规则执行器的判断依据。
GS00 位域:
| bit | 名称 | 含义 |
|---|---|---|
| 0 | 允许联动 | 1=系统侧允许联动,0=系统侧禁止联动;仅作为状态展示。 |
| 1 | 急停 | 1=急停触发中,0=正常。 |
| 2 | 维护模式 | 1=进入维护,联动受限。 |
| 3 | 手动禁止 | 1=人工禁止联动启动。原始记录位。 |
| 4~15 | 保留 | 固定 0。 |
GS00 由单个系统控制节点独占,因此 bit0、bit1、bit2、bit3 都由它广播写。该节点可以先把急停、维护模式、手动禁止等条件汇总后写入这些位;但规则执行器不读取、不判断、不依赖 GS00。
7.2 读写规则
- 一个事件源只对应一个全局状态字。网关在绑定时分配,二者是 1:1 关系。
GS00固定归系统控制节点,不参与分配。 - 每个全局状态字只有一个权威源。只有权威源可以广播写这个地址,其它节点不得广播写。
- 权威源需要更新时,直接广播写自己那一个字,写即生效,不需要状态头或版本号。
- 其它节点收到广播写后直接更新本地镜像,不转发、不做版本比较、不做暂存。
- 接收端在本地记录每个全局状态字最后一次更新的时刻;超过设定周期未更新视为过期,依赖它的规则不触发。这一判定完全在本地完成,不占用总线。
- 规则条件只读本地全局状态镜像,不在判断过程中临时发起总线读;但
GS00不允许作为规则条件。 - 任一节点可通过单播
0x03读全局状态。
7.3 广播示例
节点 05 是 GS05 的权威源,广播写 GS05 = 1000:
00 06 01 F8 03 E8 08 A8
广播无 ACK。权威源在值变化时立即广播,并按周期重发当前值。
8. 长包缓存区(扩展)
长包只有一个缓存区:逻辑 40900~41155(PDU 0x0383~0x0482),共 256 个连续寄存器,也就是 512 字节。
本区采用三段式 + commit 流程:先用一次 0x10 声明「对象号 + 长度 + CRC16」,再用若干次 0x10 分包灌入数据字节,最后用一个 commit 控制字触发装载。三段全部使用标准 Modbus 功能码(0x03 / 0x10),不使用任何扩展功能码。
这样保证「数据没灌完不会生效」——写一半掉线不会留下半截配置。
应答约定:按 5.1,长包区 40900~41155 是必须回应答的写区,与「写单个寄存器 0x06 不回应答」和产品属性区不同。原因是长包写是配置与规则装载通道,需要宿主逐步确认;不回 ACK 会让上位机无法判断帧是否落到目标设备,也无法在分包续传时发现丢帧。因此本区每一帧 0x10 写都有应答(回显起始地址 + 寄存器数量),地址或参数非法时回异常帧(0x02 地址越界 / 0x03 长度非法 / 0x04 处理失败)。8.6 各例每一帧都配了应答。
注意区分两层含义:应答只证明「帧收到、格式合法」;是否装载成功看 commit 之后的 LP_STATE / LP_ERR。
8.1 寄存器布局
前 8 个寄存器是控制字,数据区从 40908 开始。
| 逻辑地址 | PDU 偏移 | 名称 | 权限 | 说明 |
|---|---|---|---|---|
40900 |
0x0383 |
LP_CTRL |
写 / 读 | 控制字,见 8.2。 |
40901 |
0x0384 |
LP_LEN |
读写 | 业务包有效字节数,16 位大端,1~496。 |
40902 |
0x0385 |
LP_CRC16 |
读写 | 业务包有效字节的 Modbus CRC16,16 位大端。 |
40903~40904 |
0x0386~0x0387 |
预留 | 只读 | 保留,固定 0。 |
40905 |
0x0388 |
LP_STATE |
只读 | 会话状态,见 8.3。 |
40906 |
0x0389 |
LP_ERR |
只读 | 错误码,见 8.3。 |
40907 |
0x038A |
— | — | 预留,读 0。 |
40908~41155 |
0x038B~0x0482 |
LP_DATA |
读写 | 496 字节数据区。业务包字节按高字节在前装入寄存器,尾部补 0x00。 |
要点:
LP_CTRL是唯一入口:三段流程全部通过写LP_CTRL(或其所在连续区间)驱动,不需要额外的 OPCODE / TXN_ID / 分片号 / READY / RESP 字段。LP_LEN/LP_CRC16是「齐备信号」:它们各占 1 个寄存器,在数据之前声明,设备收到 commit 时据此判断数据是否完整、是否被破坏。- 数据区净容量 496 字节:单条业务包上限 496 字节(
0x10单帧最多 123 个寄存器 = 246 字节,故最多 3 包)。 - 协议层只校验 Modbus 帧本身、地址范围和寄存器数量;业务包语义(是否合法 JSON、字段是否超限)由上层规则执行器判定,失败时通过
LP_ERR回告。
8.2 LP_CTRL 控制字
LP_CTRL 为 1 个寄存器:高 8 位是命令,低 8 位是对象号(规则号 1~16)。
命令常量 0x00 / 0x01 / 0x40 / 0x80 各占高字节,低字节为对象号,二者以
0x0100 | i(OPEN)、0x4000 | i(COMMIT)、0x8000 | i(LOAD)形式拼合。
例:COMMIT 规则 2 = 0x4002(字节 40 02);LOAD 规则 2 = 0x8002(字节 80 02)。
对象号必须 1~16,不得为 0;对 LP_CTRL 的写入均按本编码解释,读回原样返回。
| 值 | 名称 | 含义 |
|---|---|---|
0x0000 |
IDLE | 空闲 / 取消本次会话;清 LP_DATA,LP_STATE 归 0。 |
0x0100 \| i |
OPEN | 写入第 1 段:开写窗,选对象 i。设备清 LP_DATA,LP_STATE = 1。 |
0x4000 \| i |
COMMIT | 写入第 3 段:提交。设备校验 LP_LEN / LP_CRC16,通过则装载对象 i,LP_STATE = 2;失败 LP_STATE = 3 并写 LP_ERR。 |
0x8000 \| i |
LOAD | 请求把已存对象 i 装入 LP_DATA,LP_STATE = 1;用于读取流程第 1 段。 |
本节控制字只用公共功能码
0x03/0x10,不占用0x41~0x48与0x64~0x6E这两个 Modbus / GB/Z 19582.1 共同定义的用户自定义功能码区间;那两个区间只留给第 10 节的广播扩展使用。
8.3 LP_STATE / LP_ERR
LP_STATE |
名称 | 含义 |
|---|---|---|
0 |
IDLE | 空闲,无进行中的会话。 |
1 |
ARMED | 已开窗,正在接收数据(或正在处理读请求)。 |
2 |
OK | 最近一次 commit / load 成功。 |
3 |
ERR | 最近一次操作失败,见 LP_ERR。 |
LP_ERR |
名称 | 含义 |
|---|---|---|
0x00 |
无错 | — |
0x01 |
未开窗 / 对象越界 | 未收到 OPEN 就 commit,或对象号不在 1~16。 |
0x02 |
长度非法 | LP_LEN = 0 或 > 496。 |
0x03 |
CRC 不符 | LP_CRC16 与 LP_DATA 实算不符。 |
0x04 |
业务包非法 | JSON 语法错,或 op / ifs / ok / err 字段不合法。 |
0x05 |
字段超限 | ifs / ok / err 项数超过 8。 |
0x06 |
对象被占用 | 目标规则槽已 enable,须先 RULE_CTRL = 2 清除。 |
8.4 写入流程(三段式 · 多包)
App / 上位机 设备
|
|-- ① 0x10 写 40900~40902 -------->| OPEN|Ctrl + LEN + CRC16
| (一次写 3 个寄存器) | 清 LP_DATA,LP_STATE=1
|<-- 0x10 正常应答 -----------------|
|
|-- ② 0x10 写 40908+ 分包灌数据 --->| 第 1 包,123 个寄存器
|<-- 0x10 正常应答 -----------------|
|-- 0x10 写 40908+123 分包 -------->| 第 2 包
|<-- 0x10 正常应答 -----------------|
| ... 直到 LP_LEN 字节全部写完 ...
|
|-- ③ 0x10 写 40900 = COMMIT ------>| 校验 LEN + CRC16 + 业务格式
|<-- 0x10 正常应答 -----------------| 通过 → 装载,LP_STATE=2
失败 → LP_STATE=3 + LP_ERR
规则:
- ① 必须在所有数据包之前;重复发 ① 会清空
LP_DATA,因此重传必须整段重来,本流程不做断点续传。 - ② 的分包数 =
ceil(ceil(LEN/2) / 123)。每包 ≤ 123 个寄存器(246 字节),起始地址依次为0x038B、0x0406、0x0481。 - ③ 只写
LP_CTRL一个寄存器即可 commit,不需要额外的事务号或专用应答帧。 - commit 通过后,设备把
LP_CRC16同步到规则表40820+i RULE_CRC16(见 9.4),并置40810+i RULE_STATUSbit2installed=1。 - 规则启停与清除仍走
40800+i RULE_CTRL(0= disable,1= enable,2= clear)。两者分工不同:LP_CTRL管数据面,RULE_CTRL管运行面。 - 覆盖一个已 enable 的规则前,必须先写
40800+i RULE_CTRL=2,否则 commit 返回LP_ERR=0x06。
8.5 读取流程(三段式)
App / 上位机 设备
|
|-- ① 0x10 写 40900 = LOAD|i ----->| 把对象 i 装入 LP_DATA,LP_STATE=1
|<-- 0x10 正常应答 -----------------|
|
|-- ② 0x03 读 40900~40906 -------->| 一次读回 7 个寄存器元数据
|<-- CTRL / LEN / CRC16 / 预留 / 预留 / STATE / ERR
| 上位机据此知道该读多少字节、期望 CRC
|
|-- ③ 0x03 读 40908+ 分包 -------->| 第 1 包,125 个寄存器
|<-- 0x03 应答,250 字节 ------------|
| ... 直到读满 LP_LEN 字节 ...
|
上位机本地重算 CRC16,与 ② 读回的 LP_CRC16 比对
读取分包数 = ceil(ceil(LEN/2) / 125)(0x03 单帧最多 125 个寄存器 = 250 字节)。
8.6 收发示例
以下示例均以节点站号 01 为前提,CRC16 为 Modbus CRC16(多项式 0xA001,初值 0xFFFF),帧尾低字节在前。
例 1 写入短规则(数据 1 包)
规则 JSON 共 75 字节(0x004B),CRC16 = 0x995C:
{"op":"and","ifs":["r 30005 > #13.0"],"ok":["w 3 0 #1"],"err":["w 3 0 #0"]}
装入规则槽 1:
① OPEN|1 = 0x0101(高字节 0x01=OPEN,低字节 0x01=规则 1),LEN = 0x004B,CRC16 = 0x995C
请求 01 10 03 83 00 03 06 01 01 00 4B 99 5C 38 4D
应答 01 10 03 83 00 03 71 A4
② 第 1 包:40908 起 38 个寄存器 = 76 字节(尾部补 1 个 0x00)
请求 01 10 03 8B 00 26 4C 7B 22 6F 70 22 3A 22 61 6E 64 22 2C 22 69 66 73 22 3A 5B 22 72 20 33 30 30 30 35 20 3E 20 23 31 33 2E 30 22 5D 2C 22 6F 6B 22 3A 5B 22 77 20 33 20 30 20 23 31 22 5D 2C 22 65 72 72 22 3A 5B 22 77 20 33 20 30 20 23 30 22 5D 7D 00 D3 A7
应答 01 10 03 8B 00 26 31 BD
③ COMMIT|1 = 0x4001(高字节 0x40=COMMIT,低字节 0x01=规则 1)
请求 01 10 03 83 00 01 02 40 01 7A A3
应答 01 10 03 83 00 01 F0 65
例 2 写入长规则(数据 2 包)
规则 JSON 共 299 字节(0x012B),CRC16 = 0xFBDE,占 150 个寄存器,故数据需分 2 包:
{"op":"and","ifs":["r 40600 == #1","r 30005 > #13.0","r 40501 == #100","r 30004 < #5.2","r 30006 > #3.5","r 40502 != #0","r 40400 == #2026","r 30007 < #-70"],"ok":["w 3 0 #1","w 3 40017 r30004","w 3 0 #1","w 4 0 #1","w 4 0 #0","w 3 40018 #1"],"err":["w 3 0 #0","w 3 40017 #0","w 4 0 #0","w 4 0 #1"]}
装入规则槽 2:
① OPEN|2 = 0x0102(高字节 0x01=OPEN,低字节 0x02=规则 2),LEN = 0x012B,CRC16 = 0xFBDE
请求 01 10 03 83 00 03 06 01 02 01 2B FB DE D4 AE
应答 01 10 03 83 00 03 71 A4
② 第 1 包:40908 起 123 个寄存器 = 246 字节
请求 01 10 03 8B 00 7B F6 7B 22 6F 70 22 3A 22 61 6E 64 22 2C 22 69 66 73 22 3A 5B 22 72 20 34 30 36 30 30 20 3D 3D 20 23 31 22 2C 22 72 20 33 30 30 30 35 20 3E 20 23 31 33 2E 30 22 2C 22 72 20 34 30 35 30 31 20 3D 3D 20 23 31 30 30 22 2C 22 72 20 33 30 30 30 34 20 3C 20 23 35 2E 32 22 2C 22 72 20 33 30 30 30 36 20 3E 20 23 33 2E 35 22 2C 22 72 20 34 30 35 30 32 20 21 3D 20 23 30 22 2C 22 72 20 34 30 34 30 30 20 3D 3D 20 23 32 30 32 36 22 2C 22 72 20 33 30 30 30 37 20 3C 20 23 2D 37 30 22 5D 2C 22 6F 6B 22 3A 5B 22 77 20 33 20 30 20 23 31 22 2C 22 77 20 33 20 34 30 30 31 37 20 72 33 30 30 30 34 22 2C 22 77 20 33 20 30 20 23 31 22 2C 22 77 20 34 20 30 20 23 31 22 2C 22 77 20 34 20 30 20 23 30 22 2C 22 77 20 33 20 34 30 30 31 38 20 23 31 22 5D 2C 22 65 72 9F 11
应答 01 10 03 8B 00 7B F0 44
② 第 2 包:41031 起 27 个寄存器 = 54 字节(起始 PDU 0x0406)
请求 01 10 04 06 00 1B 36 72 22 3A 5B 22 77 20 33 20 30 20 23 30 22 2C 22 77 20 33 20 34 30 30 31 37 20 23 30 22 2C 22 77 20 34 20 30 20 23 30 22 2C 22 77 20 34 20 30 20 23 31 22 5D 7D 00 9C D2
应答 01 10 04 06 00 1B 61 33
③ COMMIT|2 = 0x4002(高字节 0x40=COMMIT,低字节 0x02=规则 2)
请求 01 10 03 83 00 01 02 40 02 3A A2
应答 01 10 03 83 00 01 F0 65
分包首地址连续:第 2 包起始 PDU = 0x038B + 123 = 0x0406(逻辑 41031),寄存器数 = 150 - 123 = 27。
例 3 读取规则(读回规则槽 2)
沿用例 2 已装载的规则,LEN = 0x012B、CRC16 = 0xFBDE:
① LOAD|2 = 0x8002(高字节 0x80=LOAD,低字节 0x02=规则 2)
请求 01 10 03 83 00 01 02 80 02 6A A2
应答 01 10 03 83 00 01 F0 65
② 读元数据 40900~40906,共 7 个寄存器
请求 01 03 03 83 00 07 F5 A4
应答 01 03 0E 80 02 01 2B FB DE 00 00 00 00 00 02 00 00 1E 73
│ │ │ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │ │ │ │ │ │ └── CRC16 = 0x731E → 1E 73
│ │ │ │ │ │ │ │ │ └────── 40906 LP_ERR = 0x0000
│ │ │ │ │ │ │ │ └────────────── 40905 LP_STATE = 0x0002(OK)
│ │ │ │ │ │ │ └────────────────────── 40904 预留 = 0x0000
│ │ │ │ │ │ └────────────────────────────── 40903 预留 = 0x0000
│ │ │ │ │ └────────────────────────────────────── 40902 LP_CRC16 = 0xFBDE
│ │ │ │ └────────────────────────────────────────────── 40901 LP_LEN = 0x012B = 299 字节
│ │ │ └────────────────────────────────────────────────────── 40900 LP_CTRL = 0x8002(LOAD|2 回显)
│ │ └── BYTE_COUNT = 0x0E = 14 字节(7 个寄存器)
│ └── FUNC = 0x03
└───── ADDR = 0x01
③ 第 1 包读:40908 起 125 个寄存器 = 250 字节
请求 01 03 03 8B 00 7D F5 85
应答 01 03 FA 7B 22 6F 70 22 3A 22 61 6E 64 22 2C 22 69 66 73 22 3A 5B 22 72 20 34 30 36 30 30 20 3D 3D 20 23 31 22 2C 22 72 20 33 30 30 30 35 20 3E 20 23 31 33 2E 30 22 2C 22 72 20 34 30 35 30 31 20 3D 3D 20 23 31 30 30 22 2C 22 72 20 33 30 30 30 34 20 3C 20 23 35 2E 32 22 2C 22 72 20 33 30 30 30 36 20 3E 20 23 33 2E 35 22 2C 22 72 20 34 30 35 30 32 20 21 3D 20 23 30 22 2C 22 72 20 34 30 34 30 30 20 3D 3D 20 23 32 30 32 36 22 2C 22 72 20 33 30 30 30 37 20 3C 20 23 2D 37 30 22 5D 2C 22 6F 6B 22 3A 5B 22 77 20 33 20 30 20 23 31 22 2C 22 77 20 33 20 34 30 30 31 37 20 72 33 30 30 30 34 22 2C 22 77 20 33 20 30 20 23 31 22 2C 22 77 20 34 20 30 20 23 31 22 2C 22 77 20 34 20 30 20 23 30 22 2C 22 77 20 33 20 34 30 30 31 38 20 23 31 22 5D 2C 22 65 72 72 22 3A 5B 72 96
③ 第 2 包读:41033 起 25 个寄存器 = 50 字节(起始 PDU 0x0408)
请求 01 03 04 08 00 19 04 F2
应答 01 03 32 22 77 20 33 20 30 20 23 30 22 2C 22 77 20 33 20 34 30 30 31 37 20 23 30 22 2C 22 77 20 34 20 30 20 23 30 22 2C 22 77 20 34 20 30 20 23 31 22 5D 7D 00 DF B5
分包首地址连续:第 2 包起始 PDU = 0x038B + 125 = 0x0408(逻辑 41033),寄存器数 = 150 - 125 = 25。
上位机收齐 LP_LEN = 299 字节后,剥掉尾部补的 0x00,本地重算 CRC16,与 ② 读回的 0xFBDE 比对一致即认为读回完整。
9. 条件表达与逻辑运算
9.1 规则表达式
智能化标准说法是程序,小脚本,可编程实现与、或、非运算。
因资源有限,这里用一段 JSON 字符串来表示类似汇编语句的、助记符与变量,实现最轻量的脚本化表达与解析并执行,读取状态,输出结果。
编辑时按Modbus的长包读写。JSON 只保留四个字段:
{
"op": "and",
"ifs": ["r 40600 == #1", "r 30005 > #13.0"],
"ok": ["w 3 0 #1"],
"err": ["w 3 0 #0"]
}
| 字段 | 说明 |
|---|---|
op |
and 或 or,只有一层。 |
ifs |
条件数组,最多 8 项。 |
ok |
条件成立动作数组,最多 8 项。 |
err |
条件不成立动作数组,最多 8 项。 |
# 立即数和 r 寄存器操作数都按物理值比较;固件按字段 scale 把物理值换算成原始值(或把寄存器原始值换算成物理值)后再比较。规则编辑 / 上位机 UI 直接填小数,更直观。下面再给几类常用规则。
立即数条件,满足溶氧低于 5.2 mg/L 时打开节点 03 的线圈(oxygen 30004,scale 0.1,即原始值 52):
{
"op": "and",
"ifs": ["r 30004 < #5.2"],
"ok": ["w 3 0 #1"],
"err": ["w 3 0 #0"]
}
寄存器对寄存器,比较电压和电流的物理值:
{
"op": "and",
"ifs": ["r 30005 > r30006"],
"ok": ["w 3 0 #1"],
"err": ["w 3 0 #0"]
}
日期时间条件,2026-05-08 的 08:00~17:59 之间允许执行:
{
"op": "and",
"ifs": [
"r 40400 == #2026",
"r 40401 == #5",
"r 40402 == #8",
"r 40403 >= #8",
"r 40403 < #18"
],
"ok": ["w 3 0 #1"],
"err": ["w 3 0 #0"]
}
星期条件,周一到周五允许执行:
{
"op": "and",
"ifs": ["r 40406 <= #5"],
"ok": ["w 3 0 #1"],
"err": ["w 3 0 #0"]
}
or 组合,溶氧低或电压高时动作:
{
"op": "or",
"ifs": [
"r 30004 < #5.2",
"r 30005 > #13.0"
],
"ok": ["w 3 0 #1"],
"err": ["w 3 0 #0"]
}
动作也可以把本机寄存器值复制到目标节点:
{
"op": "and",
"ifs": ["r 40600 == #1"],
"ok": ["w 3 40017 r30004"],
"err": ["w 3 40017 #0"]
}
9.2 统一表达式
规则表达式只有两种:
条件:r <addr> <cmp> <operand>
动作:w <node> <addr> <operand>
r / w 里的 <addr> 一律使用第 4 节地址表的逻辑地址:Coil 0~100、Discrete Input 10001 起、Input Register 30001 起、Holding Register 40001 起(含 40300 起的公共区),不使用相对 PDU 偏移。
条件只使用 r。不使用 ! 前缀;取反逻辑统一用比较符表达,例如“禁用”写成 r 40600 != #0。r 读本节点统一地址表:Coil / Discrete Input、Input Register 30001 起、产品属性 / 配置(Holding 40001 起)、规则暂存区、日期时间区和 GS01~GS63 全局状态镜像都由固件映射成统一地址;判断过程中不再发起总线读。GS00 不参与规则条件判断。
动作只使用 w。node 是目标站号:1~239 单播,0 广播;写本机时也直接填本机站号。固件根据统一地址表决定写 Coil、Holding Register 还是全局状态区。# / r 操作数同样按物理值,固件按目标字段 scale 换算成原始值再写。
比较符固定为 <、<=、>、>=、==、!=。
<operand> 只允许两种:
| 写法 | 含义 |
|---|---|
#5.2 |
立即数,物理值,可带小数(如 #5.2、#-10.5);固件按字段 scale 换算成原始值比较。 |
r30004 |
本机寄存器当前物理值(原始值 × scale)。 |
# 在 JSON 字符串里只是普通字符,不是 JSON 结构字符,不需要转义。
例如:
r 30004 < #5.2 溶氧 < 5.2 mg/L
r 30004 < r30005 和另一个本机寄存器(物理值)比较
w 3 0 #1 写立即数
w 3 40017 r30004 把本机溶氧物理值写到节点 3 的 40017
两类表达式都按 4 个空白分隔的 token 解析:
r 40600 != #0
1 2 3 4
w 3 0 #1
1 2 3 4
C 固件先识别第一个 token 是 r 还是 w,再分别解析剩余字段:
#include <ctype.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char kind; /* 'r' 或 'w' */
uint16_t node;
uint16_t addr;
char cmp[4]; /* < <= > >= == != */
uint8_t value_is_reg;
uint16_t src_addr;
double value; /* # 立即数:物理值,可带小数 */
} rule_expr_t;
/* 声明:读本机寄存器原始值,以及取某字段 scale(无 scale 字段按 1) */
extern unsigned short modbus_get_reg(unsigned short regadd);
extern double field_scale(unsigned short regadd);
static int at_end(const char *s)
{
while (*s != '\0' && isspace((unsigned char)*s)) {
s++;
}
return *s == '\0';
}
static int parse_u16(const char *s, uint16_t *out)
{
char *end;
unsigned long value;
if (s == NULL || *s == '\0') {
return -1;
}
value = strtoul(s, &end, 10);
if (end == s || *end != '\0' || value > 65535UL) {
return -1;
}
*out = (uint16_t)value;
return 0;
}
static int parse_double(const char *s, double *out)
{
char *end;
double value;
if (s == NULL || *s == '\0') {
return -1;
}
value = strtod(s, &end);
if (end == s || *end != '\0') {
return -1;
}
*out = value;
return 0;
}
static int parse_operand(const char *s, rule_expr_t *e)
{
if (s[0] == '#') {
e->value_is_reg = 0;
return parse_double(s + 1, &e->value);
}
if (s[0] == 'r' && isdigit((unsigned char)s[1])) {
e->value_is_reg = 1;
e->value = 0;
return parse_u16(s + 1, &e->src_addr);
}
return -1;
}
int rule_expr_parse(const char *s, rule_expr_t *e)
{
char kind[4], node[8], addr[16], cmp[4], operand[18];
int used = 0;
uint16_t u16;
if (s == NULL || e == NULL) {
return -1;
}
memset(e, 0, sizeof(*e));
if (sscanf(s, " %3s", kind) != 1) {
return -1;
}
if ((kind[0] != 'r' && kind[0] != 'w') || kind[1] != '\0') {
return -1;
}
if (kind[0] == 'r') {
if (sscanf(s + used, " %15s %3s %17s%n",
addr, cmp, operand, &used) != 3 ||
!at_end(s + used)) {
return -1;
}
if (strcmp(cmp, "<") && strcmp(cmp, "<=") &&
strcmp(cmp, ">") && strcmp(cmp, ">=") &&
strcmp(cmp, "==") && strcmp(cmp, "!=")) {
return -1;
}
e->node = 0;
} else {
if (sscanf(s + used, " %7s %15s %17s%n",
node, addr, operand, &used) != 3 ||
!at_end(s + used)) {
return -1;
}
cmp[0] = '\0';
if (parse_u16(node, &u16) != 0) {
return -1;
}
e->node = u16;
}
if (parse_u16(addr, &u16) != 0) {
return -1;
}
e->addr = u16;
if (parse_operand(operand, e) != 0) {
return -1;
}
e->kind = kind[0];
strcpy(e->cmp, cmp);
return 0;
}
int rule_condition_eval(const rule_expr_t *e, int *result)
{
double left, right;
if (e == NULL || result == NULL || e->kind != 'r') {
return -1;
}
/* 原始值 × scale = 物理值;无 scale 字段按 1 */
left = (double)modbus_get_reg(e->addr) * field_scale(e->addr);
right = e->value_is_reg ?
(double)modbus_get_reg(e->src_addr) * field_scale(e->src_addr) : e->value;
if (!strcmp(e->cmp, "<")) {
*result = left < right;
} else if (!strcmp(e->cmp, "<=")) {
*result = left <= right;
} else if (!strcmp(e->cmp, ">")) {
*result = left > right;
} else if (!strcmp(e->cmp, ">=")) {
*result = left >= right;
} else if (!strcmp(e->cmp, "==")) {
*result = left == right;
} else if (!strcmp(e->cmp, "!=")) {
*result = left != right;
} else {
return -1;
}
return 0;
}
例如:
r 40600 != #0 -> kind=r, addr=40600, cmp="!=", immediate=0
w 3 0 #1 -> kind=w, node=3, addr=0, immediate=1
w 3 40017 r30004 -> kind=w, node=3, addr=40017, src=30004
9.3 执行语义
- 节点周期评估规则,条件由假变真执行
ok;由真变假执行err。 - 上电初值按“条件为假”处理,避免上电误动作。
- 不内置延时、防抖和冷却;需要这些行为时,由规则编辑者用暂存寄存器或多条规则组合实现。
- 动作连续失败进入
fault,不无限重试;由RULE_CTRL清除。 - 依赖的全局状态字从未收到过广播、或按 7.2.5 判定为过期时,不触发。
9.4 规则寄存器表
规则号从 1 开始,最大 16。i 为规则号减 1。
| 地址 | 名称 | 说明 |
|---|---|---|
40800+i |
RULE_CTRL |
写控制:0=disable,1=enable,2=clear。 |
40810+i |
RULE_STATUS |
只读:bit0 active,bit1 fault,bit2 installed。 |
40820+i |
RULE_CRC16 |
只读:规则 JSON 的 CRC16。 |
控制值只能由网关或有配置权节点写入;产品节点不得自改规则。
9.5 长包读写
规则 JSON 的写入与读取走第 8 节定义的长包区三段式流程,这里只给出与规则表的对应关系:
| 步骤 | 动作 | 说明 |
|---|---|---|
| 写 ① | 0x10 写 40900~40902:LP_CTRL = 0x0100\|i、LP_LEN、LP_CRC16 |
开写窗并声明长度与校验 |
| 写 ② | 0x10 分包写 40908 起数据区 |
高字节在前,尾补 0x00;重传须整段重来 |
| 写 ③ | 0x10 写 40900 = 0x4000\|i |
commit,设备校验后装载规则槽 i |
| 读 ① | 0x10 写 40900 = 0x8000\|i |
请求把规则 i 装入数据区 |
| 读 ② | 0x03 读 40900~40906 |
取回 LP_LEN、LP_CRC16、LP_STATE、LP_ERR |
| 读 ③ | 0x03 分包读 40908 起数据区 |
收齐 LP_LEN 字节后本地重算 CRC16 比对 |
commit 通过后,设备同步 40820+i RULE_CRC16 = 本次 LP_CRC16,并置 40810+i RULE_STATUS bit2 installed = 1,上位机可据此对账。规则启停和清除仍使用 40800+i RULE_CTRL;LP_CTRL 只管数据装载,不管运行状态。
10. 扫描设备和修改站号
本节只用于真实 485 设备首次绑定和站号修改。8 字节唯一序列码由工厂写入,只在本流程使用;常规寄存器读写不需要携带序列码。
10.0 扩展功能码的使用
本节用的 0x41 与 0x42 属于 Modbus 的「用户自定义功能码」,
不是公共功能码。
| 十进制 | 十六进制 | 类别 |
|---|---|---|
| 65~72 | 0x41~0x48 |
用户自定义(本节 0x41 / 0x42 在此区间,共 8 个) |
| 100~110 | 0x64~0x6E |
用户自定义(另有 11 个) |
读取身份区有两个途径,地位不同,不要混为一谈:
| 途径 | 功能码 | 何时可用 | 说明 |
|---|---|---|---|
| 标准通用接口 | 0x03(读多个保持寄存器) |
主站知道站号后,任何时候 | 身份区本质就是 Holding 区 40300~40307,主站按标准 Modbus 读保持寄存器即可读到,不依赖本节任何扩展功能码。设备已绑定后,日常读取身份区走的就是这条路。 |
| 另加的广播措施 | 0x41 / 0x42 |
仅当设备还没有有效站号时 | 设备没有站号就无法被单播,主机也就无法用 0x03 指向它。这两步是为主动补上这个缺口而另加的扩展通道:0x41 广播发现、0x42 广播改号。改号一旦完成,后续所有读写(含读身份区)都回到标准 0x03。 |
因此 0x41/0x42 不替代也不改变标准 Modbus 读保持寄存器接口,只是把「无站号设备」接入总线的一次性手段。
本节三类报文的角色:
| 角色 | 功能码 | 目标地址 | 用途 | 应答 |
|---|---|---|---|---|
| 扩展:发现 | 0x41 |
0x00 广播 |
无站号设备的扫描发现 | 设备按 0x41 从伪地址 40(0x28) 回复 |
| 扩展:改号 | 0x42 |
0x00 广播 |
给发现的设备下发新站号 | 无(广播不回复) |
| 标准接口 | 0x03 |
站号单播 | 读保持寄存器(读身份区 40300~40307 即属此类) |
设备回 0x03 正常应答 |
10.1 发现模式(随时可用)
发现模式不做进入限制、不需要按键或本地命令,扫描方发出合法的 0x41 扫描请求帧即视为“报文对”,总线上所有设备立即应答。
规则:
- 随时可扫:设备任何时候都在监听
0x41;收到校验通过且MODE有效的扫描请求即进入本次发现流程,无需预先进入某个模式。 - 报文对即生效:
ADDR=0x00、FUNC=0x41、SLOT_COUNT/SLOT_MS非 0、MODE受支持、CRC 正确——五项全对即认为报文有效并立即按10.2 计算时隙应答;任一项不对则丢弃,不回复。 - 无 120 s 超时:发现流程随本次扫描请求开始、随本次扫描窗口结束,不需要退出发现模式。
MODE当前仅支持0x01(只扫描);其余值设备丢弃不回复,便于后续扩展。- 扫描方应把
NONCE设为随机数并每次重扫都更换,避免收到上一次扫描的迟到回复。
10.2 扫描请求(0x41)
APP封装好功能码是 0x41 报文下发网关,网关只提取并生成Hex后,
通过广播转发出去给485总线
| 偏移 | 长度 | 名称 | 说明 |
|---|---|---|---|
| 0 | 2 | NONCE |
扫描随机数,设备原样回填,用于把回复与本次请求配对、过滤旧回复。 |
| 2 | 1 | SLOT_COUNT |
应答时隙数,建议 16。 |
| 3 | 1 | SLOT_MS |
每个时隙时长(ms),建议 50(本版推荐值),取值 0x0A~0xFF。 |
| 4 | 1 | SCANNER_ADDR |
扫描发起方站号,0x01~0xEF。 |
| 5 | 1 | MODE |
0x01 = 只扫描(当前唯一支持值)。 |
时隙时间轴(以 SLOT_COUNT=16、SLOT_MS=50 为例,总窗口 16 × 50 = 800 ms):
t=0ms 扫描方广播 0x41 请求
|---- 时隙 0 ----|---- 时隙 1 ----| ... |---- 时隙 15 ----|
0 50 100 800
设备按 SLOT = CRC16(SERIAL) % SLOT_COUNT 选择自己的时隙,在 t = SLOT × SLOT_MS 时刻起、占用该时隙窗口发送回复。
示例报文:节点 01 发起扫描,16 个时隙,每时隙 50 ms,NONCE=0x1234:
00 41 12 34 10 32 01 01 C6 E6
│ │ └─┬─┘ │ │ │ │ └── CRC16 = 0xE6C6 → 低字节 C6 在前、高字节 E6 在后
│ │ │ │ │ │ └───── MODE = 0x01(只扫描)
│ │ │ │ │ └──────── SCANNER_ADDR = 0x01
│ │ │ │ └─────────── SLOT_MS = 0x32 = 50 ms
│ │ │ └────────────── SLOT_COUNT = 0x10 = 16
│ │ └─────────────────── NONCE = 0x1234
│ └──────────────────────── FUNC = 0x41(扩展:扫描请求)
└─────────────────────────── ADDR = 0x00(广播)
10 时隙版本(SLOT_COUNT=0x0A,总窗口 10 × 50 = 500 ms):
00 41 12 34 0A 32 01 01 C1 FE
10.3 扫描回复(0x41)
设备 → 扫描方, 假设地址是 40
设备从假设址 40(0x28)发送 0x41 回复,不使用普通单播应答流程,也不需要扫描方预先分配站号。
| 偏移 | 长度 | 名称 | 说明 |
|---|---|---|---|
| 0 | 1 | STATUS |
0x01 = 待绑定(首次上电未绑定);0x02 = 已配置,按请求显式上报。 |
| 1 | 2 | NONCE |
原样返回扫描请求的 NONCE。 |
| 3 | 2 | PRODUCT_CODE |
产品编码(如 rly4p3 / wq8in1 的编号,大端)。 |
| 5 | 8 | SERIAL |
8 字节唯一序列码,即改号与绑定用的设备身份。 |
示例:STATUS=0x01、NONCE=0x1234、PRODUCT_CODE=0x0001、序列码 43 21 87 65 43 21 87 65:
28 41 01 12 34 00 01 43 21 87 65 43 21 87 65 35 F4
│ │ │ └─┬─┘ └─┬─┘ └──────┬─────┘ └── CRC16 = 0xF435 → 低字节 35 在前
│ │ │ │ │ └────────────── SERIAL[6..7] = 43 21
│ │ │ │ └────────────────────────── SERIAL[4..5] = 87 65
│ │ │ └───────────────────────────────── SERIAL[2..3] = 87 65
│ │ └────────────────────────────────────── SERIAL[0..1] = 43 21(偏移 5,6)
│ └───────────────────────────────────────── PRODUCT_CODE = 0x0001(偏移 3,4)
└──────────────────────────────────────────── NONCE = 0x1234(偏移 1,2)
ADDR = 40(0x28) FUNC = 0x41 STATUS = 0x01(待绑定)
字节偏移对照(回复帧共 17 字节):
| 偏移 | 字节 | 字段 |
|---|---|---|
| 0 | 28 |
ADDR(伪地址 40 / 0x28) |
| 1 | 41 |
FUNC |
| 2 | 01 |
STATUS |
| 3~4 | 12 34 |
NONCE |
| 5~6 | 00 01 |
PRODUCT_CODE |
| 7~14 | 43 21 87 65 43 21 87 65 |
SERIAL(8 字节 = 64 位硬件码) |
| 15~16 | 35 F4 |
CRC16 |
回复帧总长 = 1(ADDR) + 1(FUNC) + 1(STATUS) + 2(NONCE) + 2(PRODUCT_CODE) + 8(SERIAL) + 2(CRC) = 17 字节。
ADDR为伪地址40(0x28,首字节);40不是可分配站号,只在扫描回复帧中使用。
扫描方判定与处理:
- 按
NONCE配对:只接受与本次请求NONCE相同的回复,否则丢弃。 - 按时隙窗口过滤:只在
t = SLOT × SLOT_MS对应的窗口内收帧,降低碰撞误收。 - 校验 CRC,失败丢弃。
- 记录
SERIAL(后续下发新站号用)、PRODUCT_CODE、STATUS,并按CRC16(SERIAL) % SLOT_COUNT反推该设备应占用的时隙。 - 若同一时隙仍碰撞(收到多于 1 帧或丢帧),扫描方更换
NONCE后重扫,或增大SLOT_MS。
10.4 修改为新站号(0x42)
APP封装好功能码是 0x42 报文下发网关,网关只提取并生成Hex后,
通过广播转发出去给485总线, 数据包携带 SERIAL 号。
目标设备按 SERIAL 匹配,不按地址匹配(此时它还没有可用站号)。
| 偏移 | 长度 | 名称 | 说明 |
|---|---|---|---|
| 0 | 1 | SCANNER_ADDR |
发起方站号,0x01~0xEF。 |
| 1 | 8 | SERIAL |
目标设备唯一序列码(来自 10.3 回复)。 |
| 9 | 1 | NEW_ADDR |
新站号,必须 0x01~0xEF。 |
| 10 | 2 | AUTH |
CRC16(SERIAL + NEW_ADDR),帧尾低字节在前。 |
AUTH 计算输入共 9 字节(8 字节 SERIAL + 1 字节 NEW_ADDR):
输入: 43 21 87 65 43 21 87 65 02
AUTH = 0xA147 → 低字节 47 在前、高字节 A1 在后
示例:把序列码 4321876543218765 的设备改成站号 02:
00 42 01 43 21 87 65 43 21 87 65 02 47 A1 A9 A4
│ │ │ └────────┬────────┘ │ └──┬──┘ └─┬─┘ └── CRC16 = 0xA4A9 → 低字节 A9 在前
│ │ │ │ │ │ └───── CRC16(8 字节数据 + 2 字节 AUTH)
│ │ │ │ │ └─────────── AUTH = 0xA147(低字节 47 在前)
│ │ │ │ └───────────────── NEW_ADDR = 0x02
│ │ │ └─────────────────────────── SERIAL(8 字节)
│ │ └────────────────────────────────────── SCANNER_ADDR = 0x01
│ └───────────────────────────────────────── FUNC = 0x42(扩展:修改站号)
└────────────────────────────────────────── ADDR = 0x00(广播)
规则:
- 校验
SERIAL是否与本机出厂序列码一致、NEW_ADDR是否在0x01~0xEF、AUTH是否匹配、CRC 是否正确;四项全对才接受。 - 接受后立即保存新站号到 NVS 并生效,无需等待任何后续帧。
- 该帧为广播,设备不回复;扫描方须按 10.5 读身份区确认。
SERIAL不匹配的设备不保存、不回复;NEW_ADDR与本机当前站号相同也视为成功(幂等)。- 修改失败或未确认时,扫描方必须重新扫描,不得直接占用该站号。
10.5 用标准 0x03 读设备身份区
身份区就是 Holding 区的 40300~40307,主站用标准 Modbus 读保持寄存器(0x03)即可读取,不需要任何扩展功能码。本节给出发改号后用它做校验的示例;日常读取身份区同样走这条路。
身份区布局见 5.3,共 8 个寄存器(40305~40307 预留,实际读前 5 个即可)。
| 逻辑地址 | PDU 偏移 | 名称 | 说明 |
|---|---|---|---|
40300~40303 |
0x012B~0x012E |
HW_CODE_0~3 |
64 位硬件码(4 个 16 位寄存器,只读),字序 0x4321、0x8765、0x4321、0x8765。 |
40304 |
0x012F |
NODE_ID |
低 8 bit = 本机当前站号;高 8 bit 预留,固定 0。 |
40305~40307 |
0x0130~0x0132 |
预留 | 保留,固定 0。 |
请求(读 40300~40304 共 5 个寄存器,PDU 偏移 0x012B、数量 0x0005):
02 03 01 2B 00 05 F4 0E
│ │ └─┬─┘ └──┬──┘ └── CRC16 = 0x0EF4 → 低字节 F4 在前
│ │ │ └──────── REG_COUNT = 0x0005(读 5 个寄存器)
│ │ └─────────────── START_ADDR(PDU)= 0x012B(逻辑 40300)
│ └────────────────────── FUNC = 0x03(读保持寄存器)
└───────────────────────── ADDR = 0x02(刚下发的新站号)
应答(0x03 正常应答,BYTE_COUNT = 0x0A,10 字节数据):
02 03 0A 43 21 87 65 43 21 87 65 00 02 30 E1
│ │ │ └──────────┬─────────┘ │ │ └─ CRC16 = 0xE130 → 低字节 30 在前
│ │ │ │ │ └──── 40304 NODE_ID = 0x0002(低8bit=0x02 ✓)
│ │ │ │ └─────── 40303 HW_CODE_3 = 0x8765
│ │ │ └──────────────────── 40302 HW_CODE_2 = 0x4321
│ │ └────────────────────────────────── 40300~40301 HW_CODE_0/1 = 0x4321 / 0x8765
│ └───────────────────────────────────── BYTE_COUNT = 0x0A = 10 字节(5 个寄存器)
└──────────────────────────────────────── ADDR = 0x02
数据段共 10 字节按寄存器顺序排列,每寄存器高字节在前:
| 顺序 | 寄存器 | 本例值 | 说明 |
|---|---|---|---|
| 1~2 | 40300 HW_CODE_0 |
43 21 |
硬件码最高字 |
| 3~4 | 40301 HW_CODE_1 |
87 65 |
— |
| 5~6 | 40302 HW_CODE_2 |
43 21 |
— |
| 7~8 | 40303 HW_CODE_3 |
87 65 |
硬件码最低字 |
| 9~10 | 40304 NODE_ID |
00 02 |
低 8 bit = 0x02,应等于新站号 |
确认判据:ADDR 为新站号、CRC 正确、NODE_ID 低 8 bit 等于 NEW_ADDR。全部通过即绑定完成,扫描方记录该 SERIAL → NEW_ADDR 映射并退出;否则重新走 10.2 扫描。
这一步用的是标准
0x03,任何主机在设备有站号后随时都能读,不依赖0x41/0x42。它在此处只是恰好用来校验改号结果。
10.6 示例-查找设备流程(三角色)
以「扫描发现设备 → 选择空闲站号 → 下发新站号 → 读回确认」的完整流程为例,串起三个角色。 报文内容与 10.2 / 10.4 / 10.5 完全一致,这里重点交代每个角色各负责什么、帧在两个层里怎么走。
10.6.1 三个角色
| # | 角色 | 实现 | 职责 | 所在链路层 |
|---|---|---|---|---|
| 1 | 扫描方(App) | tools_ui2pid/ui_idedit.py |
① 按功能码封包:生成 0x41 扫描 / 0x42 改号 / 0x03 读身份区的 Modbus RTU 帧(含 CRC16);② 再封装为透传 MQTT 报文 {"restopic":...,"hexstr":...} 发布到 mqtt485 主题;③ 接收并解析 gw4g 回包 |
上位机 / MQTT |
| 2 | 转发网关 | gw4g(真实硬件) |
只转发、不解析业务:取 mqtt485 请求里的 hexstr 原样发到 RS485 总线;总线应答帧在透传窗口内按 restopic 原样回包。细节见第 11 章 |
硬件网关 / MQTT ↔ 485 |
| 3 | 被扫描从站 | 真设备,或 tools_ui2pid/dev_xxxx.py 模拟(USB-485 接到 gw4g) |
无站号也能收 0x41 广播并按 10.2/10.3 在时隙内回扫描回复(伪地址 40);SERIAL 匹配则接受 0x42 改号并保存 NVS;改号后按新站号应答 0x03 读身份区 |
485 从站 |
角色 1 与角色 3 不直接见面:ui_idedit.py 在 MQTT 侧,dev_xxxx.py / 真设备在 485 总线侧,中间隔着 gw4g 真机做原样转发。
dev_xxxx.py为 485 Modbus 模拟设备端(命名见tools_ui2pid/ReadMe.md),通过 USB-485 适配器与 gw4g 真机相连,用于无真机时联调整条链路。
10.6.2 帧的两层封装与流向
ui_idedit.py 发出的每一帧要经过两层封装,切勿混为一谈:
第 1 层(功能码 / Modbus 业务帧): 0x41 扫描 / 0x42 改号 / 0x03 读身份区 → 原始字节 + CRC16
第 2 层(透传 MQTT 报文) : {"restopic":"<回包主题>","hexstr":"<第1层帧的hex>"}
发布到 <mqtt_subbase>/<dev_id>.gw4g/mqtt485
ui_idedit.py (扫描方) gw4g (真机, 只转发) 485 从站 (真设备 / dev_xxxx.py)
| | |
|-- ① 封 0x41 帧 + 封装 mqtt485 ------->| ② 取 hexstr → 原样发到 485 总线 ------->|
| {"restopic":T0,"hexstr":"00411234.."} | | 各设备按 CRC16(SERIAL)%16 选时隙
| |<-- ③ 0x41 回复(伪地址 40) -------------| t=SLOT*50ms 起发扫描回复
|<-- 收 T0 回包(可能多条) <-------------| 窗口内每帧按 T0 回一包 |
| 校验 CRC + NONCE 配对 | |
| 收集 (SERIAL, PRODUCT_CODE, STATUS) | |
| | |
|-- ④ 封 0x42 帧 + 封装 mqtt485 ------->| 取 hexstr → 广播发到 485 总线 -------->| 仅 SERIAL 匹配的设备接受
| {"restopic":T1,"hexstr":"00420143.."} | (无应答; 窗口超时回 timeout 包) | 立即保存新站号到 NVS, 不回复
|<-- {"success":false,"errmsg":"timeout no responce"} ← 预期, 视为广播已送达 |
| | |
|-- ⑤ 封 0x03 帧 + 封装 mqtt485 ------->| 取 hexstr → 单播发到 485 总线 -------->| 按新站号应答身份区
| {"restopic":T2,"hexstr":"0203012B.."} |<-- 0x03 应答(HW_CODE0~3 + NODE_ID) ---|
|<-- 收 T2 回包 -----------------------| 回包按 T2 发布 |
| NODE_ID 低8bit == 新站号 → 绑定成功 | |
| | |
└── ⑥ 记录 SERIAL→新站号 到本地站号表, 结束 |
ui_idedit.py 发布/回包示例(每步一条 mqtt485 请求,一问一答窗口,严格串行):
| 步骤 | 发布主题 | 报文(restopic + hexstr) |
gw4g 行为 | ui_idedit 收到的回包 |
|---|---|---|---|---|
| ① 扫描 | aqua/<dev_id>.gw4g/mqtt485 |
{"restopic":"aqua/idedit/scan","hexstr":"0041123410320101C6E6"} |
原样广播 0x41 |
每台从站一条 {"success":true,"hexstr":"28410112340001432187654321876535F4"} |
| ④ 改号 | 同上 | {"restopic":"aqua/idedit/change","hexstr":"00420143218765432187650247A1A9A4"} |
原样广播 0x42(无应答) |
窗口超时 {"success":false,"errmsg":"timeout no responce"}(预期) |
| ⑤ 确认 | 同上 | {"restopic":"aqua/idedit/confirm","hexstr":"0203012B0005F40E"} |
原样单播 0x03 读身份区 |
{"success":true,"hexstr":"02030A4321876543218765000230E1"} |
10.6.3 ui_idedit.py 端实现要点
- 封包(功能码层):三个封包函数各自追加 Modbus CRC16(多项式
0xA001,初值0xFFFF,帧尾低字节在前)。 pack_scan(nonce, slot_count, slot_ms, scanner_addr)→00 41 <nonce> <slot_count> <slot_ms> <scanner_addr> 01 <CRC>(MODE=01,仅扫描)pack_change(serial, new_addr, scanner_addr)→00 42 <scanner_addr> <serial(8)> <new_addr> <AUTH(2)> <CRC>(AUTH=CRC16(serial+new_addr))pack_read_id(addr)→<addr> 03 01 2B 00 05 <CRC>(读身份区40300~40304,见 10.5)- 透传封装(mqtt485 层):
{"restopic":R,"hexstr":frame.hex().upper()}发布到<mqtt_subbase>/<dev_id>.gw4g/mqtt485。restopic由 ui_idedit.py 自己定(每步可用不同主题便于对账),严格小写键。 - 每个功能码帧 = 一条 mqtt485 请求,一问一答窗口、严格串行、按回包节奏限速(队列深 4,见 11.7.1)。
- 临时身份:
SCANNER_ADDR用不与设备冲突的值(如0xF1),只写进请求帧用于标识发起方,设备不校验也不分配该地址。 - 随机
NONCE:每次扫描random.randint(1, 0xFFFF),避免收到上一次扫描的迟到回复。 - 时隙 50 ms 推荐:
SLOT_MS=0x32;总窗口SLOT_COUNT × SLOT_MS+ 应答余量 必须 < gw4g 透传窗口 3500 ms(11.6.3 / 11.7.1),否则尾部时隙的回复落在窗口外收不到。 - 伪地址
40(0x28):只接受首字节为40且FUNC=0x41的扫描回复;40不可分配为站号(见第 2 章),改号时NEW_ADDR必须避开40。 - 改号后必确认:
0x42是广播、无应答,ui_idedit.py 收到timeout no responce属预期、视为广播已送达;必须靠 ⑤0x03读回NODE_ID判定,未确认不得占用该站号。0x42不重试(广播可能已生效),确认失败重新扫描。 - 多设备逐台绑定:遍历扫描回复列表,对每个
SERIAL依次执行「选空闲站号 →0x42下发 →0x03确认」。 - 日志建议:打印每次扫描的
NONCE、收到的(SERIAL, PRODUCT_CODE, STATUS, SLOT)、每次下发的hexstr与对应restopic回包,便于装机排障。
10.6.4 gw4g 侧职责(详见第 11 章)
- 只转发,不解析
0x41/0x42/0x03的内容,不做 CRC 校验、不做地址过滤(透传通道本就不过问数据,见 11.1)。 mqtt485接收窗口从下发被受理起 3500 ms;窗口内收到的每一条完整 485 帧按当前restopic逐条回包(11.6.4)。0x41扫描回复可能多条,都会回;0x42无应答,窗口超时回timeout no responce错误包(预期)。- 透传期间让出 Modbus 周期读写(11.6.8),避免与自身周期读抢占总线。
10.6.5 从站侧职责(真设备 / dev_xxxx.py)
- 真设备:无站号也能收
0x41广播并按 10.2/10.3 在时隙内回扫描回复;0x42按SERIAL匹配后立即保存新站号到 NVS 并生效(10.4);0x03身份区见 5.3 / 10.5。 dev_xxxx.py模拟(tools_ui2pid/dev_rly4p3.py等,USB-485 接 gw4g 真机):按同一套0x41/0x42/0x03报文实现从站行为,用于无真机时联调「ui_idedit.py → gw4g → 从站」整条链路。- 从站侧始终在 485 总线层,与 ui_idedit.py 之间隔着一个 gw4g 真机,两边都不直接见对方串口/MQTT。
11. AppUI 透传直通 485 总线
订阅专有主题/mqtt485
转发:网关只管转发,不过问数据包内容,AppUI 与 485 上的从机互相定义收发协议与过程控制。
状态:已实现。本节是旁路调试/配置通道,与第 3~9 节的 Modbus 从站协议并存、互不影响。
用途:App 助手下发任意一帧 485 原始字节,网关原样转发到 RS485 总线,并把收到的应答帧回传给 App,用于装机调试、参数整定和故障定位。
-
11.1 设计边界
-
本通道不参与 Modbus 从站协议:不做 CRC 校验、不做地址过滤、不解析功能码,收到的字节一律原样透传。
- 本通道与物模型链路完全隔离:既不改写
cmd/up主题,也不占用data_485node节点表。 - 事务串行执行:只有一个透传工作任务在跑,同一时刻 485 总线上只有一个事务;请求先进队列(深度 4),队列满即丢弃并记日志,不重试。
-
设备不校验回包主题,按 App 指定的
restopic原样发布;restopic由 App 负责指向自己的收包主题。 -
11.2 主题
| 方向 | 主题 | 说明 |
|---|---|---|
| 订阅(App → 网关) | <mqtt_subbase>/<dev_id>.gw4g/mqtt485 |
透传请求入口。本机命名空间下的第三段为 mqtt485,与 cmd、up 同级。 |
| 发布(网关 → App) | 请求里的 restopic |
原样使用 App 指定的主题,不做前缀校验。 |
<mqtt_subbase>、<dev_id> 取自 app_data(NVS,默认 aqua)。示例:
aqua/AC27-6EE9-A964.gw4g/mqtt485。
11.3 请求报文
{"restopic":"vartopi-specified-by-app","hexstr":"010300100004C40B"}
| 字段 | 必填 | 说明 |
|---|---|---|
restopic |
是 | 回包主题,由 App 指定。vartopi-specified-by-app 只是示例,表示 variable topic specified by App,不是固定协议名。键严格使用小写 restopic,不做大小写或别名兼容;restopic 缺失时无处回包,只记串口日志。 |
hexstr |
是 | 485 原始帧的 hex 串。规范格式为连续、无分隔符的 ASCII hex;例如 010300100004C40B 表示 8 个原始字节。 |
hexstr 解析规则:
- 规范报文不写空格和分隔符。为兼容历史助手报文,解析时空格、
,、:、-、\t、\r、\n会被忽略。 - 大小写均可(
AABB与aabb等价)。 - 必须为偶数个 hex 字符,否则回
{"success":false,"errmsg":"hexstr length not even"}。 - 出现非 hex 字符,回
{"success":false,"errmsg":"hexstr invalid char"}。 - 解码后长度 ≤ 256 字节,否则回
{"success":false,"errmsg":"hexstr too long"}。 hexstr为空,回{"success":false,"errmsg":"hexstr empty"}。-
有
restopic但缺少hexstr,回{"success":false,"errmsg":"missing hexstr"}。 -
11.4 回包报文
应答(收到数据):
{"success":true,"hexstr":"010400080100007D0000271093F7"}
超时或无有效数据:
{"success":false,"errmsg":"timeout no responce"}
错误回包汇总:
| 场景 | 回包 |
|---|---|
| 485 应答超时 | {"success":false,"errmsg":"timeout no responce"} |
restopic 存在但缺少 hexstr |
{"success":false,"errmsg":"missing hexstr"} |
hexstr 为空 |
{"success":false,"errmsg":"hexstr empty"} |
hexstr 含非 hex 字符 |
{"success":false,"errmsg":"hexstr invalid char"} |
hexstr 长度不是 2 的倍数 |
{"success":false,"errmsg":"hexstr length not even"} |
hexstr 解码后超过 256 字节 |
{"success":false,"errmsg":"hexstr too long"} |
restopic 缺失时无处回包,只记串口日志。成功回包 hexstr 统一输出大写、连续、无分隔符。
11.5 两层 hex 不要混淆(重要)
本通道报文里存在两层互不相干的 hex,极易改错:
| 层 | 出现位置 | 编码对象 | 谁负责 |
|---|---|---|---|
| 模块层(AT 传输层) | +MQTTURC: "publish",...,"<topic>",<len>,<len>,7B2274... 以及 AT+MQTTPUB=...,"7B22..." |
整份 JSON 文本 | 4G 模块,因 AT+MQTTCFG="encoding",0,1,1(发送 Hex / 接收显示 Hex)自动完成,App 与固件代码都不用管 |
| 应用层(本通道) | JSON 内部的 "hexstr":"01040008..." 字段 |
485 原始帧字节 | 本节固件代码:at4g_485topi_hexstr2byte() / at4g_485topi_byte2hexstr() |
对照示例。App 下行明文 JSON(67 字节):
{"restopic":"vartopi-specified-by-app","hexstr":"010300100004C40B"}
模块层 URC(67,67 是 JSON 字节数;hex 串长 134,是它的 2 倍):
+MQTTURC: "publish",0,7,"aqua/AC27-6EE9-A964.gw4g/mqtt485",67,67,7B22524553544F504943223A22...
其中 7B 22 72 65 73 74 6F 70 69 63 22 3A 22 = {"restopic":",与 485 无关。
固件内的解码顺序(两级各解一次,不可合并):
| 方向 | 步骤 | 函数 |
|---|---|---|
| 下行 | URC 尾部 hex → JSON 文本 | at4g_on_publish() 内联解码(模块层,原有逻辑) |
| 下行 | JSON 的 hexstr 字段 → 485 字节 |
at4g_485topi_hexstr2byte()(应用层) |
| 上行 | 485 字节 → JSON 的 hexstr 字段 |
at4g_485topi_byte2hexstr()(应用层) |
| 上行 | JSON 文本 → AT 命令里的 hex | at4g_pub_json_to()(模块层,原有逻辑) |
由此推出三条硬约束:
AT+MQTTPUB的<msg_len>是 JSON 原文字节数,不是 hex 字符数,也不是hexstr的长度。- App 看到的
hexstr只有一层 hex;模块层的 hex 由模块自己吃掉,不会外泄到 App。 -
千万不要把
hexstr的内容当 JSON 解析——它是 485 字节的 hex,不是 JSON。 -
11.6 时序与超时
App 网关(4G) RS485 从站
|--- mqtt485 请求 -->| |
| |--- uart0_485_send -->|
| | | (处理并应答)
| |<-- 应答帧 ----------|
| | (帧结束: 总线静默 ≥ 5 ms)
|<-- restopic 回包 -| |
- 发送前先
uart_flush_input()清掉总线上其它节点遗留的字节,避免上一次事务的残帧被当成本次应答。 - 帧结束判定:UART 接收超时(
uart_set_rx_timeout(5),9600 8N1 下约 5.2 ms 空闲)触发timeout_flag,与第 5.2 节“静默 ≥ 5 ms 判定一帧结束”一致。 - App/助手侧总等待与网关内部 485 接收窗口都按
3500 ms(AT4G_485_TIMEOUT_MS);窗口从下发那一刻起算,其中已预留 MQTT 收发链路耗时。 - 窗口不是“一收一发”结束:窗口期(
AT4G_485_TIMEOUT_MS,3500 ms)内收到的每一条完整 485 帧,都按当前下发指定的restopic逐条回给 App。超时后才关闭窗口。多从机配置时可能返回多条应答。 - 窗口内又收到一条有效
mqtt485下发时,立即追加下发到 485 总线,并把接收窗口重置为完整AT4G_485_TIMEOUT_MS;后续回包按最新一次下发指定的restopic回给 App。 - 等待期间由独立工作任务完成,不阻塞 UART1 事件任务——该任务按
\n组 AT 行(URC 分帧依赖它),阻塞会导致 AT 应答错帧。 - 透传事务在途时,UART0 的周期测试发送任务跳过本轮发送,避免测试帧抢占总线被误判为应答。
-
透传期间 Modbus 周期读让路:从
mqtt485下发被受理(入队)那一刻起,到接收窗口关闭为止,uart0_485_pt_is_busy()一律报占线(含请求还在队列里排队的空档,由uart0_485_pt_reserve()占位),map_modbus_pid的写与周期读都让出总线。另:本机任意一次总线占用(写转发 / 周期读 / 透传)之后,周期读还须连续静默MAP_MODBUS_READ_QUIET_TICKS拍(10 拍 = 6S)才再发一帧。 -
11.7 已知限制
-
同一时刻仅 1 个 485 接收窗口占用 485 总线;请求队列深 4,第 5 个及以后被丢弃(记
485 pt busy, drop日志)。App 侧应串行下发,或按回包节奏限速。 - 回包主题不做白名单校验;若需要收紧,可在
restopic解析处增加mqtt_pubbase前缀与通配符校验。 - 单次请求/应答各 ≤ 256 字节;超长 Modbus 帧不支持。
- 只在 4G 链路(
uart1_at4g)实现;wifi_mqtt链路启用后需复用同一段名与报文格式。 - 事务在途期间到达的周期属性上报、联动广播会被推迟到事务结束后执行。
12. 总线收发仲裁
| 参数 | 默认值 | 说明 |
|---|---|---|
| 波特率 | 9600 8N1 | 与 rs485_baud 默认一致。 |
| 帧间隔 | ≥ 5 ms | 帧结束判断。 |
| 周期广播时隙 | 20 ms | 按 (addr485 - 1) % 32 参与周期上报。 |
| 事件退避 | 5 + (addr485 * 3) + 随机 0~10 ms |
侦听空闲后等待。 |
| 应答窗口 | 5~20 ms | 需要应答的帧(读、长包区写、规则区写)发出后的响应延迟。0x06 单个寄存器写与广播帧不适用,发完即走。 |
- 网关可发起普通属性读写。
- 联动动作节点可在事件触发时发起目标写,但发送前必须侦听空闲并退避。
- 非事件源节点不主动广播全局状态,也不转发状态。
- 广播帧失败无法确认;广播只传当前完整值,靠权威源周期重发保证最终一致。
13. 常见异常码
单播异常格式:
ADDR | FUNC | EXCEPTION_CODE | CRC
FUNC = 请求功能码 | 0x80。示例,节点 01 返回 Illegal Function:
01 83 01 80 F0
| 值 | 名称 | 约定 |
|---|---|---|
0x01 |
Illegal Function | 功能码未实现。 |
0x02 |
Illegal Data Address | 地址越界。 |
0x03 |
Illegal Data Value | 数值、数量、长度非法。 |
0x04 |
Device Failure | 内部处理失败。 |
0x06 |
Device Busy | 长包或动作未完成。 |
0x08 |
Check Error | payload CRC 错误。 |
0x10 |
Gate Target Unavailable | 目标影子设备未注册。 |
广播帧、扫描请求、修改站号帧、以及 0x06 单个寄存器写,均不返回异常(按 5.1 的按区应答约定,它们本来就不回应答)。异常帧只出现在读请求和长包区 / 规则区写这两类必须回应答的帧上。
14. 网关职责
- 扫描新设备、分配站号、绑定
pid + pid_index。 - 配置设备相关的规则表达式分配规则号。
- 通过长包下发规则 JSON。
- 周期读取设备物模型属性并转换成云端 JSON。
- 接收云端命令并翻译为 485 帧发给真设备。
- 其它寄存器不用在网关备份。
- 周期同步时间给设备。
- 维护设备在线状态;离线只更新
online,不删除影子。
15. 实现收发顺序
uart0_485完成完整帧接收、CRC 校验和 5 ms 帧间隔。- 实现 Modbus RTU
0x01/02/03/04/05/06/0F/10,写路径按统一地址表分派 Coil / Holding;统一地址表覆盖物模型区(Coil0~100、Holding40001~40100)与公共区(Holding40300起)。应答按 5.1 的按区约定实现:读、长包区写、规则区写必须回应答;0x06单个寄存器写与广播帧不回应答。 - 实现全局状态区
40500~40563(PDU0x01F3~0x0232):权威源按 1:1 归属广播写单字,接收端更新本地镜像并记录更新时间。 - 实现日期时间区
40400~40415(PDU0x018F~0x019E)的只读 RTC 映射;规则评估时取一次时间快照。 - 实现长包缓存区
40900~41155(PDU0x0383~0x0482)的普通 Modbus 读写,并按第 8 节实现三段式 + commit 装载:LP_CTRL四态(OPEN/COMMIT/LOAD/IDLE)、LP_LEN+LP_CRC16齐备校验、LP_STATE/LP_ERR状态回告;数据区净容量 496 字节,写入最多 3 包(0x10每包 123 寄存器)、读取最多 2 包(0x03每包 125 寄存器)。 - 实现规则寄存器表
40800~40863(RULE_CTRL三态)和扁平规则 JSON 执行器:周期评估、边沿比对、r条件、w动作,以及#立即数和r寄存器操作数;#/r按物理值比较,固件按字段scale换算(物理值 = 原始值 × scale,无 scale 字段按 1);规则暂存区40600~40727作为动态条件源,地址校验必须拒绝把GS00写入规则条件。 - 实现扩展功能码
0x41/0x42(另加的广播通道,只解决「设备尚无有效站号、无法被单播」的问题):0x41扫描发现、0x42广播改号。改号后主站用标准0x03读保持寄存器读身份区40300~40307校验(NODE_ID在40304,实际只需读40300~40304共 5 个字)。身份区本身对0x03是普通可读地址,标准读接口不受0x41/0x42影响。 - 扩展
data_485node的序列码绑定表、最近在线时间和超时计数。 - 增加测试:单读、单写、批量写、广播状态(
40500~40563)、长包三段式写入与读取(40900~41155,含LP_STATE/LP_ERR错误码各分支、LEN/CRC16不匹配的拒绝、commit 前不生效、覆盖已 enable 规则返回LP_ERR=0x06)、扫描碰撞、修改站号后确认、规则边沿触发、立即数条件、寄存器对寄存器条件、暂存寄存器动态条件(40600~40727)、日期时间条件(40400~40415)。
16. 已知限制
- 定标寄存器按 int16 补码解释;32 位数值必须占连续两个寄存器。
- 属性写不做回读校验:
0x06单个寄存器写按 5.1 本来就不回应答,因此收到合法帧即视为成功,继电器实际动作失败不在协议层体现;需要确认时改用会回应答的成组写0x10或回读属性。 - 产品属性无写仲裁:云端与联动谁后写谁赢;属性归属由网关在安装规则时保证,不下发到总线。
- 规则只做边沿触发,不自动再断言:条件持续成立期间若目标被外部改动,规则不会补发。
- 长包缓存建议只由网关写入;节点之间不直接抢写。
- 长包数据区净容量 496 字节(
40908~41155),单条规则 JSON 上限 496 字节;按第 8 节规格,ifs/ok/err各填满 8 项时约 486 字节,已接近上限,UI 侧必须做长度校验。 - 三段式不支持断点续传:第 1 段
OPEN会清空数据区,任何失败都必须从第 1 段整段重来,靠LP_CRC16兜底而非分片去重。 0x41/0x42是用户自定义功能码(0x41~0x48、0x64~0x6E),只在本生态内可用;不支持的从站会回0x01或丢弃,只支持公共功能码的组态工具无法配置,详见 10.0。