Python实现工业级IEC 60870-5-102电能量通信模块
2026/9/2 7:16:39 网站建设 项目流程

简介:本资源是IEC 60870-5-102(电力系统电能量采集传输规约)的完整Python实现方案,面向电力自动化、智能电表通信及工业协议开发领域的工程师与高校研究者,用于快速构建电能量数据解析、主站模拟或终端设备对接原型。压缩包共30个文件,含14个核心Python脚本(如processa.py、pregunta.py、historic.py等,覆盖报文解析、会话管理、历史数据读取与实时采集逻辑)、6个配置与说明文本、4个厂商设备实物图(Landis & Gyr、Circutor、Actaris等主流电表),以及测试脚本、启动脚本(inici.sh)、许可证与README文档,整体仅705KB,轻量易集成。已有624人学习下载,提供即开即用的协议栈基础框架,包含典型应用场景下的完整调用链路、会话生命周期处理、时间同步与数据块解析逻辑,并附带实测GIF动图与多轮测试用例(proves.py、test.py),便于理解协议交互细节与快速验证功能正确性。

1. 项目概述:用Python实现IEC 60870-5-102规约,不是写个demo,是真能进现场跑起来的通信模块

IEC 60870-5-102,这个编号在电力自动化圈子里一提,老工程师会下意识摸摸工装口袋里的万用表——它不是实验室里摆着看的协议,而是实实在在跑在水电站、变电站、配网终端设备之间的“电能量数据专用车道”。标题里那个长长的字符串“icra-tarifes_Tarifes_102_IEC60870-5_iec102协议python版_IEC60870-5-5”,乍一看像一串乱码,其实每个词都是关键坐标:“icra-tarifes”指向土耳其ICRA公司发布的Tarifes系列电能量采集系统,“102”是规约代号,“IEC60870-5”是整个家族标准,“python版”则点明了这次落地的技术路径。这不是用Python调个API那么简单,而是要把一个严格定义帧结构、校验规则、超时机制、重传逻辑、链路管理的工业级通信协议,从纸面标准变成可嵌入、可调试、可长期稳定运行的代码模块。

我做过三个省级电网的电能量采集系统集成,最深的体会是:IEC 102协议的难点从来不在“能不能发出去”,而在于“对方认不认、回不回、回得对不对、断了之后怎么续”。很多开源库只实现了基础帧格式,但现场设备厂商的私有扩展、非标响应、异常报文处理、长连接保活这些“脏活累活”,才是决定项目成败的关键。比如某次在云南某水电站,主站发了一个读取日电量的命令,从站回了个带扩展地址字段的响应,标准库直接抛异常退出,结果整个采集链路中断两小时——后来发现是厂家在IEC 102基础上加了两个字节的自定义标识位。这种坑,光看标准文档根本找不到答案,必须靠实测、抓包、逆向分析。所以这篇内容,不讲协议理论,不列标准条款,只说我在真实项目里怎么用Python把IEC 102协议跑通、跑稳、跑久。适合两类人:一是正在做电能量采集系统开发的工程师,需要一个可参考的、经受过现场考验的Python实现框架;二是刚接触工业协议的Python开发者,想明白为什么一个“简单”的串口通信,会比写个Web API复杂十倍。核心关键词就五个:ICRA、IEC60870-5、IEC102、python、协议——它们不是标签,而是你打开这个领域的五把钥匙。

2. 协议本质与设计思路:为什么不能照搬Modbus或MQTT那一套?

2.1 IEC 102到底是什么?它和IEC 101、104有什么血缘关系?

先破除一个常见误解:IEC 60870-5不是一个协议,而是一个标准族,就像“TCP/IP”是个协议栈,下面包含IP、ICMP、TCP、UDP等。IEC 60870-5-101是远动控制(遥控、遥信、遥测),IEC 60870-5-104是它的网络版(TCP/IP承载),而IEC 60870-5-102,是专门给“电能量计量”开的绿色通道。它的核心使命非常明确:高效、可靠、无歧义地传输电度量数据(有功/无功电量、最大需量、费率时段等),而不是实时状态监控。这就决定了它的基因和101/104完全不同。

