Files
pickling-mes/backend/app/services/message_parser.py
wangyu 193da0018f feat: 移除PDI和订单号字段,新增设备巡检模块
- 从物料跟踪页面移除订单号列和表单字段
- 从导航菜单移除PDI管理,添加设备巡检
- 新增InspectionLocation和InspectionRecord后端模型和API
- 新增设备巡检前端页面(左侧点位列表,右侧设备和历史记录)
2026-05-27 16:38:40 +08:00

349 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
L1报文解析服务 — UDP协议
假设报文格式(固定帧结构,收到实际协议文档后对应调整):
┌─────────────────────────────────────────────────────┐
│ Offset Size 说明 │
│ 0 2B 魔数 0xAA 0xBB │
│ 2 4B 报文类型 ASCII"PC01"
│ 6 2B 序列号 uint16 大端 │
│ 8 2B Body长度 uint16 大端 │
│ 10 2B 校验和 所有Body字节累加低16位 │
│ 12 N B Body GBK编码固定列宽文本 │
└─────────────────────────────────────────────────────┘
UDP最大包65507字节单帧不分片。
回执帧Header相同结构Body = "ACK" + 原序列号(2B)
"""
import asyncio
import struct
import time
import uuid
import json
from datetime import datetime
from typing import Optional, Dict, Any, Tuple
from loguru import logger
from app.config import settings
# ─────────────────────────── 报文类型注册表 ───────────────────────────
MSG_TYPES: Dict[str, str] = {
"PC01": "卷材入口报文",
"PC02": "卷材出口报文",
"PC03": "过程数据报文",
"PC04": "质量数据报文",
"PC05": "设备状态报文",
"PC10": "计划下发报文",
"PC11": "计划确认报文",
"PC20": "心跳报文",
}
HEADER_SIZE = 12 # 报文头固定长度
MAGIC = b'\xAA\xBB'
# ─────────────────────────── 校验和 ───────────────────────────
def _checksum(body: bytes) -> int:
return sum(body) & 0xFFFF
# ─────────────────────────── 报文头解析 ───────────────────────────
def parse_header(raw: bytes) -> Optional[Dict[str, Any]]:
if len(raw) < HEADER_SIZE:
logger.warning(f"报文过短: {len(raw)}B丢弃")
return None
magic = raw[0:2]
if magic != MAGIC:
logger.warning(f"魔数错误: {magic.hex()},丢弃")
return None
msg_type = raw[2:6].decode("ascii", errors="replace").strip()
seq = struct.unpack(">H", raw[6:8])[0]
body_len = struct.unpack(">H", raw[8:10])[0]
checksum = struct.unpack(">H", raw[10:12])[0]
body = raw[HEADER_SIZE: HEADER_SIZE + body_len]
if len(body) < body_len:
logger.warning(f"Body不完整: 期望{body_len}B 实际{len(body)}B")
return None
if _checksum(body) != checksum:
logger.warning(f"校验和错误 [{msg_type}] seq={seq},丢弃")
return None
return {"msg_type": msg_type, "seq": seq, "body": body,
"body_str": body.decode("gbk", errors="replace")}
# ─────────────────────────── 构建回执帧 ───────────────────────────
def build_ack(seq: int) -> bytes:
body = b"ACK" + struct.pack(">H", seq)
hdr = MAGIC
hdr += b"ACK ".ljust(4)[:4]
hdr += struct.pack(">H", seq)
hdr += struct.pack(">H", len(body))
hdr += struct.pack(">H", _checksum(body))
return hdr + body
# ─────────────────────────── 构建发送帧 ───────────────────────────
def build_frame(msg_type: str, body: bytes, seq: int = 0) -> bytes:
hdr = MAGIC
hdr += msg_type.encode("ascii").ljust(4)[:4]
hdr += struct.pack(">H", seq)
hdr += struct.pack(">H", len(body))
hdr += struct.pack(">H", _checksum(body))
return hdr + body
# ─────────────────────────── Body解析器 ───────────────────────────
class PC01Parser:
"""卷材入口报文
Body固定列宽(GBK): 卷号(20) 钢种(10) 厚度(6) 宽度(6) 重量(8) 班次(2)
"""
def parse(self, body: str) -> Dict[str, Any]:
return {
"coil_no": body[0:20].strip(),
"steel_grade": body[20:30].strip(),
"thickness": _safe_float(body[30:36]),
"width": _safe_float(body[36:42]),
"weight": _safe_float(body[42:50]),
"shift": body[50:52].strip(),
"event_time": datetime.now(),
}
class PC02Parser:
"""卷材出口报文
Body: 卷号(20) 实测厚度(6) 实测宽度(6) 处理长度(8) 平均速度(6) 质量等级(2)
"""
def parse(self, body: str) -> Dict[str, Any]:
return {
"coil_no": body[0:20].strip(),
"actual_thickness": _safe_float(body[20:26]),
"actual_width": _safe_float(body[26:32]),
"process_length": _safe_float(body[32:40]),
"avg_speed": _safe_float(body[40:46]),
"quality_grade": body[46:48].strip(),
"event_time": datetime.now(),
}
class PC03Parser:
"""过程数据报文(周期推送)
Body: 卷号(20) 位置(10) 速度(6) 入口张力(8) 出口张力(8) 酸液温度(6)
"""
def parse(self, body: str) -> Dict[str, Any]:
return {
"coil_no": body[0:20].strip(),
"position": body[20:30].strip(),
"speed": _safe_float(body[30:36]),
"tension_inlet": _safe_float(body[36:44]),
"tension_outlet": _safe_float(body[44:52]),
"acid_temp": _safe_float(body[52:58]),
"event_time": datetime.now(),
}
class PC04Parser:
"""质量数据报文
Body: 卷号(20) 缺陷类型(10) 缺陷位置(8) 严重程度(2)
"""
def parse(self, body: str) -> Dict[str, Any]:
return {
"coil_no": body[0:20].strip(),
"defect_type": body[20:30].strip(),
"defect_pos": _safe_float(body[30:38]),
"severity": body[38:40].strip(),
"event_time": datetime.now(),
}
class PC05Parser:
"""设备状态报文
Body: 设备编号(10) 状态码(4) 故障码(6) 时间戳(14 yyyyMMddHHmmss)
"""
def parse(self, body: str) -> Dict[str, Any]:
ts_str = body[20:34].strip()
try:
ts = datetime.strptime(ts_str, "%Y%m%d%H%M%S")
except Exception:
ts = datetime.now()
return {
"equipment_code": body[0:10].strip(),
"status_code": body[10:14].strip(),
"fault_code": body[14:20].strip(),
"event_time": ts,
}
class PC20Parser:
"""心跳报文 Body: 时间戳(14)"""
def parse(self, body: str) -> Dict[str, Any]:
return {"event_time": datetime.now(), "raw_ts": body[0:14].strip()}
def _safe_float(s: str) -> float:
try:
return float(s.strip())
except (ValueError, AttributeError):
return 0.0
BODY_PARSERS: Dict[str, Any] = {
"PC01": PC01Parser(),
"PC02": PC02Parser(),
"PC03": PC03Parser(),
"PC04": PC04Parser(),
"PC05": PC05Parser(),
"PC20": PC20Parser(),
}
# ─────────────────────────── 分发器 ───────────────────────────
class MessageDispatcher:
def __init__(self):
self._handlers: Dict[str, list] = {}
def register(self, msg_type: str):
def decorator(func):
self._handlers.setdefault(msg_type, []).append(func)
return func
return decorator
async def dispatch(self, msg_type: str, data: Dict[str, Any]):
for handler in self._handlers.get(msg_type, []):
try:
await handler(data)
except Exception as e:
logger.error(f"报文处理器异常 [{msg_type}]: {e}")
dispatcher = MessageDispatcher()
# ─────────────────────────── UDP 服务端 ───────────────────────────
class L1UdpProtocol(asyncio.DatagramProtocol):
"""asyncio UDP DatagramProtocol 实现"""
def __init__(self, server: "L1UdpServer"):
self._server = server
self.transport: Optional[asyncio.DatagramTransport] = None
def connection_made(self, transport: asyncio.DatagramTransport):
self.transport = transport
host, port = transport.get_extra_info("sockname")
logger.info(f"UDP监听启动: {host}:{port}")
def datagram_received(self, data: bytes, addr: Tuple[str, int]):
asyncio.create_task(self._server.handle(data, addr, self.transport))
def error_received(self, exc: Exception):
logger.error(f"UDP错误: {exc}")
def connection_lost(self, exc):
logger.warning(f"UDP连接丢失: {exc}")
class L1UdpServer:
"""UDP报文接收服务"""
def __init__(self):
self._transport: Optional[asyncio.DatagramTransport] = None
self._running = False
# 统计
self.recv_count = 0
self.error_count = 0
async def start(self):
self._running = True
loop = asyncio.get_running_loop()
self._transport, _ = await loop.create_datagram_endpoint(
lambda: L1UdpProtocol(self),
local_addr=(settings.L1_HOST, settings.L1_PORT),
)
logger.info(f"L1 UDP服务已启动监听 {settings.L1_HOST}:{settings.L1_PORT}")
async def handle(self, raw: bytes, addr: Tuple[str, int],
transport: asyncio.DatagramTransport):
t0 = time.time()
self.recv_count += 1
logger.debug(f"收到UDP包 from {addr[0]}:{addr[1]} {len(raw)}B")
header = parse_header(raw)
if not header:
self.error_count += 1
await self._save_log(raw, addr, None, "error", "报文头解析失败", t0)
return
msg_type = header["msg_type"]
seq = header["seq"]
# 发送ACK回执
if msg_type != "ACK":
ack = build_ack(seq)
transport.sendto(ack, addr)
# 心跳不做业务处理
if msg_type == "PC20":
logger.debug(f"心跳 seq={seq}")
return
# Body解析
body_parser = BODY_PARSERS.get(msg_type)
data: Dict[str, Any] = {}
if body_parser:
try:
data = body_parser.parse(header["body_str"])
except Exception as e:
logger.error(f"Body解析异常 [{msg_type}]: {e}")
self.error_count += 1
await self._save_log(raw, addr, header, "error", str(e), t0)
return
else:
logger.warning(f"未知报文类型: {msg_type}")
elapsed_ms = (time.time() - t0) * 1000
logger.info(f"[{msg_type}] seq={seq} from {addr[0]} 耗时{elapsed_ms:.1f}ms")
await self._save_log(raw, addr, header, "success", None, t0, data)
await dispatcher.dispatch(msg_type, data)
async def _save_log(self, raw: bytes, addr: Tuple[str, int],
header: Optional[Dict], status: str,
error_msg: Optional[str], t0: float,
parsed_data: Optional[Dict] = None):
try:
from app.database import AsyncSessionLocal
from app.models.message import MessageLog
elapsed_ms = (time.time() - t0) * 1000
async with AsyncSessionLocal() as db:
log = MessageLog(
msg_id=str(uuid.uuid4())[:16],
msg_type=header["msg_type"] if header else "UNKNOWN",
direction="recv",
source=f"{addr[0]}:{addr[1]}",
raw_data=raw.hex(),
parsed_data=json.dumps(parsed_data, default=str) if parsed_data else None,
status=status,
error_msg=error_msg,
process_time=round(elapsed_ms, 2),
received_at=datetime.now(),
)
db.add(log)
await db.commit()
except Exception as e:
logger.error(f"保存报文日志失败: {e}")
def send(self, data: bytes, addr: Tuple[str, int]):
"""主动向L1发送报文"""
if self._transport:
self._transport.sendto(data, addr)
else:
raise RuntimeError("UDP服务未启动")
def stop(self):
self._running = False
if self._transport:
self._transport.close()
# 全局单例
l1_server = L1UdpServer()