☰
PF6000开放协议拧紧数据采集:MID报文、Relay映射与联调避坑
2026/10/28 21:27:11 网站建设 项目流程

简介:这份资源是 PowerFocus 6000 开放协议(Open Protocol)的官方附录说明文档,面向需要对接阿特拉斯控制器、开发远程控制或数据订阅程序的工程师与上位机开发者。内容围绕 PF6000 2.8 版本的协议实现细节展开,逐项说明 MID 支持与修订版本对照表、支持的中继与数字输入信号、新旧版本兼容情况,以及紧固程序(Pset)的选择、批量大小设置和序列(Job)选择的用法,并针对 MID 64/65 旧结果数据给出专门说明,可帮助读者在 Linux、PLC、打印机或 Windows 等平台上落地协议对接、排查通讯异常。压缩包内共 1 个 PDF 文件,体积约 980KB,按章节组织,含 MID 对照表与注释,便于按条目检索。该文档已有 195 人学习下载,适合从事装配拧紧系统集成、需要一份权威协议对照手册的中高级开发者参考。

1. 一份附录撑起一条产线数据链:PF6000 开放协议能做什么

产线上要接一台 PF6000 控制器做拧紧结果采集,供应商给过来的不是 SDK,而是一份二十来页的附录:通篇是 MID 号、Revision 数字和一张 Relay 编号表。很多人第一反应是“这就是份协议说明书”,等真动手才发现,开放协议在 PF6000 上的难点从来不在报文格式——MID + 长度 + 数据这套帧结构半小时就能写完——而在这份附录用 “p” 和 “-” 两个符号划出来的能力边界:有些 MID 整条不支持,有些只实现了部分字段,有些字段在旧版本里干脆换过位置。

这份资源适合三类人:给拧紧工位做数据采集上报的、要把结果接进上层系统的、以及被“链路通了但数据不对”折磨过一轮的。下面按帧结构、结果订阅、位号映射、避坑、验收的顺序拆,目标是照着能把一条从建链到落库的采集链路跑通。

2. 帧结构与能力表:把 MID 支持表变成发命令前的自检

这一章先把两件事立住:报文怎么拼、附录里那张Table 1 MID/Revision support怎么读成代码里的能力表。前者决定你能不能和设备说上话,后者决定你发出去的每条命令会不会石沉大海。

2.1 定长帧:为什么这协议看起来这么“老派”

开放协议的每一条消息都是 ASCII 文本,结构固定成四段:4 位 MID、4 位长度、定长数据体、一个0x00结束符。长度字段只统计数据体的字节数,不含前面 8 位头,也不含结束符。数据体里所有字段都是定长的,短了用空格左对齐补齐,靠偏移量而非分隔符来切分。这套设计在 PLC 和打印设备上兼容性最好,代价是你必须严格按附录给出的字段宽度来拼,多一个空格都会让控制器把字段读串。

所以组帧函数不要用 JSON 那种思路,老老实实按“值 + 宽度”拼接:

def pack_field(value: str, width: int) -> str: """定长字段:左对齐、空格补齐、超长直接截断并让调用方知道""" s = str(value) if len(s) > width: raise ValueError(f"field overflow: {s!r} width={width}") return s.ljust(width) def build_frame(mid: str, fields=None) -> bytes: """ mid : 4 位数字字符串,如 "0060" fields : [(value, width), ...],按附录字段表顺序传 """ assert len(mid) == 4 and mid.isdigit() body = "".join(pack_field(v, w) for v, w in (fields or [])) return (mid + f"{len(body):04d}" + body).encode("ascii") + b"\x00"

接收端要比发送端更小心,因为一个 TCP 分片可能把两条消息黏在一起,也可能把一条消息劈成两半。用缓冲区累积,按0x00切分,切完立刻校验长度字段和实际数据体长度是否一致——不一致就说明拼接错位了,这时候继续解析只会得到一堆看起来像数据实则全错的字段值:

