feat: 完成 issue #25 ① 和利时 DCS OPC 接口参数化适配

This commit is contained in:
2026-08-04 23:34:07 +08:00
parent 8dc379c479
commit 50b51d4a27
5 changed files with 283 additions and 3 deletions
+14 -3
View File
@@ -10,13 +10,24 @@ collector:
cache_limit_bytes: 536870912 # 512MB
# 协议可插拔驱动插槽(protocol 注册表见 drivers/__init__.py)
drivers:
# 和利时 DCS —— OPC UA 只读
- protocol: opcua
# 和利时 DCS —— OPC UA 只读(推荐;支持用户认证参数化,issue #25)
- protocol: hollysys
device_prefixes: ["CLF"] # 设备编号前缀路由(CLF-01, CLF-02, ...)
config:
mode: ua # ua(OPC UA)| da(OPC DA COM 接口)
endpoint: "opc.tcp://10.20.1.10:4840"
security: "None"
security: "Basic256Sha256" # None | Basic256Sha256 等安全策略
username: "iaop_read" # 和利时 UA 服务用户认证(只读账号)
password: "******"
timeout: 10
# 和利时 DCS —— OPC DA 传统接口(无 UA 服务时使用;opcNode 列写 OPC Item)
# - protocol: hollysys
# device_prefixes: ["CLF"]
# config:
# mode: da
# host: "10.20.1.10" # DCOM 主机(需 Windows + OpenOPC)
# prog_id: "Hollysys.OPCServer" # 或 cls_id 指定 CLSID
# timeout: 10
# 西门子 S7-1200 —— PLC 只读(点位 opcNode 列写 DB 地址,如 DB100.0.0)
- protocol: s7
device_prefixes: ["S7"]
+3
View File
@@ -12,6 +12,7 @@ if TYPE_CHECKING: # pragma: no cover
from .base import Driver
from .energy_driver import EnergyDriver
from .hollysys_driver import HollysysDcsDriver
from .modbus_driver import ModbusDriver
from .opcua_driver import OpcUaDriver
from .s7_driver import S7Driver
@@ -20,6 +21,7 @@ from .weighing_driver import WeighingDriver
_DRIVER_REGISTRY: Dict[str, Type["Driver"]] = {
"opcua": OpcUaDriver,
"hollysys": HollysysDcsDriver,
"s7": S7Driver,
"modbus": ModbusDriver,
"weighing": WeighingDriver,
@@ -30,6 +32,7 @@ _DRIVER_REGISTRY: Dict[str, Type["Driver"]] = {
__all__ = [
"Driver",
"OpcUaDriver",
"HollysysDcsDriver",
"S7Driver",
"ModbusDriver",
"WeighingDriver",
@@ -0,0 +1,113 @@
# -*- coding: utf-8 -*-
"""和利时 DCS OPC 接口驱动 —— 参数化适配(issue #25)。
和利时 DCS(HOLLiAS-M / MACS 系列)对外数据接口常见两种形态,全部参数化:
- `mode: ua` —— OPC UA 接口(推荐):endpoint / security / username / password
均来自模板配置(gateway.yaml 的 drivers.hollysys 段),委托 OpcUaDriver
(含用户认证参数化,见 opcua_driver.py);
- `mode: da` —— OPC DA 传统 COM/DCOM 接口:host(DCOM 主机)/ prog_id
(如 "Hollysys.OPCServer")/ cls_id 参数化,点位 opcNode 列写 OPC Item 路径
(如 "CLF.Temp")。
安全约束(PRD 9 章):仅暴露只读 read_points(),无任何写/控制方法。
依赖(未安装时给出明确提示,不静默失败):
- ua 模式:`opcua`(同步)或 `asyncua`;
- da 模式:Windows 上 `OpenOPC`(依赖 pywin32)。
"""
from __future__ import annotations
from typing import Dict, List, Optional
from point_dict.loader import Point
from .base import Driver, SampleValue
from .opcua_driver import OpcUaDriver
class _HollysysDaDriver(Driver):
"""OPC DA(COM/DCOM)只读采集实现 —— 和利时传统接口。"""
protocol = "hollysys-da"
def __init__(self, config: Optional[dict] = None):
super().__init__(config)
self.host: str = self.config.get("host", "")
self.prog_id: str = self.config.get("prog_id", "Hollysys.OPCServer")
self.cls_id: str = self.config.get("cls_id", "")
self._client = None
def connect(self) -> None:
try:
import OpenOPC # type: ignore
except ImportError as exc: # pragma: no cover - 依赖缺失路径
raise ConnectionError(
"OPC DA 驱动依赖库未安装:请 `pip install OpenOPC`(Windows + pywin32)"
) from exc
if not self.host:
raise ConnectionError("OPC DA 驱动缺少配置: drivers.hollysys.host")
self._client = OpenOPC.client()
self._client.connect(self.prog_id, self.host)
# 可选:按 CLSID 连接(部分和利时 OPC 服务器无 ProgID 注册时使用)
if self.cls_id:
self._client.connect(self.cls_id, self.host)
def read_points(self, points: List[Point]) -> Dict[str, SampleValue]:
if self._client is None:
raise ConnectionError("OPC DA 客户端未连接,请先 connect()")
result: Dict[str, SampleValue] = {}
for p in points:
item = p.opc_node
if not item:
continue # 无 OPC Item 的点由其它驱动采集
try:
value = self._client.read(item)
# OpenOPC.read 返回 (value, quality, timestamp) 或裸值
if isinstance(value, (tuple, list)) and value:
value = value[0]
result[p.point_id] = value
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
class HollysysDcsDriver(Driver):
"""和利时 DCS OPC 接口只读采集驱动(参数化适配,issue #25)。
按 ``mode`` 参数路由到底层实现(与 energy_driver 的委托模式一致):
- ua:OPC UA(委托 :class:`OpcUaDriver`,支持 endpoint/security/认证);
- da:OPC DA COM 接口(委托 :class:`_HollysysDaDriver`)。
"""
protocol = "hollysys"
def __init__(self, config: Optional[dict] = None):
super().__init__(config)
self.mode: str = self.config.get("mode", "ua") # ua | da
self._delegate: Optional[Driver] = None
def connect(self) -> None:
if self.mode == "da":
self._delegate = _HollysysDaDriver(self.config)
else:
# ua 默认:和利时 OPC UA 服务,支持用户认证参数化
self._delegate = OpcUaDriver(self.config)
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:
try:
self._delegate.close()
finally:
self._delegate = None
+14
View File
@@ -22,6 +22,8 @@ class OpcUaDriver(Driver):
super().__init__(config)
self.endpoint: str = self.config.get("endpoint", "")
self.security: str = self.config.get("security", "None")
self.username: str = self.config.get("username", "")
self.password: str = self.config.get("password", "")
self._client = None
def connect(self) -> None:
@@ -34,7 +36,19 @@ class OpcUaDriver(Driver):
if not self.endpoint:
raise ConnectionError("OPC UA 驱动缺少配置: drivers.opcua.endpoint")
self._client = Client(self.endpoint, timeout=self.config.get("timeout", 10))
# 安全策略参数化(如 Basic256Sha256;证书/密钥由现场安全通道提供)
if self.security and self.security != "None":
self._client.set_security_string(self.security)
# 用户认证参数化(和利时 DCS 的 OPC UA 服务常启用用户名/密码认证)
if self.username:
self._client.set_user(self.username)
self._client.connect()
if self.username and self.password:
try:
self._client.activate_session(self.username, self.password)
except Exception:
# 部分服务端在 connect 时已完成认证;激活失败不阻断读取
pass
self._client.session_timeout = self.config.get("session_timeout", 60000)
def read_points(self, points: List[Point]) -> Dict[str, SampleValue]:
@@ -0,0 +1,139 @@
# -*- coding: utf-8 -*-
"""和利时 DCS OPC 驱动参数化适配测试(issue #25)。
覆盖:
1. 驱动注册表包含 hollysys(from_template 可实例化);
2. 参数化提取:ua 模式(endpoint/security/username/password)与
da 模式(host/prog_id/cls_id);
3. 只读约束:驱动无任何写/控制方法(PRD 9 章零控制指令下发);
4. 依赖缺失路径:UA/DA 底层库未安装时 connect 抛出明确 ConnectionError;
5. 委托路由:connect 后 read_points/close 委托到底层实现。
"""
import unittest
from unittest import mock
from drivers import HollysysDcsDriver, from_template
from drivers.base import Driver
from drivers.hollysys_driver import _HollysysDaDriver
from drivers.opcua_driver import OpcUaDriver
from point_dict.loader import Point
UA_CONFIG = {
"mode": "ua",
"endpoint": "opc.tcp://10.20.1.10:4840",
"security": "Basic256Sha256",
"username": "iaop_read",
"password": "secret",
"timeout": 10,
}
DA_CONFIG = {
"mode": "da",
"host": "10.20.1.10",
"prog_id": "Hollysys.OPCServer",
"timeout": 10,
}
def make_point(point_id="CLF-01.TEMP", opc_node="ns=2;s=CLF.Temp"):
return Point(
device_id="CLF-01", point_id=point_id, name="炉温", unit="℃",
data_type="float", sample_rate=1000, quality_code=True,
opc_node=opc_node, protocol="hollysys",
)
class TestRegistry(unittest.TestCase):
"""注册表与工厂。"""
def test_registry_contains_hollysys(self):
driver = from_template("hollysys", UA_CONFIG)
self.assertIsInstance(driver, HollysysDcsDriver)
def test_driver_is_read_only(self):
"""只读约束:驱动只暴露 connect/read_points/close,无写方法。"""
driver = HollysysDcsDriver(UA_CONFIG)
methods = {m for m in dir(driver) if not m.startswith("_")}
self.assertIn("read_points", methods)
self.assertNotIn("write", methods)
self.assertNotIn("write_points", methods)
self.assertNotIn("set_value", methods)
class TestParameterization(unittest.TestCase):
"""参数化提取。"""
def test_ua_params(self):
driver = HollysysDcsDriver(UA_CONFIG)
self.assertEqual(driver.mode, "ua")
ua = OpcUaDriver(UA_CONFIG)
self.assertEqual(ua.endpoint, "opc.tcp://10.20.1.10:4840")
self.assertEqual(ua.security, "Basic256Sha256")
self.assertEqual(ua.username, "iaop_read")
self.assertEqual(ua.password, "secret")
def test_da_params(self):
driver = HollysysDcsDriver(DA_CONFIG)
self.assertEqual(driver.mode, "da")
da = _HollysysDaDriver(DA_CONFIG)
self.assertEqual(da.host, "10.20.1.10")
self.assertEqual(da.prog_id, "Hollysys.OPCServer")
self.assertEqual(_HollysysDaDriver({}).prog_id,
"Hollysys.OPCServer") # 默认 ProgID
def test_default_mode_is_ua(self):
driver = HollysysDcsDriver({})
self.assertEqual(driver.mode, "ua")
class TestConnectDependencyErrors(unittest.TestCase):
"""底层库未安装时的明确报错(不静默失败)。"""
def test_ua_missing_dependency(self):
driver = HollysysDcsDriver(UA_CONFIG)
with mock.patch.dict("sys.modules", {"opcua": None}):
with self.assertRaises(ConnectionError) as ctx:
driver.connect()
self.assertIn("opcua", str(ctx.exception))
def test_da_missing_dependency(self):
driver = HollysysDcsDriver(DA_CONFIG)
with mock.patch.dict("sys.modules", {"OpenOPC": None}):
with self.assertRaises(ConnectionError) as ctx:
driver.connect()
self.assertIn("OpenOPC", str(ctx.exception))
class TestDelegation(unittest.TestCase):
"""connect 后 read_points/close 委托到底层实现。"""
def test_ua_delegates_to_opcua_driver(self):
driver = HollysysDcsDriver(UA_CONFIG)
with mock.patch("drivers.opcua_driver.OpcUaDriver.connect"):
driver.connect()
self.assertIsInstance(driver._delegate, OpcUaDriver)
point = make_point()
with mock.patch.object(driver._delegate, "read_points",
return_value={"CLF-01.TEMP": 850.5}) as rd:
values = driver.read_points([point])
rd.assert_called_once_with([point])
self.assertEqual(values["CLF-01.TEMP"], 850.5)
with mock.patch.object(driver._delegate, "close"):
driver.close()
self.assertIsNone(driver._delegate)
def test_da_delegates_to_da_driver(self):
driver = HollysysDcsDriver(DA_CONFIG)
fake_openopc = mock.MagicMock()
with mock.patch.dict("sys.modules", {"OpenOPC": fake_openopc}):
with mock.patch.object(_HollysysDaDriver, "connect"):
driver.connect()
self.assertIsInstance(driver._delegate, _HollysysDaDriver)
def test_read_before_connect_raises(self):
driver = HollysysDcsDriver(UA_CONFIG)
with self.assertRaises(ConnectionError):
driver.read_points([make_point()])
if __name__ == "__main__":
unittest.main()