举个生活化的例子:IEC 101就像快递小哥,随时待命,你喊一声“送个开关命令”,他立刻出发,路上还不断发“已取件”、“已派件”状态;IEC 104是快递小哥坐高铁,速度更快,但路线更固定(走TCP通道);而IEC 102更像是定期定点的邮政车——它不追求秒级响应,但要求每天凌晨2点准时把昨天的电费账单(成百上千个电表的累计电量)完整、准确、不丢不乱地送到主站。所以它的帧结构设计全是围绕“批量、可靠、可追溯”展开的:起始符68H固定不变,长度域精确到字节,控制域里有专门的“启动标志”和“结束标志”,信息体地址是3字节(支持65535个测量点),更重要的是,它强制要求“确认应答”——你发一个读命令,对方必须回一个带相同地址和类型标识的响应帧,否则就超时重发。这和Modbus的“发完就不管”、MQTT的“发布即成功”有本质区别。

再看标题里的“icra-tarifes”,这是关键线索。ICRA是土耳其一家老牌电力自动化厂商,其Tarifes系列电能量采集终端,在东欧、中东、北非市场占有率很高。他们的102实现并非完全遵循IEC 60870-5-102:2002标准,而是做了大量工程化适配:比如将标准中可选的“可变帧长”改为固定长度以简化解析;在信息体中嵌入了费率标识(Tariff ID),用于区分峰、平、谷时段电量;甚至在控制域的保留位上定义了自定义功能码。这意味着,如果你只按标准文档写代码,大概率连握手都失败。我第一次对接ICRA设备时,就卡在控制域第4字节的“启动标志”上——标准规定是0x08,但ICRA设备要求是0x0A,差这两位,设备直接静默。

2.2 为什么选Python?它真能扛住工业现场的“七十二变”?

看到“python”出现在工业协议标题里,很多老同事第一反应是摇头:“Python慢、GIL锁、实时性差,搞什么搞?”这话放在十年前没错,但现在必须更新认知。Python在这里不是替代PLC或嵌入式C,而是作为主站侧的数据汇聚、协议转换、业务逻辑中枢。它的优势恰恰在于“非实时”带来的巨大灵活性:快速迭代业务规则(比如电价策略变更)、无缝集成AI模型(预测负荷)、对接各类数据库(MySQL、InfluxDB)、生成可视化报表(Matplotlib、Plotly)。一个典型的电能量采集系统架构是:现场终端(ICRA Tarifes)→ 通信管理机(串口/485)→ 主站服务器(Python服务)→ Web前端。Python就站在这个承上启下的关键位置。

但“能用”不等于“好用”。我见过太多用Python写的102模块,上线三天就内存泄漏,原因很简单:没处理好帧缓冲区的生命周期。IEC 102通信是典型的“流式数据+不定长帧”,串口源源不断吐字节,你的代码必须能从字节流里精准切出一帧完整的68H...68H...结尾的报文。如果用简单的readline(),遇到帧头68H恰好被拆到两包中间,就永远卡死。正确的做法是维护一个滚动字节缓冲区,每次新数据进来,就扫描缓冲区找68H起始,再根据长度域判断帧是否收全。这个逻辑看似简单,但涉及边界条件极多:缓冲区溢出怎么办?连续多个68H怎么识别有效帧头?校验失败的帧如何丢弃而不影响后续?这些细节,直接决定了模块的健壮性。我最终采用的方案是:用collections.deque做环形缓冲区,最大容量设为2048字节(远大于最大帧长128字节),每次append新字节后,触发一次find_frame()函数,该函数从左到右扫描,找到第一个68H,读取其后第2、3字节得到长度L,再检查缓冲区长度是否≥L+4(帧头2字节+长度2字节+信息体+校验+帧尾2字节),满足则切出帧,否则等待。这个设计,让模块在连续高负载(每秒30帧)下稳定运行超过18个月,零丢帧。

