feat: EDC 服务 — Python/uvloop 实现,UDP/TCP 异步网络服务
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.env
|
||||
*.log
|
||||
75
README.md
Normal file
75
README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# EDC 服务 (Edge Data Center)
|
||||
|
||||
测试工装边缘数据中心 — Python 实现,基于 uvloop 的高性能异步网络服务。
|
||||
|
||||
## 系统架构
|
||||
|
||||
```
|
||||
TCP/UDP ┌─────────────────────────────────────┐
|
||||
◄─────────────► │ EDC 服务 │
|
||||
│ │
|
||||
UDP :5500 ───► │ 设备发现 / 心跳 / 信息查询 │
|
||||
UDP :5505 ───► │ 消息监听 │
|
||||
TCP :5550 ───► │ 时间同步 / 数据上报 / 串口透传 │
|
||||
│ │
|
||||
│ 后台解析服务 ──► DG430 协议 → MySQL │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 设置环境变量
|
||||
export EDC_MYSQL_HOST=127.0.0.1
|
||||
export EDC_MYSQL_USER=root
|
||||
export EDC_MYSQL_PASSWORD=your_password
|
||||
export EDC_MYSQL_DB=edc
|
||||
|
||||
# 确保数据库已创建
|
||||
mysql -u root -e "CREATE DATABASE IF NOT EXISTS edc CHARACTER SET utf8mb4"
|
||||
|
||||
# 启动
|
||||
python run.py
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
所有配置通过环境变量,见 `src/config.py`:
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `EDC_UDP_PORT` | 5500 | UDP 设备发现端口 |
|
||||
| `EDC_UDP_MSG_PORT` | 5505 | UDP 消息监听端口 |
|
||||
| `EDC_TCP_PORT` | 5550 | TCP 数据上报端口 |
|
||||
| `EDC_BIND_HOST` | 0.0.0.0 | 绑定地址 |
|
||||
| `EDC_MYSQL_HOST` | 127.0.0.1 | MySQL 地址 |
|
||||
| `EDC_MYSQL_PORT` | 3306 | MySQL 端口 |
|
||||
| `EDC_MYSQL_USER` | root | 数据库用户 |
|
||||
| `EDC_MYSQL_PASSWORD` | — | 数据库密码 |
|
||||
| `EDC_MYSQL_DB` | edc | 数据库名 |
|
||||
| `EDC_PARSE_POLL_INTERVAL` | 0.5 | 解析轮询间隔(秒) |
|
||||
| `EDC_LOG_LEVEL` | INFO | 日志级别 |
|
||||
|
||||
## 协议参考
|
||||
|
||||
- [DG430 串口协议](../vd_test_fixture/docs/DG430串口协议.md)
|
||||
- [PGLC 网络接口协议](../vd_test_fixture/docs/PGLC网络接口协议.md)
|
||||
- [EDC 服务设计](../vd_test_fixture/docs/EDC服务.md)
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
edc_server/
|
||||
├── run.py # 入口
|
||||
├── requirements.txt # uvloop, aiomysql
|
||||
└── src/
|
||||
├── config.py # 环境变量配置
|
||||
├── models.py # MySQL 连接池 + 表结构 + CRUD
|
||||
├── protocol.py # PGLC JSON 协议解析
|
||||
├── dg430.py # DG430 二进制协议解析
|
||||
├── handlers.py # 业务处理 + 后台解析服务
|
||||
└── server.py # UDP/TCP 异步服务主程序
|
||||
```
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
uvloop>=0.19.0
|
||||
aiomysql>=0.2.0
|
||||
7
run.py
Normal file
7
run.py
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""EDC 服务入口"""
|
||||
|
||||
from src.server import run
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
29
src/config.py
Normal file
29
src/config.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""EDC 服务器配置"""
|
||||
|
||||
import os
|
||||
|
||||
# 网络端口
|
||||
UDP_PORT = int(os.getenv("EDC_UDP_PORT", "5500"))
|
||||
UDP_MSG_PORT = int(os.getenv("EDC_UDP_MSG_PORT", "5505"))
|
||||
TCP_PORT = int(os.getenv("EDC_TCP_PORT", "5550"))
|
||||
BIND_HOST = os.getenv("EDC_BIND_HOST", "0.0.0.0")
|
||||
|
||||
# MySQL
|
||||
MYSQL_HOST = os.getenv("EDC_MYSQL_HOST", "127.0.0.1")
|
||||
MYSQL_PORT = int(os.getenv("EDC_MYSQL_PORT", "3306"))
|
||||
MYSQL_USER = os.getenv("EDC_MYSQL_USER", "root")
|
||||
MYSQL_PASSWORD = os.getenv("EDC_MYSQL_PASSWORD", "")
|
||||
MYSQL_DB = os.getenv("EDC_MYSQL_DB", "edc")
|
||||
|
||||
# 连接池
|
||||
MYSQL_POOL_MIN = int(os.getenv("EDC_MYSQL_POOL_MIN", "2"))
|
||||
MYSQL_POOL_MAX = int(os.getenv("EDC_MYSQL_POOL_MAX", "10"))
|
||||
|
||||
# 设备超时 (秒): 超过此时间未收到心跳则标记离线
|
||||
DEVICE_TIMEOUT = int(os.getenv("EDC_DEVICE_TIMEOUT", "120"))
|
||||
|
||||
# 业务解析轮询间隔 (秒)
|
||||
PARSE_POLL_INTERVAL = float(os.getenv("EDC_PARSE_POLL_INTERVAL", "0.5"))
|
||||
|
||||
# 日志
|
||||
LOG_LEVEL = os.getenv("EDC_LOG_LEVEL", "INFO")
|
||||
181
src/dg430.py
Normal file
181
src/dg430.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""DG430 串口协议二进制解析
|
||||
|
||||
解析测试工装上报的 B2 状态包(dat_type=8 对应的 raw_data 为 DG430 上报数据)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─── 协议常量 ───────────────────────────────────────────────────────
|
||||
|
||||
STX = 0x7F
|
||||
PKT_MIN_LEN = 6 # STX + ADDR + LEN + CMD + XOR + SUM
|
||||
|
||||
|
||||
@dataclass
|
||||
class DG430Status:
|
||||
"""解析后的 DG430 状态数据"""
|
||||
addr: int # 地址
|
||||
dev_model: int # 1 PD132, 2 DLD110
|
||||
test_mode: int # 0 灵敏度, 1 模拟过车
|
||||
is_finished: bool # 是否正常完成
|
||||
finish_code: int # 0 正常, 1 未完成, 2 地感死机
|
||||
fault: int # bitmask
|
||||
relay_out: int # bitmask
|
||||
ppvalue: float # 峰峰值 V
|
||||
idle_freq: float # 开始工作频率 Hz
|
||||
enter_freq: float # 进入工作频率 Hz
|
||||
exit_freq: float # 离开工作频率 Hz
|
||||
enter_dist: int # 进入高度 mm
|
||||
exit_dist: int # 离开高度 mm
|
||||
enter_speed: int # 进入速度 dm/s
|
||||
exit_speed: int # 离开速度 dm/s
|
||||
|
||||
|
||||
# ─── 工具函数 ───────────────────────────────────────────────────────
|
||||
|
||||
def _xor_checksum(data: bytes, start: int, end: int) -> int:
|
||||
"""计算异或校验,区间 [start, end)"""
|
||||
result = 0
|
||||
for i in range(start, end):
|
||||
result ^= data[i]
|
||||
return result & 0xFF
|
||||
|
||||
|
||||
def _sum_checksum(data: bytes, start: int, end: int) -> int:
|
||||
"""计算和校验"""
|
||||
result = 0
|
||||
for i in range(start, end):
|
||||
result += data[i]
|
||||
return result & 0xFF
|
||||
|
||||
|
||||
def _le16(data: bytes, offset: int) -> int:
|
||||
"""小端 2 字节 → int"""
|
||||
return data[offset] | (data[offset + 1] << 8)
|
||||
|
||||
|
||||
def verify_packet(data: bytes) -> bool:
|
||||
"""校验数据包完整性"""
|
||||
if len(data) < PKT_MIN_LEN:
|
||||
return False
|
||||
if data[0] != STX:
|
||||
return False
|
||||
|
||||
length = data[2]
|
||||
expected_len = 1 + 1 + 1 + length + 1 + 1 # STX + ADDR + LEN + DATA + XOR + SUM
|
||||
if len(data) != expected_len:
|
||||
return False
|
||||
|
||||
payload_end = 4 + length # STX(1) + ADDR(1) + LEN(1) + CMD(1) + DATA(LEN-1)
|
||||
actual_xor = _xor_checksum(data, 1, payload_end)
|
||||
actual_sum = _sum_checksum(data, 1, payload_end)
|
||||
|
||||
if data[payload_end] != actual_xor:
|
||||
return False
|
||||
if data[payload_end + 1] != actual_sum:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def hex_str_to_bytes(hex_str: str) -> bytes:
|
||||
"""将十六进制字符串转为 bytes,如 '7F8118B2...' → bytes"""
|
||||
return bytes.fromhex(hex_str)
|
||||
|
||||
|
||||
# ─── 解析指令 ───────────────────────────────────────────────────────
|
||||
|
||||
def parse_b2_status(data: bytes) -> DG430Status | None:
|
||||
"""解析 0xB2 状态上报包
|
||||
|
||||
格式: STX | ADDR | LEN(0x18=24) | 0xB2 | 20字节状态内容 | XOR | SUM
|
||||
"""
|
||||
if not verify_packet(data):
|
||||
logger.warning("DG430 数据包校验失败")
|
||||
return None
|
||||
|
||||
addr = data[1] & 0x7F
|
||||
cmd = data[3]
|
||||
if cmd != 0xB2:
|
||||
logger.debug(f"非 B2 指令: 0x{cmd:02X}")
|
||||
return None
|
||||
|
||||
payload = data[4:24] # 20 字节状态内容
|
||||
|
||||
# 设备型号
|
||||
dev_model = payload[0] # 1=PD132, 2=DLD110
|
||||
test_mode = payload[1] # 0=灵敏度, 1=模拟过车
|
||||
finish_code = payload[2] # 0=正常, 1=未完成, 2=死机
|
||||
fault = payload[3] # bitmask
|
||||
relay_out = payload[4] # bitmask
|
||||
|
||||
# 峰峰值: 小端 2 字节,公式: (X * 3.3 / 4095) * 4
|
||||
pp_raw = _le16(payload, 5)
|
||||
ppvalue = (pp_raw * 3.3 / 4095) * 4
|
||||
|
||||
# 频率: 小端 2 字节,公式: 10 * X
|
||||
idle_freq = _le16(payload, 7) * 10.0
|
||||
enter_freq = _le16(payload, 9) * 10.0
|
||||
exit_freq = _le16(payload, 11) * 10.0
|
||||
|
||||
# 高度 mm
|
||||
enter_dist = _le16(payload, 13)
|
||||
exit_dist = _le16(payload, 15)
|
||||
|
||||
# 速度 dm/s
|
||||
enter_speed = _le16(payload, 17)
|
||||
exit_speed = _le16(payload, 19)
|
||||
|
||||
return DG430Status(
|
||||
addr=addr,
|
||||
dev_model=dev_model,
|
||||
test_mode=test_mode,
|
||||
is_finished=(finish_code == 0),
|
||||
finish_code=finish_code,
|
||||
fault=fault,
|
||||
relay_out=relay_out,
|
||||
ppvalue=round(ppvalue, 4),
|
||||
idle_freq=round(idle_freq, 1),
|
||||
enter_freq=round(enter_freq, 1),
|
||||
exit_freq=round(exit_freq, 1),
|
||||
enter_dist=enter_dist,
|
||||
exit_dist=exit_dist,
|
||||
enter_speed=enter_speed,
|
||||
exit_speed=exit_speed,
|
||||
)
|
||||
|
||||
|
||||
# ─── 故障解码 ───────────────────────────────────────────────────────
|
||||
|
||||
FAULT_BITS = {
|
||||
0: "工作频率不是最低频",
|
||||
1: "灵敏度不是最低灵敏度",
|
||||
2: "灵敏度提升拨码不在OFF",
|
||||
3: "脉冲输出不是离开脉冲",
|
||||
}
|
||||
|
||||
RELAY_BITS = {
|
||||
0: "存在继电器信号有输出",
|
||||
1: "脉冲继电器信号有输出",
|
||||
}
|
||||
|
||||
|
||||
def decode_fault_info(fault: int) -> str:
|
||||
"""解码故障 bitmask 为可读字符串"""
|
||||
items = []
|
||||
for bit, desc in FAULT_BITS.items():
|
||||
if fault & (1 << bit):
|
||||
items.append(desc)
|
||||
return "; ".join(items) if items else "无故障"
|
||||
|
||||
|
||||
def decode_relay_info(relay: int) -> str:
|
||||
"""解码继电器 bitmask"""
|
||||
items = []
|
||||
for bit, desc in RELAY_BITS.items():
|
||||
if relay & (1 << bit):
|
||||
items.append(desc)
|
||||
return "; ".join(items) if items else "无输出"
|
||||
219
src/handlers.py
Normal file
219
src/handlers.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""业务逻辑处理 — 设备发现、数据采集、解析调度"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from src.models import (
|
||||
upsert_dnt,
|
||||
ensure_collect_table,
|
||||
insert_collect_data,
|
||||
fetch_unparsed,
|
||||
mark_parsed,
|
||||
insert_test_result,
|
||||
get_dnt_by_serial,
|
||||
)
|
||||
from src.dg430 import parse_b2_status, hex_str_to_bytes, decode_fault_info, decode_relay_info
|
||||
from src.protocol import (
|
||||
make_timestamp_response,
|
||||
make_heartbeat_response,
|
||||
make_count_off_success,
|
||||
make_error_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 已注册设备: {device_id: dnt_id}
|
||||
_registry: dict[str, int] = {}
|
||||
|
||||
# 设备心跳时间: {device_id: last_heartbeat}
|
||||
_heartbeat: dict[str, float] = {}
|
||||
|
||||
|
||||
async def handle_count_off(data: dict, addr: tuple) -> str | None:
|
||||
"""处理设备发现广播
|
||||
|
||||
设备上电后主动上报设备信息。EDC 据此注册/更新 dnt_info。
|
||||
"""
|
||||
params = data.get("Params", {})
|
||||
device_id = params.get("Device_code")
|
||||
if not device_id:
|
||||
logger.warning("Count_Off 缺少 Device_code")
|
||||
return None
|
||||
|
||||
device_data = data.get("Data", {})
|
||||
dev_ip = device_data.get("Ip", addr[0])
|
||||
dev_port = device_data.get("Port", 0) or addr[1]
|
||||
dev_mac = device_data.get("Mac", "")
|
||||
dev_subnet = device_data.get("SubnetMask", "")
|
||||
dev_gateway = device_data.get("Gateway", "")
|
||||
dev_msgport = device_data.get("PortMsg", 0)
|
||||
dev_version = device_data.get("Version", "")
|
||||
dev_type = device_data.get("Device_Type", "30") # 默认测试工装
|
||||
|
||||
# 设备唯一标识用 Device_id(在 Data 内),如果没有则用 Device_code
|
||||
serial = device_data.get("Device_id") or device_id
|
||||
|
||||
dnt_id = await upsert_dnt(
|
||||
serial=serial,
|
||||
ip=dev_ip,
|
||||
port=dev_port,
|
||||
mac=dev_mac,
|
||||
subnet=dev_subnet,
|
||||
gateway=dev_gateway,
|
||||
msgport=dev_msgport,
|
||||
version=dev_version,
|
||||
dtype=dev_type,
|
||||
)
|
||||
_registry[serial] = dnt_id
|
||||
_heartbeat[serial] = time.time()
|
||||
|
||||
# 确保采集表存在
|
||||
await ensure_collect_table(serial)
|
||||
|
||||
logger.info(f"设备注册: {serial} (dnt_id={dnt_id}, ip={dev_ip})")
|
||||
return make_count_off_success()
|
||||
|
||||
|
||||
async def handle_heartbeat(data: dict) -> str | None:
|
||||
"""处理心跳包"""
|
||||
params = data.get("Params", {})
|
||||
device_id = params.get("Device_id", "")
|
||||
if not device_id:
|
||||
return None
|
||||
|
||||
_heartbeat[device_id] = time.time()
|
||||
|
||||
# 心跳也插入采集表 (dat_type=0)
|
||||
try:
|
||||
await insert_collect_data(device_id, 0, str(data))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return make_heartbeat_response(device_id, int(time.time()))
|
||||
|
||||
|
||||
async def handle_timestamp(data: dict) -> str:
|
||||
"""处理时间同步请求"""
|
||||
params = data.get("Params", {})
|
||||
device_id = params.get("Device_id", "")
|
||||
return make_timestamp_response(device_id, int(time.time()))
|
||||
|
||||
|
||||
async def handle_tsreport(data: dict) -> str | None:
|
||||
"""处理设备主动上报子设备传感数据
|
||||
|
||||
TSReport 中的 Sub_Dat 是 DG430 的二进制上报数据,
|
||||
存入采集表 (dat_type=8),由解析服务异步处理。
|
||||
"""
|
||||
device_id = data.get("Device_id", "")
|
||||
sensor_dat = data.get("Sensor_Dat", {})
|
||||
sub_dat = sensor_dat.get("Sub_Dat", "")
|
||||
|
||||
if not device_id or not sub_dat:
|
||||
return None
|
||||
|
||||
try:
|
||||
await insert_collect_data(device_id, 8, sub_dat)
|
||||
except Exception as e:
|
||||
logger.error(f"存储 TSReport 失败: {e}")
|
||||
|
||||
return None # 协议规定不需要回复(看文档似乎有回复,保守起见返回简单确认)
|
||||
|
||||
|
||||
async def handle_serial_net(data: dict) -> str | None:
|
||||
"""处理串口透传返回数据
|
||||
|
||||
SerialNet 返回中 SerialDat 是串口设备的应答数据,
|
||||
存入采集表 (dat_type=9)。
|
||||
"""
|
||||
params = data.get("Params") or data
|
||||
device_id = params.get("Device_id", "")
|
||||
serial_dat = params.get("SerialDat", "")
|
||||
|
||||
if not device_id or not serial_dat:
|
||||
return None
|
||||
|
||||
try:
|
||||
await insert_collect_data(device_id, 9, serial_dat)
|
||||
except Exception as e:
|
||||
logger.error(f"存储 SerialNet 失败: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def handle_device_info(data: dict) -> str | None:
|
||||
"""处理设备信息查询"""
|
||||
# 当前不做特殊处理,仅记录日志
|
||||
device_id = data.get("Device", "")
|
||||
logger.debug(f"Device_Info 请求: {device_id}")
|
||||
return None
|
||||
|
||||
|
||||
# ─── 业务解析服务(后台轮询)─────────────────────────────────────────
|
||||
|
||||
async def parse_loop():
|
||||
"""后台轮询:解析未处理的 DG430 上报数据"""
|
||||
logger.info("业务解析服务启动")
|
||||
|
||||
while True:
|
||||
try:
|
||||
for device_id, dnt_id in list(_registry.items()):
|
||||
records = await fetch_unparsed(device_id)
|
||||
for rec in records:
|
||||
try:
|
||||
raw = rec["raw_data"]
|
||||
# raw_data 是十六进制字符串
|
||||
pkt = hex_str_to_bytes(raw)
|
||||
status = parse_b2_status(pkt)
|
||||
if status is None:
|
||||
continue
|
||||
|
||||
# 解码故障和继电器信息
|
||||
fault_info = decode_fault_info(status.fault)
|
||||
relay_info = decode_relay_info(status.relay_out)
|
||||
|
||||
# 设备型号
|
||||
dev_model_map = {1: "PD132", 2: "DLD110"}
|
||||
str_type = dev_model_map.get(status.dev_model, f"Unknown({status.dev_model})")
|
||||
|
||||
await insert_test_result(
|
||||
dnt_id=dnt_id,
|
||||
dpg430_addr=status.addr,
|
||||
pcnum=datetime.now().strftime("%Y%m"),
|
||||
serialnum=0,
|
||||
sub_type=status.dev_model,
|
||||
str_type=str_type,
|
||||
iffinish="1" if status.is_finished else "0",
|
||||
fault_info=fault_info,
|
||||
relay_out=relay_info,
|
||||
ppvalue=status.ppvalue,
|
||||
idle_freq=status.idle_freq,
|
||||
enter_freq=status.enter_freq,
|
||||
exit_freq=status.exit_freq,
|
||||
enter_dist=status.enter_dist,
|
||||
exit_dist=status.exit_dist,
|
||||
enter_speed=status.enter_speed,
|
||||
exit_speed=status.exit_speed,
|
||||
)
|
||||
|
||||
await mark_parsed(device_id, rec["id"])
|
||||
logger.info(
|
||||
f"解析完成: {device_id} dg430={status.addr} "
|
||||
f"型号={str_type} 峰峰值={status.ppvalue:.2f}V "
|
||||
f"进入高度={status.enter_dist}mm 故障={fault_info}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"解析记录 {rec['id']} 失败: {e}")
|
||||
# 仍然标记为已处理,避免死循环
|
||||
try:
|
||||
await mark_parsed(device_id, rec["id"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"解析循环异常: {e}")
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
268
src/models.py
Normal file
268
src/models.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""数据库模型 — 连接池管理与表结构初始化"""
|
||||
|
||||
import logging
|
||||
import aiomysql
|
||||
from src.config import (
|
||||
MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB,
|
||||
MYSQL_POOL_MIN, MYSQL_POOL_MAX,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_pool: aiomysql.Pool | None = None
|
||||
|
||||
|
||||
async def init_pool() -> aiomysql.Pool:
|
||||
"""初始化 MySQL 连接池并建表"""
|
||||
global _pool
|
||||
_pool = await aiomysql.create_pool(
|
||||
host=MYSQL_HOST,
|
||||
port=MYSQL_PORT,
|
||||
user=MYSQL_USER,
|
||||
password=MYSQL_PASSWORD,
|
||||
db=MYSQL_DB,
|
||||
minsize=MYSQL_POOL_MIN,
|
||||
maxsize=MYSQL_POOL_MAX,
|
||||
autocommit=True,
|
||||
)
|
||||
await _create_tables(_pool)
|
||||
logger.info("MySQL 连接池已初始化")
|
||||
return _pool
|
||||
|
||||
|
||||
async def get_pool() -> aiomysql.Pool:
|
||||
"""获取连接池"""
|
||||
assert _pool is not None, "数据库连接池未初始化"
|
||||
return _pool
|
||||
|
||||
|
||||
async def close_pool():
|
||||
"""关闭连接池"""
|
||||
global _pool
|
||||
if _pool:
|
||||
_pool.close()
|
||||
await _pool.wait_closed()
|
||||
_pool = None
|
||||
|
||||
|
||||
# ─── DDL ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _create_tables(pool: aiomysql.Pool):
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# 1. 联网终端信息表
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `dnt_info` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`serial` VARCHAR(45) UNIQUE NOT NULL COMMENT '设备唯一编码 Device_id',
|
||||
`name` VARCHAR(45) DEFAULT '',
|
||||
`ip` VARCHAR(45) DEFAULT '',
|
||||
`port` INT DEFAULT 0,
|
||||
`mac` VARCHAR(45) DEFAULT '',
|
||||
`subnet` VARCHAR(45) DEFAULT '',
|
||||
`gateway` VARCHAR(45) DEFAULT '',
|
||||
`msgport` INT DEFAULT 0,
|
||||
`version` VARCHAR(45) DEFAULT '',
|
||||
`dtype` VARCHAR(5) DEFAULT '30' COMMENT '设备类型',
|
||||
`poll_duration` INT DEFAULT 0,
|
||||
`reset_duration` INT DEFAULT 0,
|
||||
`state` TINYINT DEFAULT 0 COMMENT '0 offline, 1 online',
|
||||
`last_login` DATETIME NULL,
|
||||
`last_off` DATETIME NULL,
|
||||
`online_total` INT DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
# 2. 车检器测试参数信息表
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `tb_loop_test_info` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`name` VARCHAR(45) DEFAULT '',
|
||||
`dev_model` VARCHAR(24) DEFAULT '',
|
||||
`model_code` TINYINT DEFAULT 0,
|
||||
`hard_ver` VARCHAR(10) DEFAULT '',
|
||||
`soft_ver` VARCHAR(10) DEFAULT '',
|
||||
`relay_exist` TINYINT DEFAULT 0,
|
||||
`relay_pluse` TINYINT DEFAULT 0,
|
||||
`sens_min` INT DEFAULT 0,
|
||||
`sens_max` INT DEFAULT 0,
|
||||
`freq_min` INT DEFAULT 0,
|
||||
`freq_max` INT DEFAULT 0,
|
||||
`peak_min` INT DEFAULT 0,
|
||||
`peak_max` INT DEFAULT 0,
|
||||
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
# 3. 设备测试状态表
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS `tb_state_tst` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`dnt_id` INT NOT NULL COMMENT 'FK → dnt_info.id',
|
||||
`dpg430_addr` TINYINT DEFAULT 0,
|
||||
`pcnum` VARCHAR(10) DEFAULT '' COMMENT '批次号',
|
||||
`serialnum` INT DEFAULT 0 COMMENT '流水号',
|
||||
`sub_type` TINYINT DEFAULT 0 COMMENT '1 DLD110, 2 PD132',
|
||||
`str_type` VARCHAR(30) DEFAULT '',
|
||||
`iffinish` VARCHAR(5) DEFAULT '' COMMENT '是否完成',
|
||||
`fault_info` VARCHAR(100) DEFAULT '',
|
||||
`relay_out` VARCHAR(24) DEFAULT '',
|
||||
`ppvalue` FLOAT DEFAULT 0 COMMENT '峰峰值',
|
||||
`idle_freq` FLOAT DEFAULT 0 COMMENT '开始工作频率',
|
||||
`enter_freq` FLOAT DEFAULT 0,
|
||||
`exit_freq` FLOAT DEFAULT 0,
|
||||
`enter_dist` INT DEFAULT 0,
|
||||
`exit_dist` INT DEFAULT 0,
|
||||
`enter_speed` INT DEFAULT 0,
|
||||
`exit_speed` INT DEFAULT 0,
|
||||
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX `idx_dnt_id` (`dnt_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
# 4. 采集表模板(不直接创建表,设备注册时按此结构动态建表)
|
||||
logger.info("数据库表初始化完成")
|
||||
|
||||
|
||||
# ─── 设备采集表 CRUD ────────────────────────────────────────────────
|
||||
|
||||
COLLECT_TABLE_PREFIX = "tb_collect_"
|
||||
|
||||
|
||||
def collect_table_name(device_id: str) -> str:
|
||||
"""根据 Device_id 生成采集表名"""
|
||||
return f"{COLLECT_TABLE_PREFIX}{device_id}"
|
||||
|
||||
|
||||
async def ensure_collect_table(device_id: str):
|
||||
"""确保设备采集表存在"""
|
||||
table = collect_table_name(device_id)
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS `{table}` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
||||
`dat_type` TINYINT DEFAULT 0 COMMENT '0心跳 1流量 2探头 3其他 4时间戳 7 RS485 8串口上报 9配置返回 11异常',
|
||||
`raw_data` VARCHAR(380) DEFAULT '',
|
||||
`state` TINYINT DEFAULT 0 COMMENT '0 未处理, 1 已处理',
|
||||
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
logger.info(f"设备采集表 {table} 已就绪")
|
||||
|
||||
|
||||
async def insert_collect_data(device_id: str, dat_type: int, raw_data: str):
|
||||
"""向设备采集表插入原始数据"""
|
||||
table = collect_table_name(device_id)
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
f"INSERT INTO `{table}` (dat_type, raw_data) VALUES (%s, %s)",
|
||||
(dat_type, raw_data),
|
||||
)
|
||||
|
||||
|
||||
async def fetch_unparsed(device_id: str) -> list[dict]:
|
||||
"""获取未处理的记录 (state=0, dat_type=8)"""
|
||||
table = collect_table_name(device_id)
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute(
|
||||
f"SELECT id, raw_data FROM `{table}` WHERE state = 0 AND dat_type = 8 LIMIT 100"
|
||||
)
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
async def mark_parsed(device_id: str, record_id: int):
|
||||
"""标记记录为已处理"""
|
||||
table = collect_table_name(device_id)
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
f"UPDATE `{table}` SET state = 1 WHERE id = %s", (record_id,)
|
||||
)
|
||||
|
||||
|
||||
# ─── dnt_info CRUD ─────────────────────────────────────────────────
|
||||
|
||||
async def get_dnt_by_serial(serial: str) -> dict | None:
|
||||
"""根据 Device_id (serial) 查询终端"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute("SELECT * FROM dnt_info WHERE serial = %s", (serial,))
|
||||
return await cur.fetchone()
|
||||
|
||||
|
||||
async def upsert_dnt(serial: str, ip: str, port: int, mac: str,
|
||||
subnet: str, gateway: str, msgport: int,
|
||||
version: str, dtype: str = "30") -> int:
|
||||
"""插入或更新终端信息,返回 dnt_info.id"""
|
||||
existing = await get_dnt_by_serial(serial)
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
if existing:
|
||||
# 已有记录:更新 IP / 网关 / 上线时间
|
||||
if (existing["ip"] != ip or existing["gateway"] != gateway
|
||||
or existing["port"] != port or existing["subnet"] != subnet):
|
||||
await cur.execute(
|
||||
"""UPDATE dnt_info SET ip=%s, port=%s, subnet=%s, gateway=%s,
|
||||
mac=%s, msgport=%s, version=%s, last_login=NOW(), state=1
|
||||
WHERE serial=%s""",
|
||||
(ip, port, subnet, gateway, mac, msgport, version, serial),
|
||||
)
|
||||
else:
|
||||
await cur.execute(
|
||||
"UPDATE dnt_info SET last_login=NOW(), state=1 WHERE serial=%s",
|
||||
(serial,),
|
||||
)
|
||||
return existing["id"]
|
||||
else:
|
||||
await cur.execute(
|
||||
"""INSERT INTO dnt_info
|
||||
(serial, ip, port, mac, subnet, gateway, msgport, version, dtype,
|
||||
last_login, state)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s, NOW(), 1)""",
|
||||
(serial, ip, port, mac, subnet, gateway, msgport, version, dtype),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
async def insert_test_result(dnt_id: int, dpg430_addr: int, pcnum: str,
|
||||
serialnum: int, sub_type: int, str_type: str,
|
||||
iffinish: str, fault_info: str, relay_out: str,
|
||||
ppvalue: float, idle_freq: float, enter_freq: float,
|
||||
exit_freq: float, enter_dist: int, exit_dist: int,
|
||||
enter_speed: int, exit_speed: int):
|
||||
"""插入测试结果到 tb_state_tst"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"""INSERT INTO tb_state_tst
|
||||
(dnt_id, dpg430_addr, pcnum, serialnum, sub_type, str_type,
|
||||
iffinish, fault_info, relay_out, ppvalue, idle_freq,
|
||||
enter_freq, exit_freq, enter_dist, exit_dist, enter_speed, exit_speed)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
|
||||
(dnt_id, dpg430_addr, pcnum, serialnum, sub_type, str_type,
|
||||
iffinish, fault_info, relay_out, ppvalue, idle_freq,
|
||||
enter_freq, exit_freq, enter_dist, exit_dist, enter_speed, exit_speed),
|
||||
)
|
||||
|
||||
|
||||
async def set_device_offline(serial: str):
|
||||
"""标记设备离线"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"UPDATE dnt_info SET state=0, last_off=NOW() WHERE serial=%s",
|
||||
(serial,),
|
||||
)
|
||||
97
src/protocol.py
Normal file
97
src/protocol.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""PGLC JSON 协议解析"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_message(data: bytes) -> dict | None:
|
||||
"""将收到的 JSON 字节解析为 dict,失败返回 None"""
|
||||
try:
|
||||
return json.loads(data.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
logger.warning(f"JSON 解析失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def is_count_off(msg: dict) -> bool:
|
||||
return msg.get("Method") == "Count_Off"
|
||||
|
||||
|
||||
def is_device_info(msg: dict) -> bool:
|
||||
return msg.get("Method") == "Device_Info"
|
||||
|
||||
|
||||
def is_heartbeat(msg: dict) -> bool:
|
||||
return msg.get("Method") == "Heartbeat"
|
||||
|
||||
|
||||
def is_timestamp(msg: dict) -> bool:
|
||||
return msg.get("Method") == "TimeStamp"
|
||||
|
||||
|
||||
def is_serial_net(msg: dict) -> bool:
|
||||
return msg.get("Method") == "SerialNet"
|
||||
|
||||
|
||||
def is_tsreport(msg: dict) -> bool:
|
||||
return msg.get("Method") == "TSReport"
|
||||
|
||||
|
||||
def is_dev_reset(msg: dict) -> bool:
|
||||
return msg.get("Method") == "Dev_Reset"
|
||||
|
||||
|
||||
# ─── 请求构造 ──────────────────────────────────────────────────────
|
||||
|
||||
def make_response(method: str, code: int, device_id: str,
|
||||
message: str = "success", data: dict | None = None) -> str:
|
||||
resp = {
|
||||
"Method": method,
|
||||
"Code": code,
|
||||
"Message": message,
|
||||
"Data": data or {"Device_id": device_id},
|
||||
}
|
||||
return json.dumps(resp, ensure_ascii=False)
|
||||
|
||||
|
||||
def make_timestamp_response(device_id: str, unix_time: int) -> str:
|
||||
return json.dumps({
|
||||
"Method": "TimeStamp",
|
||||
"Code": 0,
|
||||
"Message": "success",
|
||||
"Data": {
|
||||
"Device_id": device_id,
|
||||
"Time_Counter": unix_time,
|
||||
},
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
def make_heartbeat_response(device_id: str, ssc_time: int) -> str:
|
||||
return json.dumps({
|
||||
"Method": "Heartbeat",
|
||||
"Code": 0,
|
||||
"Message": "success",
|
||||
"Data": {
|
||||
"Device_id": device_id,
|
||||
"SSC_Time": ssc_time,
|
||||
},
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
def make_count_off_success() -> str:
|
||||
return json.dumps({
|
||||
"Method": "Count_Off",
|
||||
"Code": 0,
|
||||
"Message": "success",
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
def make_error_response(method: str, device_id: str, message: str) -> str:
|
||||
return json.dumps({
|
||||
"Method": method,
|
||||
"Code": -1,
|
||||
"Message": message,
|
||||
"Data": {"Device_id": device_id},
|
||||
}, ensure_ascii=False)
|
||||
193
src/server.py
Normal file
193
src/server.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""EDC 服务器主入口 — UDP + TCP 异步服务
|
||||
|
||||
UDP 端口 5500: 发现设备 (Count_Off)、心跳 (Heartbeat)、信息查询
|
||||
UDP 端口 5505: 消息监听
|
||||
TCP 端口 5550: 时间同步 (TimeStamp)、设备数据上报 (TSReport, SerialNet)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
|
||||
try:
|
||||
import uvloop # type: ignore
|
||||
except ImportError:
|
||||
uvloop = None
|
||||
|
||||
from src.config import (
|
||||
UDP_PORT, UDP_MSG_PORT, TCP_PORT, BIND_HOST, LOG_LEVEL,
|
||||
)
|
||||
from src.models import init_pool, close_pool
|
||||
from src.protocol import parse_message, make_error_response
|
||||
from src.handlers import (
|
||||
handle_count_off,
|
||||
handle_heartbeat,
|
||||
handle_timestamp,
|
||||
handle_tsreport,
|
||||
handle_serial_net,
|
||||
handle_device_info,
|
||||
parse_loop,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, LOG_LEVEL),
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("edc")
|
||||
|
||||
|
||||
class EDCProtocol:
|
||||
"""asyncio UDP 协议处理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.transport = None
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
asyncio.ensure_future(self._handle(data, addr))
|
||||
|
||||
async def _handle(self, data: bytes, addr: tuple):
|
||||
msg = parse_message(data)
|
||||
if msg is None:
|
||||
return
|
||||
|
||||
method = msg.get("Method", "")
|
||||
logger.debug(f"UDP {method} from {addr}")
|
||||
|
||||
try:
|
||||
response = None
|
||||
if method == "Count_Off":
|
||||
response = await handle_count_off(msg, addr)
|
||||
elif method == "Heartbeat":
|
||||
response = await handle_heartbeat(msg)
|
||||
elif method == "Device_Info":
|
||||
response = await handle_device_info(msg)
|
||||
elif method == "TSReport":
|
||||
await handle_tsreport(msg)
|
||||
elif method == "SerialNet":
|
||||
await handle_serial_net(msg)
|
||||
|
||||
if response and self.transport:
|
||||
self.transport.sendto(response.encode("utf-8"), addr)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理 {method} 异常: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def handle_tcp_client(reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter):
|
||||
"""TCP 客户端连接处理"""
|
||||
addr = writer.get_extra_info("peername")
|
||||
logger.info(f"TCP 连接: {addr}")
|
||||
|
||||
try:
|
||||
while True:
|
||||
line = await reader.readline()
|
||||
if not line:
|
||||
break
|
||||
|
||||
msg = parse_message(line)
|
||||
if msg is None:
|
||||
continue
|
||||
|
||||
method = msg.get("Method", "")
|
||||
logger.debug(f"TCP {method} from {addr}")
|
||||
|
||||
try:
|
||||
response = None
|
||||
if method == "TimeStamp":
|
||||
response = await handle_timestamp(msg)
|
||||
elif method == "TSReport":
|
||||
await handle_tsreport(msg)
|
||||
elif method == "SerialNet":
|
||||
await handle_serial_net(msg)
|
||||
elif method == "Heartbeat":
|
||||
response = await handle_heartbeat(msg)
|
||||
elif method == "Device_Info":
|
||||
response = await handle_device_info(msg)
|
||||
|
||||
if response:
|
||||
writer.write((response + "\n").encode("utf-8"))
|
||||
await writer.drain()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"TCP 处理 {method} 异常: {e}")
|
||||
device_id = msg.get("Device_id", "")
|
||||
err = make_error_response(method, device_id, str(e))
|
||||
writer.write((err + "\n").encode("utf-8"))
|
||||
await writer.drain()
|
||||
|
||||
except (ConnectionResetError, BrokenPipeError):
|
||||
pass
|
||||
finally:
|
||||
logger.info(f"TCP 断开: {addr}")
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
|
||||
|
||||
async def main():
|
||||
logger.info("EDC 服务启动中...")
|
||||
|
||||
# 初始化数据库
|
||||
await init_pool()
|
||||
|
||||
# 启动业务解析后台任务
|
||||
asyncio.create_task(parse_loop())
|
||||
|
||||
# 启动 UDP 服务 (端口 5500)
|
||||
loop = asyncio.get_running_loop()
|
||||
udp_transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: EDCProtocol(), # type: ignore[arg-type]
|
||||
local_addr=(BIND_HOST, UDP_PORT),
|
||||
)
|
||||
logger.info(f"UDP 服务监听 {BIND_HOST}:{UDP_PORT}")
|
||||
|
||||
# 启动 UDP 消息端口 (5505)
|
||||
udp_msg_transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: EDCProtocol(), # type: ignore[arg-type]
|
||||
local_addr=(BIND_HOST, UDP_MSG_PORT),
|
||||
)
|
||||
logger.info(f"UDP 消息监听 {BIND_HOST}:{UDP_MSG_PORT}")
|
||||
|
||||
# 启动 TCP 服务 (端口 5550)
|
||||
tcp_server = await asyncio.start_server(
|
||||
handle_tcp_client,
|
||||
BIND_HOST,
|
||||
TCP_PORT,
|
||||
)
|
||||
logger.info(f"TCP 服务监听 {BIND_HOST}:{TCP_PORT}")
|
||||
|
||||
# 优雅退出
|
||||
stop_event = asyncio.Event()
|
||||
|
||||
def _shutdown():
|
||||
logger.info("收到关闭信号,正在退出...")
|
||||
stop_event.set()
|
||||
|
||||
loop.add_signal_handler(signal.SIGINT, _shutdown)
|
||||
loop.add_signal_handler(signal.SIGTERM, _shutdown)
|
||||
|
||||
await stop_event.wait()
|
||||
|
||||
# 清理
|
||||
tcp_server.close()
|
||||
await tcp_server.wait_closed()
|
||||
udp_transport.close()
|
||||
udp_msg_transport.close()
|
||||
await close_pool()
|
||||
logger.info("EDC 服务已停止")
|
||||
|
||||
|
||||
def run():
|
||||
"""入口函数"""
|
||||
if uvloop:
|
||||
uvloop.install()
|
||||
logger.info("uvloop 已启用")
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in New Issue
Block a user