485-Modbus 通信与联动(v2简化版)

状态:设计稿 v2。当前 uart0_485 仍只有半双工测试收发,本文约定用于后续固件实现。
目标:在没有固定 Modbus Master 的 RS485 总线上,完成属性读写、全局状态广播、规则安装、联动执行和新设备修改站号。

1. 设计原则

  1. RS485 半双工,任意时刻只允许一个发送端;所有节点必须做 CRC 校验、地址过滤和帧间隔判断。
  2. 报文层尽量使用 Modbus RTU:功能码 0x01/02/03/04/05/06/0F/10。
  3. 少量扩展功能码只用于新设备扫描和修改站号:0x41/0x42。
  4. 简单属性、全局状态、规则启停和状态用寄存器;规则 JSON、批量配置和长文本统一走长包缓存。
  5. 广播帧不确认。事件源按周期重发当前值;接收端以最后写入的值为准,不需要版本去重。
  6. 新设备扫描回复带 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     |
+------+------+-------+--------+--------+

按区应答一览:

区 读 写 说明
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 收发过程

发送:

  1. 组帧并计算 CRC16。
  2. 侦听总线空闲至少 5 ms。
  3. 打开 485 发送方向,发送完整帧,等待 UART 发送完成。
  4. 关闭发送方向。
  5. 读请求、长包区写、规则区写等待响应;扫描请求等待扫描时隙;单个寄存器写(0x06)与所有广播帧发完即走,不等 ACK。

接收:

  1. 连续接收字节;静默 ≥ 5 ms 判定一帧结束。
  2. 校验最小长度、CRC16、目标地址、功能码、地址范围、数量和字节数。
  3. 单播帧在 5~20 ms 内回复;广播帧不回复。
  4. 业务层用自身事务号去重。

单播重试建议 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。