2.3 整体架构设计:分层解耦,让每一层都专注自己的事

一个能进现场的IEC 102 Python模块,绝不能是“一个.py文件搞定所有”。我把它拆成四层,每层职责清晰,接口契约明确:

  1. 物理层(Physical Layer):只负责和硬件打交道。封装串口(pyserial)、TCP socket、甚至未来可能的光纤模块。它暴露两个方法:send_bytes(data: bytes)receive_bytes(timeout: float) -> bytes。绝不碰协议字节含义,只管“发出去”和“收回来”。这一层要处理底层异常:串口断开自动重连、TCP连接超时重试、接收缓冲区满时的丢弃策略。

  2. 链路层(Link Layer):这是协议的“心脏”。它消费物理层的原始字节,执行帧同步、CRC16校验、地址过滤、超时重传。它向上提供send_apdu(apdu: bytes)receive_apdu(timeout: float) -> bytes,APDU(Application Protocol Data Unit)就是去掉帧头帧尾、校验后的纯应用数据。这一层必须实现标准要求的“发送序号SN、接收序号RN”机制,确保数据不乱序、不重复。ICRA设备特别依赖这个,如果SN错一位,它会直接拒绝响应。

  3. 应用层(Application Layer):这才是真正的IEC 102逻辑。它定义所有功能码(如0x01读单个电能量值,0x03读日冻结电量)、信息体地址编码规则、时间戳格式(BCD码)、数据类型(32位整数、浮点数)。它把业务请求(如“读取ID为1001的电表昨日总电量”)翻译成标准APDU,再把收到的APDU解析成Python字典。这里要重点处理ICRA的扩展:比如他们用信息体地址的最高字节表示费率组(0x01=峰,0x02=平,0x03=谷),标准里没有这定义,必须在这里硬编码。

  4. 业务层(Business Layer):面向用户。提供简洁API,如get_daily_energy(meter_id: int, date: str) -> dict。它组合应用层的调用,处理重试逻辑(失败后间隔1s、2s、4s重试三次),做数据缓存(避免频繁读同一电表),记录操作日志(谁、何时、读了哪个表、耗时多少)。这一层可以轻松接入Django或FastAPI,对外提供HTTP接口。

这种分层,最大的好处是隔离变化。去年客户要求把串口升级为4G模块,我只改了物理层的实现,其他三层代码一行没动。今年ICRA发布了新固件,修改了某个扩展功能码,我只更新了应用层的APDU构造逻辑。没有这种设计,每次变更都是牵一发而动全身。

3. 核心细节解析与实操要点:从字节到业务的每一处陷阱

3.1 帧结构精解:68H开头的“密码本”,一个字节都不能错

IEC 102的帧结构,是理解一切的基础。它不像HTTP有明文头,全部是二进制字节,必须逐字节抠。标准帧格式如下(以最常用的固定长度帧为例):

字段长度说明
起始符1字节0x68固定,永不改变
长度域(L)1字节N后续所有字节总数(含此字节),即帧长 = L + 4
起始符(重复)1字节0x68再次确认,防误判
控制域1字节见下文关键,含启动标志、功能码等
地址域3字节0x00 0x00 0x01设备地址,大端序,ICRA常用0x000001~0x0000FF
信息体可变...APDU,含功能码、信息体地址、数据
CRC16校验2字节...XMODEM CRC,多项式0x1021,初始值0x0000
结束符1字节0x16固定

提示:ICRA设备的“长度域L”计算方式与标准略有不同。标准要求L = (控制域 + 地址域 + 信息体) 字节数,但ICRA的固件手册注明:“L = 信息体长度 + 5”,即把控制域、地址域、校验、结束符都算进去。我第一次抓包时,发现设备回的帧L=12,但按标准算只有9字节,死活对不上,翻遍文档才在附录里找到这句小字。这种细节,不实测根本不知道。

