简介:面向使用阿特拉斯·科普柯 PowerFocus 6000 控制器做二次开发的工程师,这份协议附录帮助读者了解 Open Protocol 在该产品上的具体实现范围。协议本身与平台无关,可以用于 Linux、PLC、打印机以及各类 Windows 平台;但 PF6000 对 MID 和版本的支持存在差异,文档正是围绕这些差异展开说明。全文用规范表格和章节条目组织,依次说明 MID 与 MIDs 的版本支持情况、受支持的 MID Relay 与数字输入信号、版本兼容性、MID 64/65 旧结果数据的特殊处理,以及紧固程序 Pset 的选择与批量大小、序列 Job 选择等用法要点,并标注了部分 MID 仅被部分版本支持等细节,便于读者在对接前确认能力范围。压缩包内仅有 1 个 PDF 文件,约 980KB,体量轻便,按主题分章,便于检索。目前已有 195 人学习。它适合具备一定总线通信或控制器集成经验、需要核对报文与功能支持的开发者,可用于对照实现细节规划接口、排查通信异常,减少试错成本。
1. 一台拧紧控制器想让上位机接管,缺的从来不是协议文档
产线上最常见的一幕:拧紧控制器本身跑得好好的,但 PLC 要拿这一颗螺栓的扭矩和角度做追溯,MES 要在换型时远程切紧固程序(Pset),工位还想在批次做满时自动锁枪。这三件事各自都有现成做法,凑在一起就得走开放协议(Open Protocol)。这份附录就是某工业拧紧设备厂商为 PF6K 系列控制器写的 Open Protocol 实现补充说明,规格版本 2.8,重点不在协议通用语法,而在「这台控制器到底支持哪些 MID、支持到第几版修订、哪些字段是残的」。它适合已经拿到基础协议文档、准备动手写收发代码的工程师;也适合手上有一套跑了两年的对接程序、最近换型就出玄学问题的老手。协议本身基于以太网,平台无关,Linux、PLC、Windows 都能实现,所以真正决定成败的不是语言,而是你有没有把这张支持矩阵读对。
2. MID 支持矩阵:先把表读对,再写第一行收发代码
附录里最值钱的是第 2.1 节那张 MID/Revision 支持表,它把所有 MID 编号、支持的最高修订号、以及「部分支持」标记摊平列出来。绝大多数对接翻车,不是代码写错,是照着通用协议文档写了一套完整实现,结果撞上控制器这边根本不认的字段。
2.1 Revision 那一列写的是「上限」,不是「版本号」
表头那句说明很容易被跳过:Revision 列填的数字,表示该产品支持从 0 到该数字的全部修订。也就是说写6不是「只支持第 6 版」,而是 0、1、2、3、4、5、6 全支持。这个语义差别直接影响你的分支写法——如果你的代码里写的是if revision == 6,那遇到控制器回 4 的时候整条链路就静默失败了。
我用下来比较稳的写法是把能力建成一张常量表,运行期只做一次协商,把结果落到会话对象里,后续所有 MID 构造都从这张表取参数,而不是散落在各处硬编码:
# 能力表:只记录「最高支持修订」和「部分支持」两个事实 # 数字来自附录表格,pid 是 MID 编号字符串,保持四位 CAPABILITY = { "0001": {"max_rev": 6, "partial": False}, # 通信开始 "0002": {"max_rev": 6, "partial": True}, # 通信开始应答,注意 p "0012": {"max_rev": 4, "partial": True}, # Pset 数据上传请求 "0015": {"max_rev": 1, "partial": True}, # Pset 已选中回读 "0033": {"max_rev": 2, "partial": True}, # Job 数据上传应答 "0041": {"max_rev": 5, "partial": True}, # 工具数据上传应答 "0061": {"max_rev": 8, "partial": True}, # 最近一次拧紧结果 "0065": {"max_rev": 8, "partial": True}, # 历史拧紧结果应答 "0140": {"max_rev": 1, "partial": True}, # 动态 Job 请求 } def negotiate(pid: str, peer_rev: int) -> int: """取双方都支持的最高修订号,取不到就直接报错,不要静默降级""" cap = CAPABILITY.get(pid) if cap is None: raise ValueError(f"MID {pid} 不在本机型支持列表内") return min(cap["max_rev"], peer_rev)min(cap["max_rev"], peer_rev)这一行是关键:控制器回什么修订,你就用什么修订,不要试图「按最新版发」,控制器对超范围的修订号通常不报错,只是把多余字段丢掉,你会拿到一个字段错位的结构体。partial字段是给后面做字段白名单用的,先埋着,第 5 章会用到。
2.2 短横线和 p 的区别,决定了你的降级分支
表里有三类标记:正常数字、一个短横线-、一个字母p。短横线的意思是这台控制器完全没实现该 MID 或该修订,你发过去大概率收到 0004 命令错误,或者干脆没应答;p的意思是至少有一个修订是部分支持的,字段可能缺、可能恒为 0。
这两类要分开处理。遇到短横线的项,最省事的做法是在能力表里直接去掉,让它走raise,而不是留着一条永远走不通的分支。附录里被标短横线的不少,比如多轴状态(0090-0093)、多轴结果(0100-0103)、显示用户文本(0110/0111)、控制器重启(0270)、量具相关(0410/0411)这些,在这台机型上都是空的。我见过有人在 0090 上耗了两天,最后才发现是机型不支持——这种问题查代码永远查不出来,只能查表。
p的处理更麻烦,因为它是「部分支持」,不是「不支持」。典型例子是 0065 旧结果上传应答:修订号标 8,但附录 2.3 节专门讲了哪些参数不支持。这意味着结构体长度对得上、解析不报错、字段值全靠猜。解决办法只有一个:把附录里标注「不支持」的字段列成白名单,解析后统一置空并打日志,不要让它流进业务层。
2.3 用一张能力表把运行期判断提前到编译期
矩阵表看着枯燥,实际用起来有个很实用的技巧:把「支持与否」的判定从运行期挪到构建期。比如在 CI 里跑一个检查脚本,扫一遍代码里出现的所有 MID 字面量,跟能力表比对,出现短横线项直接构建失败。这样后面换型、换控制器型号时,问题在流水线上就暴露了,而不是等到产线半夜停机。
这套做法我在两个项目上用过,收益比想象中大。原因很朴素:协议文档是死的,代码是活的,靠人记「这个机型不支持 0090」一定会忘,靠工具拦住才可靠。附录的价值恰恰在于它把每台机型的差异写成了明确的表格,而不是藏在正文段落里。
另外提醒一句,表头写明这张表只对特定固件版本有效,附录开头也写了规格可能不经通知变更。所以能力表不要写死在代码里当真理,留一个配置文件的位置,现场升级固件后能改。
3. 通讯骨架:0001 到 9999 的建链、心跳与订阅链路
协议跑在以太网上,帧结构极其朴素,这是好事也是坏事。好事是实现成本低,PLC、单片机都能扛;坏事是没有任何内置的可靠性机制,粘包、半包、心跳、应答全要自己补。
3.1 四字节长度 + 四字节 MID:帧外壳与粘包处理
每一帧的结构是固定的:先是 4 个 ASCII 数字表示整帧长度(并且包含这 4 个字节自身),接着是 4 个 ASCII 数字表示 MID,之后才是该 MID 的数据区。这里最容易翻车的点是长度语义——是含表头还是不含表头。附录里所有示例帧都以这个约定为准,写代码时先把这一条钉死,后面所有字段偏移都基于它。
import socket def encode_frame(mid: str, payload: str = "") -> bytes: """按附录帧格式封装。length 含自身 4 字节,所以是 4 + 4 + 数据长度""" assert len(mid) == 4 and mid.isdigit() body = mid + payload length = 4 + len(body) # 4 位长度字段 + MID + 数据 return f"{length:04d}{body}".encode("ascii") def recv_exact(sock: socket.socket, n: int) -> bytes: """TCP 是流,必须凑满 n 字节再返回,不能假设一次 recv 拿到完整帧""" buf = b"" while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError("对端关闭连接") buf += chunk return buf def read_frame(sock: socket.socket) -> tuple[str, str]: head = recv_exact(sock, 4) total = int(head.decode("ascii")) if total < 8: raise ValueError(f"非法帧长度: {total}") body = recv_exact(sock, total - 4) return body[:4].decode("ascii"), body[4:].decode("ascii")recv_exact这个函数看着平淡,却是整个实现里最不能省的部分。产线上的控制器在高频结果推送时,一帧被拆成两三个 TCP 段是常态,直接sock.recv(1024)然后按 4 字节切,迟早会遇到半包,表现出来就是「偶发的解析异常」,一天出两次,复现极难。我一般会把收到的原始字节同时写一份环形缓冲,出事时能捞出来对照,这东西比日志有用。
双向都要做的是MID 9999 保活。它在表里修订号写 0,数据区为空,长度固定 0008。控制器侧通常有连接超时策略,一段时间没收到任何帧就断链。做法是起一个独立定时器,每 3 到 5 秒发一次,和业务收发互不干扰。
def keep_alive(sock: socket.socket) -> None: # MID 9999 无数据区,整帧就是 "00089999" sock.sendall(encode_frame("9999"))3.2 命令通道和订阅通道不是一回事
协议里的 MID 大致分两种语义:请求-应答型(发一个请求,等一个应答,再回一个确认)和订阅型(订阅一次,之后控制器主动推,每推一条你要回确认)。这两条链路如果混在一个线程里做同步等待,就一定会互相堵死。
实际的坑是这样的:你在主线程发了 0018 选 Pset,然后阻塞等 0015 回读;与此同时控制器推了一条 0061 拧紧结果过来,你的接收循环把它当成了 0015 的应答,字段全错位。这类问题在压测时不出现,一上产线节拍快了就冒出来。
常见做法是单接收线程 + 分发器:接收线程只负责拆帧,按 MID 扔进不同的处理队列;请求-应答走一个带超时的 future 表,订阅推送走回调。这样「等应答」和「收推送」在物理上就分开了。
| 消息类型 | 典型 MID | 是否需要回确认 | 常见误用 |
|---|---|---|---|
| 建链/断链 | 0001、0002、0003 | 0002 需回 | 不校验 0002 里的修订号 |
| 保活 | 9999 | 否 | 与业务帧抢同一把锁 |
| 参数上传 | 0010-0013 | 逐条应答 | 一次请求当多条用 |
| Pset 选择 | 0018、0015、0016 | 0015 需回 0016 | 发完 0018 不校验回读 |
| Job 选择 | 0038 | 视配置 | 与 0140 动态 Job 混用 |
| 结果订阅 | 0060-0063 | 0061 需回 0062 | 漏回确认导致推送停摆 |
| 旧结果拉取 | 0064、0065 | 否 | 当成订阅用 |
3.3 结果数据订阅:0060-0063 与 0064/0065 的特殊分支
结果数据是绝大多数项目的核心诉求。两条路:订阅最近一次结果(0060 订阅、0061 推送、0062 确认、0063 取消订阅),和主动拉取历史结果(0064 请求、0065 应答)。表里 0060 到 0063 修订号都标 8,0065 标了p,附录还专门开了 2.4 节讲 64/65 的「旧结果数据特殊支持」。
这个特殊支持具体特殊在哪,一句话说清:64/65 走的是老版本结果结构,字段集合和新版不完全一致,部分参数在这台机型上不上报。所以别指望用 0061 的解析器直接解 0065,字段能对上纯属巧合。我一般会写两套 dataclass,共用底层字节切分工具,但字段定义分开维护,注释里标明来源章节。
订阅链路的状态机也要做对。发 0060 之后不是就完事了,控制器推 0061,你必须回 0062 确认,它才会继续推下一条。漏回确认的典型现象是「第一条能收到,第二条开始就没动静」,很多人以为是网络问题,实际是自己把链路挂住了。
def on_result_push(conn, payload: str) -> None: result = parse_last_result(payload) # 按修订 8 的结构解 try: conn.dispatch_to_business(result) finally: # 无论业务处理成功失败,确认必须回,否则订阅链路会停摆 conn.send(encode_frame("0062"))finally这块不是洁癖。业务层抛异常导致没回 0062,控制器侧就卡住不再推——这个故障模式排查起来非常费劲,因为日志里只看到「订阅后无数据」,看不出是自己的异常把确认吞了。
4. 控制器资源映射:Pset、Job、Relay 与 DigIn 的编号体系
前面三章讲的是「怎么收发」,这一章讲「发什么内容」。控制器的资源分四类:紧固程序(Pset)、序列(Job)、继电器输出(Relay)、数字输入(DigIn)。每类都有一套编号和一批 MID,附录第 2.5 到 2.7 节加上 2.2 节的大表,基本把这块讲全了。
4.1 Pset 选择链路与批量计数
Pset 相关 MID 占据 0010 到 0024 这一整段。分工是:0010/0011 拉 Pset ID 列表,0012/0013 拉具体参数,0014 到 0017 是「已选中 Pset」的订阅链路,0018 是主动选择,0019/0020 管批量大小和计数器复位,0021 到 0024 是批次完成锁定。
实战里最常见的误用是把 0018 当成「设完立即生效」。它是个请求,控制器回了 0015 才算真的切过去,你还得回 0016 确认。中间这段时间如果触发拧紧,用的还是老 Pset。所以正确顺序是:发 0018 → 等到 0015 → 回 0016 → 再放行启动信号。
def select_pset(conn, pset_id: int, timeout: float = 2.0) -> bool: """选 Pset 并等回读确认,未确认前不要放行工具启动""" conn.send(encode_frame("0018", f"{pset_id:03d}")) ack = conn.wait_for("0015", timeout=timeout) # 带超时的 future if ack is None: return False current = int(ack[:3]) # 回读的 Pset 号 conn.send(encode_frame("0016")) # 必须回确认 return current == pset_idcurrent == pset_id这个比对不能省。控制器可能因为批次锁定、权限或者 Pset 不存在而拒绝切换,但它不一定回报错,只是把当前值原样推回来。不比对就等于默认成功,后面所有追溯数据都会挂到错误的程序上。
批量部分,0019 设置 batch size(3 位数字),0020 复位计数器。批次做满后如果订阅了 0021,控制器会推 0022 告诉你「已因批次完成锁定」,你要回 0023 确认。附录里特意注明,这台机型上这个信号的含义是「被批次序列完成而锁定」,不是通用意义上的「批次完成」。
4.2 Job 选择、批次增减与锁定
Job 是 Pset 之上的一层编排。0030 到 0033 拉 Job 列表和数据,0034 到 0037 是 Job 信息订阅,0038 是选择 Job,0039 是重启 Job。另外还有一条容易忽略的路:0140 动态 Job 请求,表里标了p,说明只是部分支持,用之前先探一下。
选 Job 的语义和选 Pset 类似但更轻——0038 没有配套的「已选中回读」订阅作为强制确认,所以判断成败只能看 0005(命令已接受)和 0004(命令错误)。这里有个血泪经验:不要只看有没有 0004,也要看 0005 是不是在合理时间内回来。有些拒绝是静默的,控制器什么都不回,你的代码如果只有「收到错误才处理」的分支,就会一直等下去。
批次计数还有两条独立的 MID:0128 批次递增、0129 批次递减,配合 0120 到 0126 的 Job 线控信息订阅(启动、告警 1、告警 2、完成),可以拼出一条完整的工位节拍链路。0127 是中止 Job,0130 是 Job 关闭(表里标短横线,这台机型不支持,别用)。
4.3 MID 0215 的地址算法与 DigIn 功能码
2.2 节是整份附录里最长的一节,也是最能体现「附录」价值的地方:它把继电器编号 00 到 352 和数字输入编号 00 起到 20 多,逐个映射到具体的功能名,还标了是否参与追踪事件。0215 是 IO 设备状态应答,0214 是请求。
这里有个必须理解的编号规则:00 到 30 是固定功能位(Off、OK、NOK、低扭矩、高扭矩、循环完成、报警、批次 NxOK、Job OK/NOK、控制器就绪、工具就绪等),从 31 开始是按实际硬件数量动态分配的扩展区。附录给了两条公式:
X = 31 + 4 * Number of relays present Y = X + 8 + 4 * Number of digital inputs presentX 和 Y 是状态数组里设备相关数据的起始偏移,不是功能编号。把这两个概念混在一起,是解析 0215 报文时最常见的翻车点。
def io_layout(relay_count: int, digin_count: int) -> dict: """按附录公式推导 0215 应答里的偏移,relay_count/digin_count 是实际在位数量""" x = 31 + 4 * relay_count y = x + 8 + 4 * digin_count return { "fixed_relay_max": 30, # 00-30 固定功能位 "dynamic_relay_start": 31, # 31 起为扩展区 "x": x, # 设备相关状态起始 "y": y, # 数字输入状态起始 }relay_count和digin_count一定要用实际在位数量,不是型号铭牌上的最大路数。取法有两种:0214 请求 IO 设备状态,从 0215 应答里读;或者直接查控制器配置。用错数量,后面所有偏移全错位,表现出来是「功能位偶尔对偶尔不对」,属于典型的黑匣子问题。
DigIn 这侧还有一套功能码,通过 0224(设置数字输入功能)和 0225(复位)配置,0220 到 0223 是订阅链路。附录给出的功能码里,常用的几个值得记住:
| 功能码 | 功能 | 是否参与追踪 |
|---|---|---|
| 01 | 复位批次 | 是 |
| 02 | 解锁工具 | 是 |
| 03 / 04 | 工具禁用(常开 / 常闭) | 否 |
| 05 / 06 | 禁用拧紧 / 禁用松开 | 是 |
| 07 / 08 | 远程启动脉冲 / 持续 | 是 |
| 10 | 批次递增 | 是 |
| 11 | 旁路 Pset | 是 |
| 12 | 中止 Job | 是 |
| 16-19 | Pset 选择位 0-3 | 是 |
| 20 起 | Job 选择位 | 是 |
16 到 19 这四位拼起来是 Pset 选择,意味着你可以用四个物理开关切最多 16 个 Pset,不需要上位机参与。这种硬接线方案在小工位改造里非常好用,但要注意位序——附录写的是 bit 0 到 bit 3,落到实际接线顺序上,反接一次就够你查半天。
5. 避坑排查:五条来自现场对接的踩坑记录
这一章把前面散落的坑集中列一遍,按「现象 → 原因 → 解决」写,都是能对上号的。
现象一:每天早上第一次连上,五分钟左右必断,重连就好。原因是保活帧没发或者发得太稀。控制器侧有连接空闲判定,超时就主动断开。大部分实现会在业务逻辑忙的时候把保活定时器饿死,尤其是用单线程同步收发的时候。解决是把 9999 放到独立线程或者事件循环的独立定时任务里,间隔压到 3 秒,并且这个定时器不能受业务阻塞影响。顺带把断线重连做上,重连后要重新走 0001 建链和全部订阅,不能只重连不重订。
现象二:0060 订阅成功,第一条结果能收到,之后再也没数据。原因是 0061 推送后没回 0062 确认。协议是「确认驱动」的,你不确认它就不推下一条。而且这个失败是静默的,控制器不会告诉你「因为你没确认所以我停了」。解决是把 0062 放进finally,业务异常也要回。同时加一个监控:订阅状态为「已订阅」但超过 N 秒无推送,主动打日志告警。
现象三:换型切 Pset 后,第一颗螺栓的结果还挂在旧 Pset 上。原因是把 0018 当成了同步操作,发完就放行启动。解决是等 0015 回读、比对 Pset 号、回 0016 之后才允许启动信号通过。如果现场用 DigIn 硬接线选 Pset(功能码 16-19),还要额外确认控制器已经完成切换,因为这中间没有软件握手,只能靠读 0015 或者 0250 系列的 selector 状态确认。
现象四:开了批次锁定之后,工具再也解不开,只能重启控制器。原因是 0022(批次完成锁定上传)收到后没回 0023 确认,锁定状态一直挂着。另一个可能是解锁路径没走通:要用 DigIn 功能码 02(解锁工具)或者 0020 复位批次计数器。解决是把锁定当成一个显式状态机来做,收到 0022 立刻回 0023,然后按业务策略决定是复位计数还是等人工解锁,别让它停在中间态。
现象五:解析 0064/0065 的历史结果,字段全是 0 或者明显不对。原因有两个层面。一是用了 0061 的解析器去解 0065,两者结构不同,附录 2.4 节专门讲了旧结果的特殊处理;二是 0065 标了p,部分参数这台机型根本不上报。解决是给 0065 单独写解析器,并且按附录 2.3 节把不支持的字段列成白名单,解析后置空并记一条 debug 日志。业务层拿到空值时要能区分「没上报」和「值确实是 0」,这两者在追溯场景里含义完全不同。
提示:这五条里有四条跟「静默失败」有关。协议的容错设计偏向于不报错、不回应,所以每一处收发都要有超时和状态校验,不能靠「没收到错误就是成功」来判断。
6. 进阶:把这份附录变成可回归的协议测试集
协议对接的麻烦在于回归成本高——每次改代码都要连真机验一遍,而真机只有产线上那一台。我的做法是把附录里那张 MID 支持矩阵直接翻译成测试用例清单,用录制回放的方式跑,不依赖硬件。
具体分两步。第一步是录制:在联调阶段给收发层加一个旁路,把所有进出帧按时间戳落盘,格式就是方向, 原始字节。这个开销极小,但对后面排查问题的价值巨大。第二步是回放:写一个假控制器,读录制文件,按时间戳把控制器侧的帧吐出来,同时校验上位机发的帧是否符合预期。下面是一个最小骨架:
import json, time def replay(path: str, upper, speed: float = 1.0) -> None: """回放录制文件,upper 是待测上位机对象,speed 用于压缩等待时间""" with open(path, encoding="utf-8") as f: events = [json.loads(line) for line in f] last_ts = None for ev in events: if last_ts is not None: time.sleep(max(0.0, (ev["ts"] - last_ts) / speed)) last_ts = ev["ts"] if ev["dir"] == "ctl_to_upper": # 控制器发出的帧 upper.feed(ev["raw"]) else: # 上位机发出的帧,做断言 sent = upper.take_sent() assert sent[:4] == ev["raw"][:4], f"MID 不匹配: {sent[:4]} vs {ev['raw'][:4]}"speed参数用于把真实节拍压缩,产线上 20 秒一轮的流程,回放时压到 1 秒,跑一遍完整回归只要几分钟。assert那里只比 MID 前缀是刻意的——数据区里的 Pset 号、批次号会变,逐字节比对太脆,容易变成「改个测试数据就要改断言」的维护负担。真正需要逐字节比对的,是那几个字段长度固定的命令帧(0018、0038、9999),这几个单独列出来做精确匹配。
再补一个我每次接手这类项目都会做的验证清单,按顺序跑一遍,基本能覆盖 80% 的对接问题:
| 顺序 | 验证项 | 判定标准 |
|---|---|---|
| 1 | 建链与版本协商 | 0002 回读的修订号落在能力表内 |
| 2 | 保活链路 | 静置 10 分钟不断链 |
| 3 | Pset 全量上传 | 0011 数量与实际配置一致 |
| 4 | Pset 切换回读 | 0015 值等于请求值,0016 已回 |
| 5 | Job 选择 | 0005 在超时内到达,无 0004 |
| 6 | 结果订阅 | 连续 20 条 0061 全部收到且已回 0062 |
| 7 | 历史结果拉取 | 0065 字段按白名单置空,非白名单字段有值 |
| 8 | 批次锁定与解锁 | 0022/0023 成对出现,解锁路径可走通 |
| 9 | IO 状态解析 | 0215 偏移与在位数量匹配,固定功能位正确 |
这份清单我一般会贴到项目文档首页,每次发版前跑一遍。有一次赶进度跳过了第 4 项,结果上线当天换型时第一颗螺栓数据挂到旧程序上,追溯报表直接错了一整批。从那以后我每次改协议相关代码,都强制走一遍这个清单,一项都不许跳。希望帮到你。
本文还有配套的精品资源,点击获取