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:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user