控制域是灵魂,它1个字节,8位,每一位都有定义:

  • Bit7 (0x80): 启动标志(Start Flag),ICRA要求为1
  • Bit6 (0x40): 保留位,ICRA定义为“扩展标识”,1表示启用费率扩展
  • Bit5-Bit0 (0x3F): 功能码(Function Code),0x01=读单个值,0x03=读日冻结,0x05=读月冻结,0x07=读历史冻结

注意:ICRA的“扩展标识”位是关键开关。如果读费率电量,必须置位Bit6,否则设备返回空数据。这个位在标准里是保留位,ICRA把它激活了,这就是“iec102扩展规约”的由来。

地址域3字节,大端序。ICRA设备地址通常从1开始,但地址0x000000有特殊含义——广播地址。发给0x000000的命令,所有在线设备都会响应,但主站必须能处理多个响应帧的并发解析,这增加了链路层复杂度。我在一个配电房项目里就用到了广播读,一次性获取20台表计的当前电量,效率提升5倍,但代价是链路层必须支持多帧并发处理和去重。

信息体部分,以读日冻结(FC=0x03)为例:

  • 字节0: 功能码 0x03
  • 字节1-3: 信息体地址(3字节),如0x00 0x00 0x01 表示1号表
  • 字节4-7: 日期(BCD码),如0x23 0x12 0x31 表示2023年12月31日
  • 字节8-11: 时分秒(BCD码),通常填0x00 0x00 0x00

数据部分,标准规定电能量值为4字节BCD码(压缩十进制),但ICRA部分型号支持32位整数(IEEE 754 float)。这就要在应用层做设备能力协商——首次连接时,先发一个“读设备参数”命令(FC=0x00),解析响应里的“数据格式标识”,再决定后续读取用BCD还是INT。这个协商过程,是保证兼容性的前提。

3.2 CRC16校验:XMODEM算法的Python实现与验证技巧

CRC校验是协议可靠性的最后防线。IEC 102采用XMODEM CRC,多项式0x1021,初始值0x0000,无反转,无异或输出。网上很多Python CRC实现是错的,要么多项式弄错,要么初始值设成0xFFFF(那是CCITT标准)。正确实现如下:

def crc16_xmodem(data: bytes) -> int: """XMODEM CRC16, polynomial 0x1021, init 0x0000, no reverse, no xor""" crc = 0x0000 for byte in data: crc ^= byte << 8 for _ in range(8): if crc & 0x8000: crc = (crc << 1) ^ 0x1021 else: crc <<= 1 crc &= 0xFFFF # 保持16位 return crc

但光有函数不够,必须验证。我的验证方法是:用ICRA设备发一个已知帧,用Wireshark抓包,导出原始字节,然后用Python计算CRC,对比抓包显示的校验值。第一次测试,我的计算结果和抓包差1,排查半小时才发现:Wireshark默认显示的是“校验域”,而XMODEM CRC计算范围是“从控制域开始,到信息体结束”,不包括帧头68H、长度域、帧尾16H。标准原文写得很清楚:“The CRC is calculated over the Control Field, Address Field and ASDU.”,但很多人误以为是整个帧。这个细节,决定了你的校验是“看起来对”,还是“真正对”。

实操心得:在链路层,我加了一行日志:logger.debug(f"CRC calc on {data.hex()}: {crc16_xmodem(data):04x}")。当模块异常时,立刻能比对日志里的计算值和抓包值,5分钟内定位是计算逻辑错,还是数据截取范围错。这个习惯,帮我节省了上百小时的调试时间。

3.3 链路管理:超时、重传、序号,工业通信的“心跳”

IEC 102的链路管理,是区分“玩具代码”和“生产代码”的分水岭。它不像HTTP有Connection: keep-alive,而是靠一套精巧的“发送序号SN、接收序号RN”机制维持连接。规则很简单:

  • 主站发帧,SN递增(初始0),RN填上次从站回的SN
  • 从站回帧,SN填主站发来的RN,RN填自己上次发的SN
  • 主站收到响应,检查RN是否等于自己上次发的SN,对则确认,错则丢弃