class FrameReader: def __init__(self): self.buf = bytearray() def feed(self, chunk: bytes): self.buf.extend(chunk) out = [] while True: idx = self.buf.find(b"\x00") if idx < 0: break raw = bytes(self.buf[:idx]) del self.buf[:idx + 1] out.append(self._parse(raw)) return out @staticmethod def _parse(raw: bytes): if len(raw) < 8: raise ValueError(f"frame too short: {raw!r}") mid = raw[0:4].decode("ascii") length = int(raw[4:8]) body = raw[8:8 + length].decode("ascii", "replace") if len(body) != length: # 常见于半包后误判、或对端发送了非标准帧 raise ValueError(f"length mismatch mid={mid} expect={length} got={len(body)}") return mid, body

pack_field里的超长抛异常是有意为之。字段宽度不够时静默截断,最后表现是“控制器返回的参数集 ID 少了最后一位”,排查会非常痛苦,不如在组帧阶段就翻车。

2.2 Revision 和 “p”:能力表要在启动时查一次

附录那张 MID 表有三列值得逐条抄进代码:MID 号、Revision、备注。Revision 写数字的,表示该产品支持 0 到该数字的全部修订;写-的表示整条 MID 不支持;写p的表示至少有一个修订是部分支持。这三类在工程上的处理方式完全不同:不支持的要在设计阶段就绕开,部分支持的要按字段级降级,全支持的才能放心用。

下面这张能力表是从附录里摘出来的典型片段,实际项目里建议整表抄全,别只抄你眼下用到的几条——后面加需求时不用再翻文档:

# 值: 数字 = 支持 rev0..N;"p" = 部分支持;None = 不支持 MID_CAP = { "0001": 6, "0002": 6, "0003": 1, "0004": 1, "0005": 1, "0010": 1, "0011": 1, "0012": "p", "0013": "p", "0018": 1, "0019": 1, "0020": 1, "0021": 1, "0022": 1, "0030": 2, "0031": 2, "0034": 4, "0035": "p", "0038": 2, "0039": 1, "0040": 5, "0041": "p", "0044": None, "0046": None, "0047": None, "0048": None, "0060": 8, "0061": 8, "0062": 8, "0063": 8, "0064": 8, "0065": "p", "0070": 3, "0071": 3, "0072": 3, "0076": 3, "0200": "p", "0214": 2, "0215": 2, "0216": 1, "0220": 1, "0221": 1, "0224": 1, "0225": 1, "0254": 2, "0255": 2, "0420": 1, "0421": 1, "9999": 0, } def capability(mid: str) -> str: if mid not in MID_CAP: return "unknown" v = MID_CAP[mid] if v is None: return "unsupported" if v == "p": return "partial" return f"rev0-{v}"

这张表最大的价值不是打印日志,是在启动自检里拦住错误设计。比如 MID 0044(请求断开工具)、0046(设置主工具)、0047(配对处理)在 PF6000 上全是不支持,如果上层业务里有“工具掉线自动重连到备用工具”的逻辑,就不能靠这几条实现,得走0214/0215的 IO 设备状态去判断工具连接(Relay 表里对应的是 289Tool connected和 290No tool connected)。再比如0012/0013是部分支持,意味着参数集上传拿回来的字段不全,不能拿它跟控制器界面上的参数做逐项比对。

partial的 MID 建议再补一层字段级白名单,比0012只取参数集 ID 和名称,其余字段标记为“不可信”:

PARTIAL_FIELDS = { "0012": {"pset_id", "pset_name"}, "0013": {"pset_id", "pset_name"}, "0200": {"relay_no", "relay_func"}, } def field_allowed(mid: str, name: str) -> bool: allowed = PARTIAL_FIELDS.get(mid) return True if allowed is None else name in allowed

2.3 客户端骨架:建链成功不等于链路可用

连接部分没有太多花活,socket.create_connection加上默认端口 4545(以控制器网络设置为准)就够。真正的分水岭在 MID 0001 之后:控制器回 MID 0002 才算建链完成,而在那之前发任何业务 MID 都会被丢掉或者回一条 MID 0004 命令错误。

