feat: 完成 issue #3 边缘采集网关模板化封装

- 点位字典 CSV schema/加载/自动校验(缺失字段/量纲/重复点号,PRD 5.1)
- 协议可插拔只读驱动:OPC UA(S7-1200 适配)/S7/Modbus/称重/能源/模拟
- 周期采集引擎:只读+背压保护+健康度指标(丢失率/P99/可用性)
- Kafka 流式上行 + 本地 spool 断点续传(丢失率≤0.02% 保障)
- 模板配置外置(gateway.yaml + 点位字典 CSV),换行业零改码
- 18 个单元测试全绿;端到端运行 SLA 达标
This commit is contained in:
2026-08-04 15:32:16 +08:00
parent 21c6259739
commit f49c0920d4
27 changed files with 1806 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
"""协议可插拔驱动注册表 —— 模板化封装的协议插槽。
新增现场协议:实现 `base.Driver` 子类后在此注册,
引擎 / spool / Kafka 上行链路零改动(满足 PRD“协议可插拔”能力点)。
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Dict, Optional, Type
if TYPE_CHECKING: # pragma: no cover
from .base import Driver
from .energy_driver import EnergyDriver
from .modbus_driver import ModbusDriver
from .opcua_driver import OpcUaDriver
from .s7_driver import S7Driver
from .simulator_driver import SimulatorDriver
from .weighing_driver import WeighingDriver
_DRIVER_REGISTRY: Dict[str, Type["Driver"]] = {
"opcua": OpcUaDriver,
"s7": S7Driver,
"modbus": ModbusDriver,
"weighing": WeighingDriver,
"energy": EnergyDriver,
"simulator": SimulatorDriver,
}
__all__ = [
"Driver",
"OpcUaDriver",
"S7Driver",
"ModbusDriver",
"WeighingDriver",
"EnergyDriver",
"SimulatorDriver",
"from_template",
]
def from_template(protocol: str, config: Optional[dict] = None) -> "Driver":
"""便捷入口:按协议名实例化驱动。"""
from .base import Driver
if protocol not in _DRIVER_REGISTRY:
raise KeyError(
f"未注册的采集协议: '{protocol}',已注册: {sorted(_DRIVER_REGISTRY)}"
)
return _DRIVER_REGISTRY[protocol](config)
+59
View File
@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""采集驱动抽象基类 —— 模板化封装的协议插槽。
安全约束(PRD 9 章:边缘网关严格只读、零控制指令下发):
- 驱动仅暴露只读接口 `read_points()`,没有任何写/控制方法;
- 引擎只依赖本抽象,具体协议由子类实现,新增协议不改引擎代码。
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Dict, List, Optional, Union
from point_dict.loader import Point
SampleValue = Union[float, int, bool, None]
class Driver(ABC):
"""只读采集驱动基类。"""
protocol: str = "base" # 协议名:opcua / s7 / modbus / weighing / energy / simulator
def __init__(self, config: Optional[dict] = None):
self.config = config or {}
@abstractmethod
def connect(self) -> None:
"""建立连接(幂等)。失败应抛出 ConnectionError 供引擎重试。"""
@abstractmethod
def read_points(self, points: List[Point]) -> Dict[str, SampleValue]:
"""批量读取测点值(只读)。
Args:
points: 本驱动负责的测点列表。
Returns:
{point_id: value};读失败的点可返回 None 或省略,
由引擎按“未读到”计入丢失率。
"""
@abstractmethod
def close(self) -> None:
"""关闭连接,释放资源。"""
# ------------------------------------------------------------------
# 模板化辅助
# ------------------------------------------------------------------
@classmethod
def from_template(cls, protocol: str, config: Optional[dict] = None) -> "Driver":
"""按模板配置实例化驱动(协议 → 实现类注册表)。"""
from . import _DRIVER_REGISTRY
if protocol not in _DRIVER_REGISTRY:
raise KeyError(
f"未注册的采集协议: '{protocol}',已注册: {sorted(_DRIVER_REGISTRY)}。"
f"如需支持新协议,实现 Driver 子类并注册。"
)
return _DRIVER_REGISTRY[protocol](config)
@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""能源表驱动 —— 电表/水表/气表等能源计量设备参数化适配。
能源表多支持 Modbus(DL/T 645 网关转 Modbus)或 DL/T 645 串口规约;
模板化封装与称重驱动一致:默认走 Modbus 参数化实现,
`mode: dlt645` 时需现场规约文档定制子类。
"""
from __future__ import annotations
from typing import Dict, List, Optional
from point_dict.loader import Point
from .base import Driver, SampleValue
from .modbus_driver import ModbusDriver
class EnergyDriver(Driver):
"""能源表只读采集驱动(参数化适配)。"""
protocol = "energy"
def __init__(self, config: Optional[dict] = None):
super().__init__(config)
self.mode: str = self.config.get("mode", "modbus")
self._delegate: Optional[Driver] = None
def connect(self) -> None:
if self.mode == "modbus":
self._delegate = ModbusDriver(self.config)
else:
raise ConnectionError(
"能源驱动非 modbus 模式(如 DL/T 645)需要现场规约文档支持,请实现子类"
)
self._delegate.connect()
def read_points(self, points: List[Point]) -> Dict[str, SampleValue]:
if self._delegate is None:
raise ConnectionError("能源驱动未连接,请先 connect()")
return self._delegate.read_points(points)
def close(self) -> None:
if self._delegate is not None:
self._delegate.close()
self._delegate = None
@@ -0,0 +1,79 @@
# -*- coding: utf-8 -*-
"""Modbus 驱动 —— PLC / 称重仪表等 RTU/TCP 从站参数化适配。
连接参数(mode / host / port / slave_id / register_map)来自模板配置;
寄存器映射约定写入点位字典 opcNode 列,格式 `{type}:{addr}`,
type ∈ holding|input|coil|discrete,如 `holding:40001`。
依赖 `pymodbus`,未安装时给出明确提示。
"""
from __future__ import annotations
from typing import Dict, List, Optional
from point_dict.loader import Point
from .base import Driver, SampleValue
_REG_MAP = {"holding": 3, "input": 4, "coil": 1, "discrete": 2}
class ModbusDriver(Driver):
"""Modbus 只读采集驱动(参数化适配)。"""
protocol = "modbus"
def __init__(self, config: Optional[dict] = None):
super().__init__(config)
self.mode: str = self.config.get("mode", "tcp") # tcp | rtu
self.host: str = self.config.get("host", "")
self.port: int = int(self.config.get("port", 502))
self.slave_id: int = int(self.config.get("slave_id", 1))
self._client = None
def connect(self) -> None:
try:
from pymodbus.client import ModbusTcpClient, ModbusSerialClient # type: ignore
except ImportError as exc: # pragma: no cover - 依赖缺失路径
raise ConnectionError(
"Modbus 驱动依赖库未安装:请 `pip install pymodbus`"
) from exc
if self.mode == "tcp":
if not self.host:
raise ConnectionError("Modbus 驱动缺少配置: drivers.modbus.host")
self._client = ModbusTcpClient(self.host, port=self.port)
else:
self._client = ModbusSerialClient(
port=self.config.get("port", "COM1"),
baudrate=self.config.get("baudrate", 9600),
)
if not self._client.connect():
raise ConnectionError(f"Modbus 连接失败: {self.host or self.config.get('port')}")
def read_points(self, points: List[Point]) -> Dict[str, SampleValue]:
if self._client is None:
raise ConnectionError("Modbus 客户端未连接,请先 connect()")
result: Dict[str, SampleValue] = {}
for p in points:
node = (p.opc_node or "").split(":")
if len(node) != 2 or node[0] not in _REG_MAP:
continue
reg_type, addr = node[0], int(node[1])
try:
if reg_type == "coil":
resp = self._client.read_coils(addr, count=1, slave=self.slave_id)
elif reg_type == "discrete":
resp = self._client.read_discrete_inputs(addr, count=1, slave=self.slave_id)
else:
resp = self._client.read_holding_registers(addr, count=1, slave=self.slave_id) \
if reg_type == "holding" else \
self._client.read_input_registers(addr, count=1, slave=self.slave_id)
result[p.point_id] = resp.registers[0] if hasattr(resp, "registers") and resp.registers else None
except Exception:
result[p.point_id] = None
return result
def close(self) -> None:
if self._client is not None:
try:
self._client.close()
finally:
self._client = None
+61
View File
@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
"""OPC UA 驱动 —— 和利时 DCS 等 OPC UA 接口参数化适配(issue #25 落点)。
连接参数全部来自模板配置(gateway.yaml 的 drivers.opcua 段),不硬编码。
实际设备接入依赖第三方库 `asyncua`(异步)或 `opcua`(同步);
未安装时给出明确提示,不会静默失败。
"""
from __future__ import annotations
from typing import Dict, List, Optional
from point_dict.loader import Point
from .base import Driver, SampleValue
class OpcUaDriver(Driver):
"""OPC UA 只读采集驱动(参数化适配)。"""
protocol = "opcua"
def __init__(self, config: Optional[dict] = None):
super().__init__(config)
self.endpoint: str = self.config.get("endpoint", "")
self.security: str = self.config.get("security", "None")
self._client = None
def connect(self) -> None:
try:
from opcua import Client # type: ignore
except ImportError as exc: # pragma: no cover - 依赖缺失路径
raise ConnectionError(
"OPC UA 驱动依赖库未安装:请 `pip install opcua`(或 asyncua)"
) from exc
if not self.endpoint:
raise ConnectionError("OPC UA 驱动缺少配置: drivers.opcua.endpoint")
self._client = Client(self.endpoint, timeout=self.config.get("timeout", 10))
self._client.connect()
self._client.session_timeout = self.config.get("session_timeout", 60000)
def read_points(self, points: List[Point]) -> Dict[str, SampleValue]:
if self._client is None:
raise ConnectionError("OPC UA 客户端未连接,请先 connect()")
result: Dict[str, SampleValue] = {}
for p in points:
node_path = p.opc_node
if not node_path:
continue # 无 OPC 节点的点由其它驱动采集
try:
node = self._client.get_node(node_path)
result[p.point_id] = node.get_value()
except Exception:
# 单点读取失败不中断整批:记为未读到(计入丢失率)
result[p.point_id] = None
return result
def close(self) -> None:
if self._client is not None:
try:
self._client.disconnect()
finally:
self._client = None
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""西门子 S7-1200 驱动 —— 参数化适配(issue #24 落点)。
连接参数(ip / rack / slot / db / offset 映射)全部来自模板配置,
点位到 DB 地址的映射通过点位字典 CSV 的 opcNode 列携带
(S7 场景下约定格式 `DB{db}.{byte}.{bit}`,如 DB100.0.0)。
依赖 `python-snap7`,未安装时给出明确提示。
"""
from __future__ import annotations
import re
from typing import Dict, List, Optional
from point_dict.loader import Point
from .base import Driver, SampleValue
_S7_NODE_RE = re.compile(r"^DB(\d+)\.(\d+)(?:\.(\d+))?$")
class S7Driver(Driver):
"""西门子 S7-1200 只读采集驱动(参数化适配)。"""
protocol = "s7"
def __init__(self, config: Optional[dict] = None):
super().__init__(config)
self.ip: str = self.config.get("ip", "")
self.rack: int = int(self.config.get("rack", 0))
self.slot: int = int(self.config.get("slot", 1))
self._client = None
def connect(self) -> None:
try:
import snap7 # type: ignore
except ImportError as exc: # pragma: no cover - 依赖缺失路径
raise ConnectionError(
"S7 驱动依赖库未安装:请 `pip install python-snap7`"
) from exc
if not self.ip:
raise ConnectionError("S7 驱动缺少配置: drivers.s7.ip")
self._client = snap7.client.Client()
self._client.connect(self.ip, self.rack, self.slot)
def read_points(self, points: List[Point]) -> Dict[str, SampleValue]:
if self._client is None:
raise ConnectionError("S7 客户端未连接,请先 connect()")
result: Dict[str, SampleValue] = {}
for p in points:
match = _S7_NODE_RE.match(p.opc_node or "")
if not match:
continue # 非 S7 点位由其它驱动采集
db, byte_, bit = int(match.group(1)), int(match.group(2)), match.group(3)
try:
if bit is not None:
result[p.point_id] = self._client.read_area(
0x84, db, byte_ * 8 + int(bit), 1
)[0]
else:
result[p.point_id] = self._client.read_area(0x84, db, byte_, 4)[0]
except Exception:
result[p.point_id] = None # 单点失败不中断整批
return result
def close(self) -> None:
if self._client is not None:
try:
self._client.disconnect()
finally:
self._client = None
@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
"""模拟采集驱动 —— 本地演示/联调/压测用(模板化封装的调试插槽)。
无任何现场依赖:按点位生成确定性伪随机值,可用于:
- 无设备环境下的端到端联调(采集→spool→Kafka);
- 600 点位 1Hz 压测口径的本地基准验证(验收 P99 ≤ 1.8s 参考)。
"""
from __future__ import annotations
import random
from typing import Dict, List, Optional
from point_dict.loader import Point
from .base import Driver, SampleValue
class SimulatorDriver(Driver):
"""模拟只读采集驱动(仅本地调试,不接入现场)。"""
protocol = "simulator"
def __init__(self, config: Optional[dict] = None):
super().__init__(config)
self.seed = int(self.config.get("seed", 2026))
self._rng = random.Random(self.seed)
self._base_values: Dict[str, float] = {}
def connect(self) -> None:
# 模拟驱动无需真实连接;调用 connect 即为就绪
return None
def read_points(self, points: List[Point]) -> Dict[str, SampleValue]:
result: Dict[str, SampleValue] = {}
for p in points:
base = self._base_values.setdefault(p.point_id, self._rng.uniform(10.0, 90.0))
# 小步随机游走,模拟现场量测波动
result[p.point_id] = round(base + self._rng.uniform(-0.5, 0.5), 3)
return result
def close(self) -> None:
return None
@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
"""称重终端驱动 —— 化工/树脂行业称重仪参数化适配。
多数称重仪表走串口(连续输出 / 命令应答)或 Modbus TCP;
本驱动作为模板封装:优先按配置走 Modbus(复用 ModbusDriver),
若配置指定 `mode: serial` 则走串口协议(需现场协议文档,由子类定制)。
"""
from __future__ import annotations
from typing import Dict, List, Optional
from point_dict.loader import Point
from .base import Driver, SampleValue
from .modbus_driver import ModbusDriver
class WeighingDriver(Driver):
"""称重终端只读采集驱动(参数化适配)。"""
protocol = "weighing"
def __init__(self, config: Optional[dict] = None):
super().__init__(config)
self.mode: str = self.config.get("mode", "modbus") # modbus | serial
self._delegate: Optional[Driver] = None
def connect(self) -> None:
if self.mode == "modbus":
# 称重仪表普遍支持 Modbus RTU/TCP,复用 ModbusDriver 参数化实现
self._delegate = ModbusDriver(self.config)
else:
raise ConnectionError(
"称重驱动 serial 模式需要现场协议文档支持,请实现子类或改用 modbus 模式"
)
self._delegate.connect()
def read_points(self, points: List[Point]) -> Dict[str, SampleValue]:
if self._delegate is None:
raise ConnectionError("称重驱动未连接,请先 connect()")
return self._delegate.read_points(points)
def close(self) -> None:
if self._delegate is not None:
self._delegate.close()
self._delegate = None