说明:面向工控与嵌入式现场:上位机/网关只会发二进制帧、不便改造为 HTTP 时的声光语音告警接入方案。报文以 Modbus TCP 帧格式逐字节拆解,Python 代码可直接联调。寄存器地址、端口、站号以设备当前固件页面为准,不放站外链接。
一、痛点:告警器要换,上位机一行代码都不能动
产线和老旧机房里有一类很现实的改造需求:
- 原来的声光报警器/语音通知盒是某家老产品,停产了、坏了、或者只会蜂鸣不会说话,要换新;
- 上位机是十年前写的工控程序、或某套封闭的组态/SCADA、或一块改不动的嵌入式网关------它与旧报警器之间的通信是裸 TCP 二进制帧,没有 HTTP 客户端,也没有改造预算;
- 需求是:上位机的报文结构尽量不动,只换对端,新的告警终端要能"听懂"原来那套字节。
HTTP API 那条路(见专栏对接篇)在 IT 机房里很好用,但工控现场经常走不通:老系统没有 HTTP 栈、没有 TLS、甚至没有可编程的回调出口,唯一保留的接口就是一个 TCP 端口往对端怼字节。
博灵 Q 系列为这类场景保留了原生 TCP 入口。它的协议本质一句话就能说清:借用 Modbus TCP 的二进制帧格式------上位机按功能码写寄存器,写对的瞬间终端执行对应播报。所以本文虽然叫"原生 TCP",帧却是标准的 Modbus TCP 帧,任何会发 Modbus 报文的设备(PLC、组态、网关、自研上位机)都能零成本接入。
二、协议本质:MBAP 头 + 功能码 + 数据,一个字都不神秘
一条原生 TCP 播报,就是一帧标准的 Modbus TCP 请求:
text
[MBAP 头 7 字节] [帧体]
事务标识符(2) 协议标识符(2) 长度(2) 单元标识符(1) 功能码(1) 起始地址(2) 寄存器数量/值
0x0001 0x0000 0x0006 0x01 0x06 0x0063 0x0001
- 事务标识符:随意递增即可,响应用来配对请求;
- 协议标识符 :Modbus TCP 固定
0x0000; - 长度:其后所有字节的字节数;
- 单元标识符:站号,与终端页面配置一致(常见为 1);
- 功能码 :
0x06写单寄存器(模板调用)、0x10写多寄存器(自定义播报); - 寄存器地址 :注意偏移------本文统一用界面地址 (1 起算)描述,直接组帧时写成 0 起算的十六进制(界面地址 100 =
0x63)。
终端侧是 Server(从站),监听端口默认 502,可在页面修改。上位机每次播报就是建一条 TCP 连接(或复用长连接)写一帧,播报请求即触发。
对接前必须在终端页面完成三件事:
- 打开 Modbus TCP 服务开关;
- 确认端口 与站号;
- 在通知模板页配好要用的模板列表(方式一要用,方式二可不用)。
三、两条路径:模板调用 vs 自定义播报
| 方式一:模板调用 | 方式二:自定义播报 | |
|---|---|---|
| 功能码 | 0x06 写单寄存器 |
0x10 写多寄存器 |
| 帧长度 | 固定 12 字节,最好组 | 可变,需编码文本 |
| 播报内容 | 页面预设的模板(灯效+TTS) | 每帧携带灯色、时长、语速、任意文本 |
| 上位机改动量 | 最小,值都写死 | 需实现文本编码 |
| 适用 | 告警类型有限、语句固定 | 告警内容动态(温度值、位置号) |
改造老系统的推荐顺序:先用方式一把"能响"打通,再决定是否上方式二。多数现场其实只要十几种固定告警语义,模板方式足够,还顺便把播报文案的编辑权留在了终端页面,以后改词不用动上位机。
四、方式一:模板调用,12 字节打通
4.1 逐字节拆帧
调用界面地址 100 的 1 号模板(0 起算即 0x63),完整帧 12 字节:
text
0x00 0x01 0x00 0x00 0x00 0x06 0x01 0x06 0x00 0x63 0x00 0x01
─事务── ─协议── ─长度── ─站号─ ─写单寄存器 ─地址─ ─模板ID─
- 寄存器值 = 模板 ID ,取值
0x01 ~ 0xFF,与终端模板页的编号对应; - 正常响应是请求帧原样回显,收到即认为触发成功(播报进入队列异步执行)。
4.2 Python:裸 socket 发帧
不依赖任何第三方库,工控机上往往只有标准库可用:
python
import socket
def modbus_tcp_frame(unit: int, function: int, address: int, values: list[int]) -> bytes:
"""组一帧 Modbus TCP:0x06 写单寄存器 / 0x10 写多寄存器"""
if function == 0x06:
pdu = bytes([function]) + address.to_bytes(2, "big") + values[0].to_bytes(2, "big")
elif function == 0x10:
payload = b"".join(v.to_bytes(2, "big") for v in values)
pdu = (
bytes([function])
+ address.to_bytes(2, "big")
+ len(values).to_bytes(2, "big")
+ bytes([len(payload)])
+ payload
)
else:
raise ValueError("仅支持 0x06 / 0x10")
mbap = (1).to_bytes(2, "big") + b"\x00\x00" + (len(pdu) + 1).to_bytes(2, "big")
return mbap + bytes([unit]) + pdu
def fire_template(host: str, template_id: int, port: int = 502, unit: int = 1) -> bytes:
"""模板调用:界面地址 100(0 起算 0x63)写模板 ID,返回响应帧"""
frame = modbus_tcp_frame(unit, 0x06, 0x63, [template_id])
with socket.create_connection((host, port), timeout=3) as sock:
sock.sendall(frame)
return sock.recv(256)
if __name__ == "__main__":
resp = fire_template("192.168.0.66", template_id=1)
print(" ".join(f"{b:02X}" for b in resp)) # 期望与请求帧一致
跑通后你会看到终端执行 1 号模板的灯效与 TTS。把 template_id 换成报警/恢复两个模板号,上位机原来"点亮/熄灭报警器"的两条分支就有了新去处。
4.3 用 pymodbus 的等价写法
如果机器上允许装库,直接用现成协议栈更省心(专栏 Modbus 对接篇同款思路):
python
from pymodbus.client import ModbusTcpClient
def fire_template_pymodbus(host: str, template_id: int) -> None:
client = ModbusTcpClient(host=host, port=502, timeout=3)
if not client.connect():
raise RuntimeError("Modbus TCP 连接失败")
try:
rr = client.write_register(address=100, value=template_id, slave=1)
if rr.isError():
raise RuntimeError(rr)
finally:
client.close()
注意 pymodbus 的 address 参数就是 0 起算的偏移:写 100 等价于裸帧里的 0x63 + 1,两个世界别混。
五、方式二:自定义播报,灯色和句子随帧携带
5.1 寄存器布局
从界面地址 100 起连续写多寄存器(功能码 0x10,组帧时 0 起算地址为 0x64):
| 界面地址 | 含义 | 取值说明 |
|---|---|---|
| 100 | LED 样式 | 如 10=闪烁、15=呼吸,与页面样式表一致 |
| 101 | LED 颜色 | RGB565:红 #FF0000 → 0xF800 |
| 102 | 播放时长 | 单位 0.1 秒:600 → 60 秒 |
| 103 | 声音类型 | 0 蜂鸣 / 1 报警 / 2 提示(以页面为准) |
| 104 | 语速 | 1~10,5 为默认 |
| 105 | 文本长度 | 按寄存器对计数 |
| 106~ | 文本内容 | UTF-8 编码,每 3 字节拆成 2 个寄存器,余量补 0x0000 |
文本编码是最容易写错的一步:一个汉字 UTF-8 占 3 字节,装入寄存器时按"高 8 位 + 低 16 位"拆成两个 16 位寄存器;文本长度字段按寄存器对(即每 3 字节)计数,不是字节数。
5.2 颜色换算:RGB888 → RGB565
终端寄存器吃 RGB565。换算只需三行:
python
def rgb888_to_rgb565(r: int, g: int, b: int) -> int:
"""#FF0000 -> 0xF800;损失低 3/2/3 位,肉眼几乎无感"""
return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
把通知组的色卡(critical 红 / warning 黄 / recovered 绿)在这里各算一个常量,帧里直接引用,上位机就不用管颜色语义了。
5.3 Python:文本编码 + 组帧 + 发送
python
def encode_tts_registers(text: str) -> list[int]:
"""UTF-8 文本 -> 寄存器序列:每 3 字节拆成 [高8位, 低16位] 两个寄存器"""
data = text.encode("utf-8")
regs: list[int] = []
for i in range(0, len(data), 3):
chunk = data[i : i + 3]
if len(chunk) < 3: # 尾部不足 3 字节(纯 ASCII 场景)补零
chunk = chunk.ljust(3, b"\x00")
regs.append(chunk[0])
regs.append((chunk[1] << 8) | chunk[2])
return regs
def fire_custom(host: str, text: str, color: int = 0xF800, duration: int = 600,
style: int = 15, sound: int = 1, speed: int = 5,
port: int = 502, unit: int = 1) -> bytes:
"""自定义播报:界面地址 100 起写 LED 样式/颜色/时长/声音/语速/文本"""
regs = encode_tts_registers(text)
head = [style, color, duration, sound, speed, len(regs) // 2]
frame = modbus_tcp_frame(unit, 0x10, 0x64, head + regs)
with socket.create_connection((host, port), timeout=3) as sock:
sock.sendall(frame)
return sock.recv(256)
if __name__ == "__main__":
resp = fire_custom("192.168.0.66", "三号产线二号炉温度超限",
color=rgb888_to_rgb565(0xFF, 0x00, 0x00))
print(" ".join(f"{b:02X}" for b in resp))
这段代码跑通,意味着上位机在任何时刻、以任意文案触发播报------温度值、机台号、故障码都能拼进句子,这是模板方式做不到的。
5.4 抓包核对:Wireshark 是最终裁判
组帧对不对,别靠猜。在联调机上抓包比对:
bash
# 抓 502 端口的 Modbus TCP 流量(tcpdump)
tcpdump -i eth0 -w bolin.pcap host 192.168.0.66 and port 502
Wireshark 直接把 502 端口流量按 Modbus/TCP 解码,Write Single Register / Write Multiple Registers 的地址、数量、值一目了然。官方文档给出的示例帧都可以先手动灌进去比对,再谈扩展。
六、旧设备替换四步法:从抓包到割接
真正替换老报警器时,推荐按这个顺序推进:
- 抓包取证:在旧链路挂 tcpdump/Wireshark,把旧上位机发给老报警器的帧全部录下来,确认功能码、地址、值的语义表(哪条是报警、哪条是恢复);
- 帧映射:把旧帧翻译成终端的帧。多数旧设备也是 Modbus 从站或类 Modbus 私有帧,通常只需改"地址 + 值"两个字段;如果旧帧完全私有,就在中间垫一个几十行的 Python/Node 转发进程;
- 模板对齐:按语义表在终端模板页建模板------旧系统的"报警"对应模板 A(红灯+急报),"恢复"对应模板 B(绿灯+恢复通知),上位机侧只改两个值;
- 割接与回退:先并接(新旧报警器同时在线跑一周),确认无误报漏报后摘除旧设备;保留旧帧的转发脚本作为回退预案。
这套打法的关键是语义表驱动:改造的不是字节,而是"旧语义 → 新语义"的映射关系,映射清楚了,割接只是改配置。
七、常见坑
- 地址差一 :文档示例帧
0x63是 0 起算(即 99),对应界面地址 100;pymodbus 的address=100也是 0 起算偏移。三处口径混用是最常见的事故来源,割接前用抓包核对一次; - 端口/站号不匹配:终端改过端口后上位机还在连 502,表现为 TCP 能连上但无响应或直接拒绝,先对页面再对帧;
- 文本长度字段按字节算:它按寄存器对计数。一句 12 个汉字 = 36 字节 = 24 个寄存器 = 长度填 12,填 24 或 36 都会播错;
- RGB888 直接塞寄存器:颜色会错乱,必须先转 RGB565;
- 长连接不处理超时:TCP 半开连接(网线被拔)后 sendall 不报错、播报全丢。用短连接或加应用层心跳,失败重连;
- 粘包/拆包:Modbus TCP 有 MBAP 长度字段,接收侧应按"先读 7 字节头、再按长度读体"的方式收帧,不要假定一次 recv 收完整帧;
- 拿响应当播报完成:响应回显只代表"帧收下了",播报在队列里异步执行;要确认播完,看设备的播报日志或首页告警列表;
- 无限循环无人终止:自定义播报的时长是有限的,但模板若配了循环播报,务必让恢复语义写删除/恢复模板,否则深夜喇叭长鸣。
八、验收清单
- 模板方式:报警模板、恢复模板各触发一次,灯效、TTS、通知组语义正确;
- 自定义方式:任意中文句子(含数字混排)播报清晰,RGB565 颜色与设计一致;
- 响应帧与请求帧逐字节比对通过,Wireshark 解码无异常;
- 拔网线/断电重连后,上位机自动恢复发送,无半开连接积压;
- 旧上位机语义表(报警/恢复/测试)与新模板一一对应,留有映射文档;
- 连续触发 50 次无队列堵塞,恢复模板可终止循环播报;
- 并接观察期内无误报、漏报,割接与回退步骤已写入运维文档。
九、小结
原生 TCP 入口解决的是一个很"工控"的问题:告警终端换代,不该逼着上位机换代。
- 帧格式就是标准 Modbus TCP:
0x06写地址 100 调模板,0x10从地址 100 写入灯色、时长、语速与 UTF-8 文本; - 改造方法论是"抓包取证 → 帧映射 → 模板对齐 → 并接割接",全程不动上位机核心逻辑;
- 三个易错点提前锁定:地址偏移口径、文本长度按寄存器对、RGB888 转 RGB565。
至此,博灵 Q 系列的接入矩阵又补齐一块:HTTP API 面向 IT、Modbus 面向 PLC 组态、SNMP 面向网管、邮件面向安防遗留系统、云中继面向跨网段------原生 TCP 面向改不动的二进制上位机。按现场挑门,别按习惯挑门。