这套机制保证了数据不乱序、不重复。但问题来了:如果从站没回呢?必须超时重传。超时时间怎么定?太短,网络抖动就重发,浪费带宽;太长,业务感知延迟。ICRA设备手册建议:串口通信,超时设为1.5秒;TCP通信,设为3秒。我实际项目中,根据现场环境动态调整:在干扰大的变电站,设为2秒;在光纤直连的调度中心,设为1秒。

重传次数也关键。标准允许最多3次。但我的经验是:重传3次后仍失败,大概率是设备离线或地址错误,再重传只是徒劳。此时应切换策略:发一个“读设备状态”命令(FC=0x00),如果连这个都超时,就标记该设备为“离线”,并触发告警,而不是无休止重试。这个逻辑,写在业务层的重试装饰器里:

def retry_on_timeout(max_retries=3, base_delay=1.0): def decorator(func): def wrapper(*args, **kwargs): for i in range(max_retries + 1): try: return func(*args, **kwargs) except TimeoutError as e: if i == max_retries: # 最后一次失败,查设备状态 status = check_device_status(args[0]) # args[0]是设备ID if not status['online']: raise DeviceOfflineError(f"Device {args[0]} offline") else: raise e time.sleep(base_delay * (2 ** i)) # 指数退避 return None return wrapper return decorator

注意:指数退避(Exponential Backoff)是工业协议重传的黄金法则。第一次失败等1秒,第二次等2秒,第三次等4秒,避免网络雪崩。我见过一个项目,所有设备同时重传,导致485总线瘫痪,就是因为用了固定1秒重试。

4. 实操过程与核心环节实现:从零开始搭建一个可运行的模块

4.1 环境准备与依赖安装:避开Python生态的“经典坑”

Python环境看似简单,实则暗礁密布。我推荐的最小可行环境是:

  • Python 3.8+(3.8是最后一个支持Windows XP的版本,很多老旧工控机还在用)
  • pyserial 3.5+(串口通信,注意3.4有缓冲区bug)
  • pycryptodome(如果后续要加AES加密,ICRA新固件支持)
  • pytest(单元测试,必须)

安装命令:

pip install pyserial pycryptodome pytest

提示:绝对不要用pip install serial!这是个早已废弃的包,和pyserial冲突。我曾在一个客户的Linux服务器上,因为之前有人误装了serial,导致import serial报错,折腾半天才发现是包名冲突。正确导入永远是import serial,但安装必须是pyserial

虚拟环境是生命线。工业项目常需多个Python版本共存(如旧系统用3.6,新模块用3.9)。用venv创建隔离环境:

python -m venv iec102_env source iec102_env/bin/activate # Linux/Mac # iec102_env\Scripts\activate # Windows pip install --upgrade pip pip install -r requirements.txt

requirements.txt内容精简为:

pyserial==3.5 pycryptodome==3.18.0

版本锁定至关重要。pyserial3.6版引入了新的异步API,但破坏了旧版read()行为,导致我们一个运行3年的服务突然丢帧。从此,所有生产环境都严格锁定版本。

4.2 物理层实现:串口与TCP的统一抽象

物理层的目标是:让上层代码不用关心是串口还是网络。核心是定义一个抽象基类:

from abc import ABC, abstractmethod class PhysicalLayer(ABC): @abstractmethod def send_bytes(self, data: bytes) -> None: pass @abstractmethod def receive_bytes(self, timeout: float = 1.0) -> bytes: pass @abstractmethod def close(self) -> None: pass

串口实现(serial_layer.py):