import socket class PfClient: def __init__(self, host: str, port: int = 4545, timeout: float = 5.0): self.sock = socket.create_connection((host, port), timeout=timeout) self.sock.settimeout(timeout) self.reader = FrameReader() def send(self, mid: str, fields=None): self.sock.sendall(build_frame(mid, fields)) def recv_frames(self): chunk = self.sock.recv(4096) if not chunk: raise ConnectionError("controller closed the connection") return self.reader.feed(chunk) def start(self, cell_id="0001", channel_id="0001", app_name="APP"): # 字段顺序与宽度严格照附录 MID 0001 的字段表抄,这里只列前几项 self.send("0001", [(cell_id, 4), (channel_id, 4), (app_name, 20)]) while True: for mid, body in self.recv_frames(): if mid == "0001": continue # 对端重复的建链请求,忽略 if mid == "0002": return body[:4] # 前 4 位是应答的 MID 号 if mid == "0004": raise RuntimeError(f"command error: {body.strip()}")

这里有个容易忽略的细节:MID 0002 在表里标的是p,说明它是部分支持。所以建链应答里的后续字段不要全部当成有效值使用,尤其是控制器名称、软件版本这类信息字段,拿来做设备台账可以,拿来做逻辑分支就有风险。

3. 结果订阅链:MID 60/61/62 从推送到落库的完整闭环

数据采集的核心链路只有三条消息:0060订阅、0061推送、0062确认。写起来不到一百行,但每一条的边界条件都足够让人返工。

3.1 订阅-推送-确认:不 ack 会怎样

时序很直白:建链完成后发0060(数据体为空,长度字段0000),控制器之后每完成一次拧紧就推一条0061,客户端处理完回一条0062。0062的数据体由两部分组成:被确认的 MID 号(固定0061)和本次结果的 Tightening ID(10 位)。控制器靠 Tightening ID 匹配是哪条结果被确认了。

不 ack 的后果不是“重发”,而是控制器侧的发送缓冲会一直堆着,堆到某个上限之后新结果就不再推送,表现是“跑了一晚上,早上发现少了三百多条”。更隐蔽的是 ack 了但 Tightening ID 拼错,控制器认为没确认,同样会积压。所以 ack 一定要在业务处理之前发出去,落库失败是落库的事,不能拖住协议层:

def handle_result(cli: PfClient, body: str): tid = body[188:198].strip() # Tightening ID,偏移见 3.2 的字段表 cli.send("0062", [("0061", 4), (tid, 10)]) # 先 ack,再干活 record = parse_result(body) save_async(record) # 投递到队列,不阻塞收包循环

退订用0063,数据体为空。进程退出前发一次,能减少控制器侧的异常日志。

3.2 字段解析:用偏移表,别用固定切片

MID 0061 有二十多个字段,如果代码里到处写body[99:105],下一个接手的人基本没法维护,换一个 Revision 更是全盘失效。正确做法是把附录里的字段清单抄成一张表,解析函数只负责按表取值:

RESULT_LAYOUT = [ # (字段名, 起始偏移, 长度) ("cell_id", 0, 4), ("channel_id", 4, 4), ("controller_name", 8, 20), ("vin", 28, 25), ("job_id", 53, 4), ("pset_id", 57, 4), ("batch_size", 61, 4), ("batch_counter", 65, 4), ("tightening_status", 69, 4), ("torque_status", 73, 4), ("angle_status", 77, 4), ("torque", 99, 6), ("angle", 120, 5), ("timestamp", 146, 19), ("batch_status", 184, 4), ("tightening_id", 188, 10), ] def parse_result(body: str) -> dict: out = {} for name, start, width in RESULT_LAYOUT: out[name] = body[start:start + width].strip() if len(body) < 198: raise ValueError(f"result frame too short: {len(body)}") return out

偏移量是按附录 MID 0061 的 Revision 8 抄的,字段宽度和顺序一旦换版本就要改这张表。表里的三个状态字段值得单独说:tightening_status表示整体结果(OK/NOK 之类),torque_status和angle_status是分项判定,实际产线上经常出现“整体 NOK 但扭矩 OK、角度 NOK”的情况,上位机只取整体状态会丢掉定位信息,排查时拿不到判断依据。

字段长度典型用途注意事项
tightening_id10唯一键、ack 回填必须原样回填,不能补零或截断
pset_id4关联拧紧程序与0018选择的 Pset 对照
job_id4关联序列未启用 Job 时可能是空值
batch_counter4批量计数与0019设置的批量大小一起看
timestamp19分区、时序对齐控制器本地时间,注意时区
torque/angle6 / 5曲线与趋势分析单位随控制器配置,不要写死

3.3 跨夜断线与 9999 保活

产线环境里最折磨人的不是解析错误,是“昨天晚上十一点之后数据就没了”。TCP 长连接在交换机省电、网线插拔、控制器重启这几种情况下都可能静默断开,而recv在某些状态下不会立刻返回错误。所以除了SO_KEEPALIVE,应用层还要有存活判断。

MID 9999 在表里的 Revision 是 0,作用是确认通信链路存活。实现上不必复杂:每次收到任何帧都刷新last_seen,一个后台线程盯着这个时间戳,超过阈值(我一般设 30 秒)就主动断开重连,重连成功后把订阅重放一遍——0060结果订阅、0034Job 信息订阅、0070报警订阅,一条都不能漏:

import threading import time class Watchdog(threading.Thread): def __init__(self, client, idle_limit=30.0): super().__init__(daemon=True) self.client = client self.idle_limit = idle_limit self.last_seen = time.time() def touch(self): self.last_seen = time.time() def run(self): while True: time.sleep(1.0) if time.time() - self.last_seen > self.idle_limit: self.client.reconnect() # 内部做:断开、重连、重放订阅 self.touch()

重连要带退避,第一次 1 秒、第二次 2 秒、封顶 30 秒,避免控制器刚重启时被一堆连接请求打满。重放订阅时,如果0034订阅的是某个具体 Job,重连后要重新发一次,控制器不会记住上一次会话的订阅关系。

4. 位号即配置:Relay 与 DigIn 映射如何落到 Pset 和 Job 选择

附录里最厚的两张表是MID 0215的 Relay 编号表和 DigIn 功能表,加起来三百多行。这两张表的用法不是“从头读到尾”,而是“按工位需要挑几个号,剩下的靠回读验证”。

4.1 Relay 编号分段与跟踪事件

Relay 表里有两个维度:编号和Tracking event列。编号决定你配置的是哪个物理或逻辑输出,Tracking event决定它是电平还是脉冲。Yes表示跟随事件打拍(比如一次 OK 打一个短脉冲),No表示状态电平(比如工具运行中一直保持高)。这两类在接线和上位机判断逻辑上完全不同,把状态当脉冲用,上位机就会不停错过上升沿。

常用的号段大致这么分:00 到 22 是基础功能(OK、NOK、低扭矩、高扭矩、低角度、高角度、循环完成、报警、批次 NxOK、Job OK/NOK、Job 运行中、控制器就绪、工具就绪、启动开关、正反转开关),23 到 30 是方向与运行状态(拧紧方向、拧紧中、松开中、统计报警、工具锁定),32 到 39 和 59 到 62、110 到 111 是 Pset 位选,36 到 39、63 到 66、110 是 Job 位选,251 到 266 是已完成批次和剩余批次的二进制位,289 和 290 是工具连接状态。

Relay 号功能Tracking event工程用法
01 / 02OK / NOKYes打拍给 PLC 做计数,不要当状态读
05 / 06低扭矩 / 高扭矩Yes分项判定的定位信号
12 / 13Job OK / Job NOKYes与 01/02 区分,用于 Job 维度统计
18 / 19控制器就绪 / 工具就绪Yes上电自检、允许启动的前置条件
32-35Running Pset bit 0-3No二进制编码当前 Pset,最多 16 个
36-39Running Job bit 0-3No二进制编码当前 Job
91线路控制启动Yes与0120系列线路控制配合
144批次完成No电平,与 251 起的批次位配合
289 / 290工具已连接 / 未连接No替代不支持的0044/0047做连接判断

位选类的 Relay 是电平输出,意味着上位机读到的是一组二进制位,要自己拼。比如32-35全读到高电平,说明当前 Pset 编号是 15(1+2+4+8)。这套机制在 16 个程序以内够用,超过了就得看59-62、110这几组扩展位。

4.2 DigIn 功能号与 Pset 位选

DigIn 表短一些,但更常用。00 到 19 覆盖了产线上大部分硬接按钮的需求:01 复位批次、02 解锁工具、05 拧紧禁用、06 松开禁用、07 远程启动脉冲、08 远程启动持续、10 批次递增、11 旁路 Pset、12 中止 Job、13 Job 关闭、16 到 19 Pset 选择位 0 到 3。

用16-19做 Pset 位选时要特别注意时序:先稳定置好四位,至少保持一个扫描周期,再给启动信号。反过来“边置位边启动”在现场很常见,结果是偶尔选错 Pset——这种偶发问题最难查,因为复现率低,日志里又看不出异常。

def pset_select_bits(pset_index: int): """把 Pset 序号拆成 4 个 DigIn 的电平,对应 DigIn 16..19""" if not 0 <= pset_index <= 15: raise ValueError("pset_index out of range for 4-bit select") return [(16 + i, (pset_index >> i) & 1) for i in range(4)]

如果 Pset 数量超过 16,位选方案就不够用了,得改用0018走协议选择,硬接线的场景可以留一个“默认程序 + 协议切换”的兜底。

4.3 用 0200 和 0224 下发配置并回读

Relay 和 DigIn 的映射可以用协议改,不用每次跑控制器面板。0200设置外部控制的继电器,数据体是成对的“编号 + 功能号”;0224设置数字输入功能,数据体是“DigIn 号 + 功能号”;0225复位某个 DigIn。改完之后用0214请求 IO 设备状态,0215回包里把当前所有 Relay 和 DigIn 的实际状态读回来。

def set_relay(cli: PfClient, relay_no: int, relay_func: int): # 成对下发,一次可写多个;这里只写一个做示例 cli.send("0200", [(f"{relay_no:02d}", 4), (f"{relay_func:02d}", 4)]) def set_digin(cli: PfClient, digin_no: int, digin_func: int): cli.send("0224", [(f"{digin_no:02d}", 4), (f"{digin_func:02d}", 4)]) def read_io_status(cli: PfClient): cli.send("0214") # 数据体为空 for mid, body in cli.recv_frames(): if mid == "0215": return body

0200在能力表里是p,这意味着它只实现了部分功能号,并非所有 Relay 都能通过协议远程配置。我的做法是:用协议配完立刻回读0215比对,不一致的就退回面板配置,别把“协议写成功”当成“配置生效”。另外这类配置是运行期生效还是掉电保存,取决于控制器侧设置,重启后一定要再回读一次确认。

4.4 Pset 与 Job:批量计数、锁定与重启

选择紧固程序用0018,数据体是 4 位 Pset ID;设批量大小用0019,数据体是 Pset ID 加批量数;清零当前计数用0020。这套组合的典型用法是换型:0018切程序、0019设该程序对应的批量、0020把计数归零,三步做完再放行启动。

Job 侧对应的是0038选择 Job、0039重启 Job、0034订阅 Job 信息、0035接收 Job 状态。0035在表里标p,字段不全,实际能稳定拿到的是 Job ID、Job 状态、批量大小、批量计数这几项,别指望它把 Job 的所有配置项都吐出来。

def switch_pset(cli: PfClient, pset_id: str, batch_size: int): cli.send("0018", [(pset_id, 4)]) cli.send("0019", [(pset_id, 4), (f"{batch_size:04d}", 4)]) cli.send("0020", [(pset_id, 4)]) def switch_job(cli: PfClient, job_id: str, restart: bool = True): cli.send("0038", [(job_id, 4)]) if restart: cli.send("0039", [(job_id, 4)])

0021到0024这一组(批次完成时锁定、上传、确认、退订)在 PF6000 上的语义是“批次序列完成时锁定”,和很多人的直觉不一样——它表示的是批次做完后自动锁工具、等上位机解锁,并非拧紧结果里的批次锁定。实现换型流程时把这个语义搞错,会出现“批次做完工具不解锁”或者“解锁时机不对导致下一个件起不来”。

5. 避坑:五个让联调返工的协议细节

5.1 收到 0002 就发订阅,结果一直没数据

现象:连接成功、0002也收到了,0060发出去之后再也没收到过0061,控制器面板上明明在正常拧紧。

原因是链路建链完成不代表命令通道打开。PF6000 有一组0420/0421专门表示“开放协议命令被禁用”,这个状态可能来自控制器侧配置、也可能来自某个外部信号触发的禁用。建链之后先订阅0420,或者用0421查一次当前状态,比盲目重发0060有效得多。另外0060的数据体必须是空的,长度字段写0000,多填一个空格都会被判成格式错误。

5.2 结果重复入库,唯一键冲突

现象:同一次拧紧在库里出现两条记录,或者0062发出后控制器仍然重推同一条。

原因是 ack 里的 Tightening ID 没有原样回填。这个字段是 10 位定长,有些实现会习惯性地strip之后补零,或者从错误偏移取值,控制器匹配不上就继续推。解决方式是把tightening_id作为入库唯一键,同时在 ack 前做一次格式校验:长度必须是 10,不足说明偏移表抄错了。

5.3 字段错位,扭矩值看起来像角度值

现象:解析出来的torque是012.30这种格式没错,但和控制器屏幕上的值对不上,或者timestamp解析出来是乱码。

原因是字段表版本和控制器固件不一致。附录里的字段宽度是按某个 Revision 抄的,换了版本后中间某个字段宽度变了,后面全部偏移。防御手段有两个:一是解析前校验len(body)是否达到预期总长,不够直接丢弃并打日志;二是在字段表旁边标注 Revision,代码里按能力表里的 Rev 选表。千万不要在没有长度校验的情况下硬解析,错误的数值比没有数值更危险。

5.4 Relay 配了但不动作

现象:0200下发成功,0215回读也是对的,但物理输出没反应。

原因通常是两个:一是把Tracking event = No的功能当脉冲用了,上位机等一个短脉冲,实际拿到的是持续电平;二是 Relay 号和功能号传反了,这两者都是两位数字,格式校验完全拦不住。解决方式是配置完立刻回读0215,把当前所有 Relay 的功能号打出来和预期逐条比对,别只看“发送成功”。

5.5 用 MID 64 拉历史结果,只能拿到一部分字段

现象:想补拉断线期间的结果,发了0064,回来的0065里字段比实时推送少很多,部分字段是空的。

这是能力表里0065标p的直接后果。旧结果上传这条链路在 PF6000 上是部分支持,能拿到的字段有限,而且一次只能拉有限条数,需要靠请求里的计数参数控制节奏。工程上的做法是:实时链路(0060/0061)作为主数据源,0064/0065只用来做断线补偿,并且补偿后的记录标记来源,避免和实时数据混在一起做统计。

6. 联调验收:用一张对照表把协议跑通再上线

协议层的代码写完,离“能上线”还有一段距离。我的习惯是做一个最小验收脚本,把六个动作串成一条链路,每步都有明确的期望结果,任何一步不符合就停在那里查,不要往下走。

def smoke_test(host: str, pset_id: str = "0001"): cli = PfClient(host) cli.start() # 1. 建链,期望拿到 0002 cli.send("0060") # 2. 订阅,期望后续有 0061 switch_pset(cli, pset_id, batch_size=5) # 3. 切程序 + 设批量 set_relay(cli, 1, 1) # 4. OK 信号映射到 Relay 01 read_io_status(cli) # 5. 回读 IO,比对配置 # 6. 手动打一次拧紧,确认收到 0061 且能解析出 tightening_id

验收时对着下面这张表逐项确认,比看日志猜要快得多:

验收项期望结果不符合时先看哪里
建链收到 MID 0002端口、0001字段宽度
命令通道0421显示未禁用控制器侧的协议启用配置
结果推送每次拧紧收到一条0061是否发了0060、长度是否为0000
结果确认控制器不重推同一 Tightening ID0062里的 ID 是否 10 位原样

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询