349 lines
13 KiB
Python
349 lines
13 KiB
Python
|
|
"""
|
|||
|
|
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()
|