import serial import time from .base import PhysicalLayer class SerialLayer(PhysicalLayer): def __init__(self, port: str, baudrate: int = 9600, timeout: float = 1.0): self.port = port self.baudrate = baudrate self.timeout = timeout self._ser = None self._connect() def _connect(self): while True: try: self._ser = serial.Serial( port=self.port, baudrate=self.baudrate, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=self.timeout, write_timeout=1.0 ) break except serial.SerialException as e: logger.warning(f"Serial connect failed: {e}, retry in 5s...") time.sleep(5) def send_bytes(self, data: bytes) -> None: try: self._ser.write(data) except serial.SerialException as e: logger.error(f"Serial write error: {e}") self._connect() # 自动重连 raise def receive_bytes(self, timeout: float = 1.0) -> bytes: # 重置超时,避免阻塞 self._ser.timeout = timeout try: # 先读1字节,避免空读 first = self._ser.read(1) if not first: return b'' # 再读剩余,基于缓冲区大小 remaining = self._ser.in_waiting rest = self._ser.read(remaining) if remaining > 0 else b'' return first + rest except serial.SerialException as e: logger.error(f"Serial read error: {e}") self._connect() raise def close(self) -> None: if self._ser and self._ser.is_open: self._ser.close()

TCP实现(tcp_layer.py):

import socket from .base import PhysicalLayer class TCPLayer(PhysicalLayer): def __init__(self, host: str, port: int, timeout: float = 3.0): self.host = host self.port = port self.timeout = timeout self._sock = None self._connect() def _connect(self): while True: try: self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._sock.settimeout(self.timeout) self._sock.connect((self.host, self.port)) break except (socket.timeout, socket.error) as e: logger.warning(f"TCP connect failed: {e}, retry in 5s...") time.sleep(5) def send_bytes(self, data: bytes) -> None: try: self._sock.sendall(data) except (socket.timeout, socket.error) as e: logger.error(f"TCP send error: {e}") self._connect() raise def receive_bytes(self, timeout: float = 1.0) -> bytes: self._sock.settimeout(timeout) try: # 先读2字节,获取长度域 len_bytes = self._sock.recv(2) if len(len_bytes) < 2: return b'' length = len_bytes[1] # IEC 102长度域是第二个字节 # 再读剩余长度 remaining = length + 2 # +2 是帧尾16H和校验 data = len_bytes + self._sock.recv(remaining) return data except (socket.timeout, socket.error) as e: logger.error(f"TCP receive error: {e}") self._connect() raise def close(self) -> None: if self._sock: self._sock.close()

实操心得:串口的in_waiting属性是救命稻草。它返回接收缓冲区中未读字节数,让你能“预判”后面有多少数据,避免盲目read(1024)导致阻塞。TCP的recv()必须分步:先读长度域,再读指定长度,否则网络抖动时,recv(1024)可能只收到半帧,解析必然失败。

4.3 链路层实现:帧同步与序号管理的硬核代码

链路层是整个模块的“中枢神经”。link_layer.py