要点:

  1. 硬件码(40300~40303)只能读:工厂写入的 64 位唯一硬件标识,由 4 个 16 位寄存器(每个寄存器 16 bit)拼合,按字序 HW_CODE_0 为最高字、HW_CODE_3 为最低字,每字内高字节在前。写操作无效(不影响内容),不返回异常也不改变数值。
  2. 485 通信 ID(40304)低 8 bit 绑定本机 485 站号,即节点在总线上的身份地址;高 8 bit 预留,恒为 0。
  3. 本区整体只读:对 40300~40307 使用写功能码(0x06 / 0x10)一律静默丢弃,不改变内容、不返回异常帧、不产生任何副作用。按 5.1 的按区应答约定,本区读要回应答、写不回应答;写不回应答意味着上位机无法从写操作本身得到“地址只读”的反馈,如需确认请用 0x03 读回校验。
  4. 改号只能走扩展功能码:本区不可写,站号的绑定与修改只能用扩展功能码 0x41(扫描发现)与 0x42(修改站号),见第 10 节;不得用标准写寄存器的方式改站号。
  5. 改号成功后,扫描方用新站号读本区确认:低 8 bit 应等于新站号(见 10.5)。
  6. 原 v2 稿中本区为 40300~40315(16 字,含 PRODUCT_CODE / SERIAL 8 字节 / 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:1 关系。GS00 固定归系统控制节点,不参与分配。
  2. 每个全局状态字只有一个权威源。只有权威源可以广播写这个地址,其它节点不得广播写。
  3. 权威源需要更新时,直接广播写自己那一个字,写即生效,不需要状态头或版本号。
  4. 其它节点收到广播写后直接更新本地镜像,不转发、不做版本比较、不做暂存。
  5. 接收端在本地记录每个全局状态字最后一次更新的时刻;超过设定周期未更新视为过期,依赖它的规则不触发。这一判定完全在本地完成,不占用总线。
  6. 规则条件只读本地全局状态镜像,不在判断过程中临时发起总线读;但 GS00 不允许作为规则条件。
  7. 任一节点可通过单播 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。

要点:

  1. LP_CTRL 是唯一入口:三段流程全部通过写 LP_CTRL(或其所在连续区间)驱动,不需要额外的 OPCODE / TXN_ID / 分片号 / READY / RESP 字段。
  2. LP_LEN / LP_CRC16 是「齐备信号」:它们各占 1 个寄存器,在数据之前声明,设备收到 commit 时据此判断数据是否完整、是否被破坏。
  3. 数据区净容量 496 字节:单条业务包上限 496 字节(0x10 单帧最多 123 个寄存器 = 246 字节,故最多 3 包)。
  4. 协议层只校验 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

规则:

  1. ① 必须在所有数据包之前;重复发 ① 会清空 LP_DATA,因此重传必须整段重来,本流程不做断点续传。
  2. ② 的分包数 = ceil(ceil(LEN/2) / 123)。每包 ≤ 123 个寄存器(246 字节),起始地址依次为 0x038B、0x0406、0x0481。
  3. ③ 只写 LP_CTRL 一个寄存器即可 commit,不需要额外的事务号或专用应答帧。
  4. commit 通过后,设备把 LP_CRC16 同步到规则表 40820+i RULE_CRC16(见 9.4),并置 40810+i RULE_STATUS bit2 installed = 1。
  5. 规则启停与清除仍走 40800+i RULE_CTRL(0 = disable,1 = enable,2 = clear)。两者分工不同:LP_CTRL 管数据面,RULE_CTRL 管运行面。
  6. 覆盖一个已 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 执行语义

  1. 节点周期评估规则,条件由假变真执行 ok;由真变假执行 err。
  2. 上电初值按“条件为假”处理,避免上电误动作。
  3. 不内置延时、防抖和冷却;需要这些行为时,由规则编辑者用暂存寄存器或多条规则组合实现。
  4. 动作连续失败进入 fault,不无限重试;由 RULE_CTRL 清除。
  5. 依赖的全局状态字从未收到过广播、或按 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 扫描请求帧即视为“报文对”,总线上所有设备立即应答。

规则:

  1. 随时可扫:设备任何时候都在监听 0x41;收到校验通过且 MODE 有效的扫描请求即进入本次发现流程,无需预先进入某个模式。
  2. 报文对即生效:ADDR=0x00、FUNC=0x41、SLOT_COUNT/SLOT_MS 非 0、MODE 受支持、CRC 正确——五项全对即认为报文有效并立即按10.2 计算时隙应答;任一项不对则丢弃,不回复。
  3. 无 120 s 超时:发现流程随本次扫描请求开始、随本次扫描窗口结束,不需要退出发现模式。
  4. MODE 当前仅支持 0x01(只扫描);其余值设备丢弃不回复,便于后续扩展。
  5. 扫描方应把 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 不是可分配站号,只在扫描回复帧中使用。

扫描方判定与处理:

  1. 按 NONCE 配对:只接受与本次请求 NONCE 相同的回复,否则丢弃。
  2. 按时隙窗口过滤:只在 t = SLOT × SLOT_MS 对应的窗口内收帧,降低碰撞误收。
  3. 校验 CRC,失败丢弃。
  4. 记录 SERIAL(后续下发新站号用)、PRODUCT_CODE、STATUS,并按 CRC16(SERIAL) % SLOT_COUNT 反推该设备应占用的时隙。
  5. 若同一时隙仍碰撞(收到多于 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(广播)

规则:

  1. 校验 SERIAL 是否与本机出厂序列码一致、NEW_ADDR 是否在 0x01~0xEF、AUTH 是否匹配、CRC 是否正确;四项全对才接受。
  2. 接受后立即保存新站号到 NVS 并生效,无需等待任何后续帧。
  3. 该帧为广播,设备不回复;扫描方须按 10.5 读身份区确认。
  4. SERIAL 不匹配的设备不保存、不回复;NEW_ADDR 与本机当前站号相同也视为成功(幂等)。
  5. 修改失败或未确认时,扫描方必须重新扫描,不得直接占用该站号。

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 端实现要点

  1. 封包(功能码层):三个封包函数各自追加 Modbus CRC16(多项式 0xA001,初值 0xFFFF,帧尾低字节在前)。
  2. pack_scan(nonce, slot_count, slot_ms, scanner_addr) → 00 41 <nonce> <slot_count> <slot_ms> <scanner_addr> 01 <CRC>(MODE=01,仅扫描)
  3. pack_change(serial, new_addr, scanner_addr) → 00 42 <scanner_addr> <serial(8)> <new_addr> <AUTH(2)> <CRC>(AUTH=CRC16(serial+new_addr))
  4. pack_read_id(addr) → <addr> 03 01 2B 00 05 <CRC>(读身份区 40300~40304,见 10.5)
  5. 透传封装(mqtt485 层):{"restopic":R,"hexstr":frame.hex().upper()} 发布到 <mqtt_subbase>/<dev_id>.gw4g/mqtt485。restopic 由 ui_idedit.py 自己定(每步可用不同主题便于对账),严格小写键。
  6. 每个功能码帧 = 一条 mqtt485 请求,一问一答窗口、严格串行、按回包节奏限速(队列深 4,见 11.7.1)。
  7. 临时身份:SCANNER_ADDR 用不与设备冲突的值(如 0xF1),只写进请求帧用于标识发起方,设备不校验也不分配该地址。
  8. 随机 NONCE:每次扫描 random.randint(1, 0xFFFF),避免收到上一次扫描的迟到回复。
  9. 时隙 50 ms 推荐:SLOT_MS=0x32;总窗口 SLOT_COUNT × SLOT_MS + 应答余量 必须 < gw4g 透传窗口 3500 ms(11.6.3 / 11.7.1),否则尾部时隙的回复落在窗口外收不到。
  10. 伪地址 40(0x28):只接受首字节为 40 且 FUNC=0x41 的扫描回复;40 不可分配为站号(见第 2 章),改号时 NEW_ADDR 必须避开 40。
  11. 改号后必确认:0x42 是广播、无应答,ui_idedit.py 收到 timeout no responce 属预期、视为广播已送达;必须靠 ⑤0x03 读回 NODE_ID 判定,未确认不得占用该站号。0x42 不重试(广播可能已生效),确认失败重新扫描。
  12. 多设备逐台绑定:遍历扫描回复列表,对每个 SERIAL 依次执行「选空闲站号 → 0x42 下发 → 0x03 确认」。
  13. 日志建议:打印每次扫描的 NONCE、收到的 (SERIAL, PRODUCT_CODE, STATUS, SLOT)、每次下发的 hexstr 与对应 restopic 回包,便于装机排障。

10.6.4 gw4g 侧职责(详见第 11 章)

  1. 只转发,不解析 0x41 / 0x42 / 0x03 的内容,不做 CRC 校验、不做地址过滤(透传通道本就不过问数据,见 11.1)。
  2. mqtt485 接收窗口从下发被受理起 3500 ms;窗口内收到的每一条完整 485 帧按当前 restopic 逐条回包(11.6.4)。
  3. 0x41 扫描回复可能多条,都会回;0x42 无应答,窗口超时回 timeout no responce 错误包(预期)。
  4. 透传期间让出 Modbus 周期读写(11.6.8),避免与自身周期读抢占总线。

10.6.5 从站侧职责(真设备 / dev_xxxx.py)

  1. 真设备:无站号也能收 0x41 广播并按 10.2/10.3 在时隙内回扫描回复;0x42 按 SERIAL 匹配后立即保存新站号到 NVS 并生效(10.4);0x03 身份区见 5.3 / 10.5。
  2. dev_xxxx.py 模拟(tools_ui2pid/dev_rly4p3.py 等,USB-485 接 gw4g 真机):按同一套 0x41 / 0x42 / 0x03 报文实现从站行为,用于无真机时联调「ui_idedit.py → gw4g → 从站」整条链路。
  3. 从站侧始终在 485 总线层,与 ui_idedit.py 之间隔着一个 gw4g 真机,两边都不直接见对方串口/MQTT。

11. AppUI 透传直通 485 总线

订阅专有主题/mqtt485

转发:网关只管转发,不过问数据包内容,AppUI 与 485 上的从机互相定义收发协议与过程控制。
状态:已实现。本节是旁路调试/配置通道,与第 3~9 节的 Modbus 从站协议并存、互不影响。
用途:App 助手下发任意一帧 485 原始字节,网关原样转发到 RS485 总线,并把收到的应答帧回传给 App,用于装机调试、参数整定和故障定位。

方向 主题 说明
订阅(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 解析规则:

  1. 规范报文不写空格和分隔符。为兼容历史助手报文,解析时空格、,、:、-、\t、\r、\n 会被忽略。
  2. 大小写均可(AABB 与 aabb 等价)。
  3. 必须为偶数个 hex 字符,否则回 {"success":false,"errmsg":"hexstr length not even"}。
  4. 出现非 hex 字符,回 {"success":false,"errmsg":"hexstr invalid char"}。
  5. 解码后长度 ≤ 256 字节,否则回 {"success":false,"errmsg":"hexstr too long"}。
  6. hexstr 为空,回 {"success":false,"errmsg":"hexstr empty"}。
  7. 有 restopic 但缺少 hexstr,回 {"success":false,"errmsg":"missing hexstr"}。

  8. 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()(模块层,原有逻辑)

由此推出三条硬约束:

  1. AT+MQTTPUB 的 <msg_len> 是 JSON 原文字节数,不是 hex 字符数,也不是 hexstr 的长度。
  2. App 看到的 hexstr 只有一层 hex;模块层的 hex 由模块自己吃掉,不会外泄到 App。
  3. 千万不要把 hexstr 的内容当 JSON 解析——它是 485 字节的 hex,不是 JSON。

  4. 11.6 时序与超时

App                网关(4G)              RS485 从站
 |--- mqtt485 请求 -->|                     |
 |                  |--- uart0_485_send -->|
 |                  |                     |  (处理并应答)
 |                  |<-- 应答帧 ----------|
 |                  |  (帧结束: 总线静默 ≥ 5 ms)
 |<-- restopic 回包 -|                     |
  1. 发送前先 uart_flush_input() 清掉总线上其它节点遗留的字节,避免上一次事务的残帧被当成本次应答。
  2. 帧结束判定:UART 接收超时(uart_set_rx_timeout(5),9600 8N1 下约 5.2 ms 空闲)触发 timeout_flag,与第 5.2 节“静默 ≥ 5 ms 判定一帧结束”一致。
  3. App/助手侧总等待与网关内部 485 接收窗口都按 3500 ms(AT4G_485_TIMEOUT_MS);窗口从下发那一刻起算,其中已预留 MQTT 收发链路耗时。
  4. 窗口不是“一收一发”结束:窗口期(AT4G_485_TIMEOUT_MS,3500 ms)内收到的每一条完整 485 帧,都按当前下发指定的 restopic 逐条回给 App。超时后才关闭窗口。多从机配置时可能返回多条应答。
  5. 窗口内又收到一条有效 mqtt485 下发时,立即追加下发到 485 总线,并把接收窗口重置为完整 AT4G_485_TIMEOUT_MS;后续回包按最新一次下发指定的 restopic 回给 App。
  6. 等待期间由独立工作任务完成,不阻塞 UART1 事件任务——该任务按 \n 组 AT 行(URC 分帧依赖它),阻塞会导致 AT 应答错帧。
  7. 透传事务在途时,UART0 的周期测试发送任务跳过本轮发送,避免测试帧抢占总线被误判为应答。
  8. 透传期间 Modbus 周期读让路:从 mqtt485 下发被受理(入队)那一刻起,到接收窗口关闭为止,uart0_485_pt_is_busy() 一律报占线(含请求还在队列里排队的空档,由 uart0_485_pt_reserve() 占位),map_modbus_pid 的写与周期读都让出总线。另:本机任意一次总线占用(写转发 / 周期读 / 透传)之后,周期读还须连续静默 MAP_MODBUS_READ_QUIET_TICKS 拍(10 拍 = 6S)才再发一帧。

  9. 11.7 已知限制

  10. 同一时刻仅 1 个 485 接收窗口占用 485 总线;请求队列深 4,第 5 个及以后被丢弃(记 485 pt busy, drop 日志)。App 侧应串行下发,或按回包节奏限速。

  11. 回包主题不做白名单校验;若需要收紧,可在 restopic 解析处增加 mqtt_pubbase 前缀与通配符校验。
  12. 单次请求/应答各 ≤ 256 字节;超长 Modbus 帧不支持。
  13. 只在 4G 链路(uart1_at4g)实现;wifi_mqtt 链路启用后需复用同一段名与报文格式。
  14. 事务在途期间到达的周期属性上报、联动广播会被推迟到事务结束后执行。

12. 总线收发仲裁

参数 默认值 说明
波特率 9600 8N1 与 rs485_baud 默认一致。
帧间隔 ≥ 5 ms 帧结束判断。
周期广播时隙 20 ms 按 (addr485 - 1) % 32 参与周期上报。
事件退避 5 + (addr485 * 3) + 随机 0~10 ms 侦听空闲后等待。
应答窗口 5~20 ms 需要应答的帧(读、长包区写、规则区写)发出后的响应延迟。0x06 单个寄存器写与广播帧不适用,发完即走。
  1. 网关可发起普通属性读写。
  2. 联动动作节点可在事件触发时发起目标写,但发送前必须侦听空闲并退避。
  3. 非事件源节点不主动广播全局状态,也不转发状态。
  4. 广播帧失败无法确认;广播只传当前完整值,靠权威源周期重发保证最终一致。

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. 网关职责

  1. 扫描新设备、分配站号、绑定 pid + pid_index。
  2. 配置设备相关的规则表达式分配规则号。
  3. 通过长包下发规则 JSON。
  4. 周期读取设备物模型属性并转换成云端 JSON。
  5. 接收云端命令并翻译为 485 帧发给真设备。
  6. 其它寄存器不用在网关备份。
  7. 周期同步时间给设备。
  8. 维护设备在线状态;离线只更新 online,不删除影子。

15. 实现收发顺序

  1. uart0_485 完成完整帧接收、CRC 校验和 5 ms 帧间隔。
  2. 实现 Modbus RTU 0x01/02/03/04/05/06/0F/10,写路径按统一地址表分派 Coil / Holding;统一地址表覆盖物模型区(Coil 0~100、Holding 40001~40100)与公共区(Holding 40300 起)。应答按 5.1 的按区约定实现:读、长包区写、规则区写必须回应答;0x06 单个寄存器写与广播帧不回应答。
  3. 实现全局状态区 40500~40563(PDU 0x01F3~0x0232):权威源按 1:1 归属广播写单字,接收端更新本地镜像并记录更新时间。
  4. 实现日期时间区 40400~40415(PDU 0x018F~0x019E)的只读 RTC 映射;规则评估时取一次时间快照。
  5. 实现长包缓存区 40900~41155(PDU 0x0383~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 寄存器)。
  6. 实现规则寄存器表 40800~40863(RULE_CTRL 三态)和扁平规则 JSON 执行器:周期评估、边沿比对、r 条件、w 动作,以及 #立即数 和 r 寄存器操作数;# / r 按物理值比较,固件按字段 scale 换算(物理值 = 原始值 × scale,无 scale 字段按 1);规则暂存区 40600~40727 作为动态条件源,地址校验必须拒绝把 GS00 写入规则条件。
  7. 实现扩展功能码 0x41/0x42(另加的广播通道,只解决「设备尚无有效站号、无法被单播」的问题):0x41 扫描发现、0x42 广播改号。改号后主站用标准 0x03 读保持寄存器读身份区 40300~40307 校验(NODE_ID 在 40304,实际只需读 40300~40304 共 5 个字)。身份区本身对 0x03 是普通可读地址,标准读接口不受 0x41/0x42 影响。
  8. 扩展 data_485node 的序列码绑定表、最近在线时间和超时计数。
  9. 增加测试:单读、单写、批量写、广播状态(40500~40563)、长包三段式写入与读取(40900~41155,含 LP_STATE / LP_ERR 错误码各分支、LEN / CRC16 不匹配的拒绝、commit 前不生效、覆盖已 enable 规则返回 LP_ERR=0x06)、扫描碰撞、修改站号后确认、规则边沿触发、立即数条件、寄存器对寄存器条件、暂存寄存器动态条件(40600~40727)、日期时间条件(40400~40415)。

16. 已知限制

  1. 定标寄存器按 int16 补码解释;32 位数值必须占连续两个寄存器。
  2. 属性写不做回读校验:0x06 单个寄存器写按 5.1 本来就不回应答,因此收到合法帧即视为成功,继电器实际动作失败不在协议层体现;需要确认时改用会回应答的成组写 0x10 或回读属性。
  3. 产品属性无写仲裁:云端与联动谁后写谁赢;属性归属由网关在安装规则时保证,不下发到总线。
  4. 规则只做边沿触发,不自动再断言:条件持续成立期间若目标被外部改动,规则不会补发。
  5. 长包缓存建议只由网关写入;节点之间不直接抢写。
  6. 长包数据区净容量 496 字节(40908~41155),单条规则 JSON 上限 496 字节;按第 8 节规格,ifs / ok / err 各填满 8 项时约 486 字节,已接近上限,UI 侧必须做长度校验。
  7. 三段式不支持断点续传:第 1 段 OPEN 会清空数据区,任何失败都必须从第 1 段整段重来,靠 LP_CRC16 兜底而非分片去重。
  8. 0x41 / 0x42 是用户自定义功能码(0x41~0x48、0x64~0x6E),只在本生态内可用;不支持的从站会回 0x01 或丢弃,只支持公共功能码的组态工具无法配置,详见 10.0。