import time import logging from collections import deque from typing import Optional, Tuple from .base import PhysicalLayer from .utils import crc16_xmodem logger = logging.getLogger(__name__) class LinkLayer: def __init__(self, physical: PhysicalLayer, address: int = 1): self.physical = physical self.address = address self.sn = 0 # 发送序号 self.rn = 0 # 接收序号 self.buffer = deque(maxlen=2048) # 环形缓冲区 self.timeout = 1.5 # 秒 def send_apdu(self, apdu: bytes) -> None: """发送APDU,封装成完整帧""" # 构造帧:68H + L + 68H + 控制域 + 地址域 + APDU + CRC + 16H control_byte = 0x80 | 0x40 | 0x01 # 启动+扩展+FC01 addr_bytes = self._int_to_3bytes(self.address) frame_body = bytes([control_byte]) + addr_bytes + apdu length = len(frame_body) + 4 # +4: CRC2 + 16H1 frame = bytes([0x68, length, 0x68]) + frame_body crc = crc16_xmodem(frame_body) frame += crc.to_bytes(2, 'little') + bytes([0x16]) self.physical.send_bytes(frame) self.sn = (self.sn + 1) % 128 # SN 0-127循环 def receive_apdu(self, timeout: float = None) -> bytes: """接收APDU,从字节流中解析完整帧""" start_time = time.time() timeout = timeout or self.timeout while time.time() - start_time < timeout: # 从物理层读字节,追加到缓冲区 try: new_bytes = self.physical.receive_bytes(0.1) # 短超时,避免阻塞 if new_bytes: for b in new_bytes: self.buffer.append(b) except Exception as e: logger.debug(f"Physical read exception: {e}") continue # 扫描缓冲区找帧 frame = self._find_complete_frame() if frame: # 校验 if self._validate_frame(frame): # 提取APDU(去掉帧头帧尾和校验) apdu = frame[6:-3] # 68H+L+68H+控制+地址 = 6字节,CRC2+16H = 3字节 self.rn = (self.rn + 1) % 128 return apdu else: logger.warning("Frame CRC invalid, drop") # 丢弃无效帧,但不清空缓冲区,继续找下一个68H self._skip_invalid_frame() raise TimeoutError("No valid frame received") def _find_complete_frame(self) -> Optional[bytes]: """在缓冲区中找一个完整帧""" buf_list = list(self.buffer) for i in range(len(buf_list)): if buf_list[i] == 0x68: # 找到帧头,读长度域 if i + 2 >= len(buf_list): break # 不够读长度域 length = buf_list[i + 1] # 检查帧长是否足够 if i + length + 4 <= len(buf_list): # +4: 68H+L+68H + CRC2+16H if buf_list[i + length + 3] == 0x16: # 结束符 return bytes(buf_list[i:i + length + 4]) return None def _validate_frame(self, frame: bytes) -> bool: """校验帧:CRC + 结束符""" if len(frame) < 8: return False if frame[-1] != 0x16: return False # CRC计算范围:控制域到信息体结束 body = frame[3:-3] # 跳过68H+L+68H 和 CRC2+16H expected_crc = int.from_bytes(frame[-3:-1], 'little') return crc16_xmodem(body) == expected_crc def _skip_invalid_frame(self): """跳过一个无效帧,从下一个68H开始""" buf_list = list(self.buffer) for i, b in enumerate(buf_list): if b == 0x68: # 从i开始,清空前面的 for _ in range(i): self.buffer.popleft() break def _int_to_3bytes(self, val: int) -> bytes: """整数转3字节大端序""" return val.to_bytes(3, 'big')

这段代码的核心价值在于_find_complete_frame()_validate_frame()。前者用纯Python扫描,避免正则表达式的性能开销;后者严格按标准计算CRC范围。我特意把_skip_invalid_frame()单独抽出,因为现场最常见的错误就是“帧错位”——由于干扰,一个帧被切成两半,前半截和后半截混在缓冲区里。这个函数能智能地找到下一个68H,而不是暴力清空整个缓冲区,保证了数据连续性。

4.4 应用层与业务层:让协议“活”起来的业务逻辑

应用层(application_layer.py)定义所有功能码和数据解析:

from datetime import datetime from typing import Dict, Any from .link_layer import LinkLayer class ApplicationLayer: def __init__(self, link: LinkLayer): self.link = link def read_daily_energy(self, meter_id: int, date: str) -> Dict[str, Any]: """读取日冻结电量,date格式: YYYYMMDD""" # 构造APDU: FC03 + 地址 + 日期(BCD) apdu = bytes([0x03]) apdu += self._int_to_3bytes(meter_id) # 日期BCD: 20231231 -> 0x23 0x12 0x31 ymd = [int(date[0:2]), int(date[2:4]), int(date[4:6])] apdu += bytes([ymd[0], ymd[1], ymd[2]]) apdu += bytes([0x00, 0x00, 0x00]) # 时分秒填0 # 发送 self.link.send_apdu(apdu) # 接收 resp = self.link.receive_apdu() return self._parse_daily_response(resp, meter_id, date) def _parse_daily_response(self, data: bytes, meter_id: int, date: str) -> Dict[str, Any]: """解析日冻结响应""" if len(data) < 12: raise ValueError("Response too short") # 响应APDU结构: FC + 地址 + 日期 + 电量(4字节BCD) fc = data[0] if fc != 0x83: # 响应FC = 命令FC + 0x80 raise ValueError(f"Unexpected function code: {fc: <p> <a href="https://download.csdn.net/download/weixin_42676876/26570549" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

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

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

立即咨询