之前总是听说OPC协议,一直没有接触,直到最近项目需要对接OPC DA2.0,才开始了解这个协议,并且才知道这是一个有历史、有深度的坑啊!网络上零零散散有很多的资料,但是没有跑通整个流程的文章,坑更是出奇的多,这次把其中碰到的坑以及跑通整个过程的详细流程记录下来。希望能帮助更多初次接触这个协议的勇者!
Component Object Model对象组件模型,是微软定义的一套软件的二进制接口,可以实现跨编程语言的进程间通信,进而实现复用。
Microsoft Distributed Component Object Model,坑最多的一个玩意。字面意思看起来是分布式的COM,简单理解就是可以利用网络传输数据的COM协议,客户端也可以通过互联网分布在各个角落,不再限制在同一台主机上了。
上面描述来看这玩意好像挺美好是吧?实际操作开发中才发现,这玩意简直是坑王之王,对于不熟悉的人来说充满了坑,十分折腾。
市场上的网络防火墙一般有3种模式:
如果是遇到第1种模式NAT(网络地址转换)模式,建议你选择放弃,采用OPC隧道组件类产品(如Matrikon的OPC Tunneller,Kepware的LinkMaster等)来穿透防火墙吧。
如果遇到是第2种和第三种模式,那么明确的告诉你,是完全可以通讯,与同一子网的通讯来说,你只需要在防火墙中将这两台计算机的相互访问放开即可,一般的做法是,先在防火墙中让相互通讯的两台计算机完全无限制,然后子啊根据通讯所需的协议和端口定义规则,只放行通讯所需的协议和端口,其他禁止。OPC需要TCP协议的135端口和OPC Server监听的端口,默认情况下,OPC Server的端口是动态的,因此通过Dcomcnfg去固定OPC Server的端口号,在配置面板的终结点中选择使用静态终结点并定义端口号。
OPC DA 2.0可以使用两个开源类库:
JEasyOPC Client
底层依赖JNI,只能跑在windows环境,不能跨平台
整个类库比较古老,使用的dll是32位的,整个项目只能使用32位的JRE运行
同时支持DA 2.0与3.0协议,算是亮点
Utgard
OpenSCADA项目底下的子项目
纯Java编写,具有跨平台特性
全部基于DCOM实现(划重点)
目前只支持DA 2.0协议,且已经不再维护!
介于目前大部分生产环境使用的都是Linux系统,所以本次选择使用Utgard的方式。
生产环境有OPC服务,为了本地调试所以需要安装模拟器,经过查找使用比较多的是:KepServer。但是尽量是用于生产环境一致的软件版本进行测试,虽然交互基本一致,但是结构还是会有些许差别。比如我这里调试使用的是:NETx KNX OPC Server 3.5,此软件是用来对接照明的KNX 协议,所以还需要模拟KNX,使用到软件:KNX Virtual (下载需要注册),需要安装包的勇士可以留言~
先说坑,起初直接在本机安装的服务,结果使用Client连接一直报错!!:
Access is denied, please check whether the [domain-username-password] are correct. Also, if not already done please check the GETTING STARTED and FAQ sections in readme.htm. They provide information on how to correctly configure the Windows machine for DCOM access, so as to avoid such exceptions. [0x00000005]
尝试了各种方案,结果都不好使,搞了大半天,DCOM这个坑确实深。后来无奈直接安装了虚拟机使用操作系统重新来一遍,结果一遍过!不敢相信,所以此处建议安装虚拟机进行调试。
虚拟机及操作系统安装就不详述了,自行查询很简单。KEP的安装一直下一步即可,详细使用可查看:OPCServer:使用KEPServer,主要界面使用精简为以下三张图:



参考文章:OPC和DCOM配置,在虚拟机环境,我是一遍过!
启动IDE,创建项目。连接需要使用到OPC服务的CLSID,可直接通过windows查看,后面给到更简单的方式,前提需要配通DCOM。
windows查看的方法:
打开注册表,在 \HKEY_CLASSES_ROOT\Kepware.KEPServerEX.V6\CLSID ,中可查看到。
增加引用,其中存在报错的依赖,已解决:
<dependency>
<groupId>org.openscada.utgardgroupId>
<artifactId>org.openscada.opc.dcomartifactId>
<version>1.5.0version>
<exclusions>
<exclusion>
<groupId>org.bouncycastlegroupId>
<artifactId>bcprov-jdk15onartifactId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>org.openscada.utgardgroupId>
<artifactId>org.openscada.opc.libartifactId>
<version>1.5.0version>
<exclusions>
<exclusion>
<groupId>org.bouncycastlegroupId>
<artifactId>bcprov-jdk15onartifactId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>org.bouncycastlegroupId>
<artifactId>bcprov-jdk15onartifactId>
<version>1.70version>
dependency>
配置:
opc:
enable: ${OPC_ENABLE:true}
host: ${OPC_HOST:192.168.65.130}
user: ${OPC_USER:OPCUser}
domain: ${OPC_DOMAIN:}
password: ${OPC_PASSWORD:Abc123}
cls-id: ${OPC_CLS_ID:aaeef077-f162-4a1f-ad88-c37f35ea4035} #7BC0CC8E-482C-47CA-ABDC-0FE7F9C6E729}
prog-id: ${OPC_PROG_ID:NETxKNX.OPC.Server.3.5} #Kepware.KEPServerEX.V6}
说明:
7BC0CC8E-482C-47CA-ABDC-0FE7F9C6E729,PROGID:Kepware.KEPServerEX.V6aaeef077-f162-4a1f-ad88-c37f35ea4035,PROGID:NETxKNX.OPC.Server.3.5CLSID是指windows系统对于不同的应用程序,文件类型,OLE对象,特殊文件夹以及各种系统组件分配一个唯一表示它的ID代码,用于对其身份的标示和与其他对象进行区分。
先得说下GUID,它是Globally Unique Identifier的简称,中文翻译为“全局唯一标示符”,在Windows系统中也称之为Class ID,缩写为CLSID。
CLSID像人身份证一样,是个类的唯一标识:
ID是英文IDentity的缩写,是身份标识号码的意思,就是一个序列号,也叫帐号,是一个编码,而且是唯一的。
class是对某种类型的对象定义变量和方法的原型,是ID的样式或属性的补充。
后续介绍通过连接代码查看的方式。
ServerList serverList = new ServerList(host, userName, password, domain);
Collection<ClassDetails> classDetails = serverList.listServersWithDetails(new Category[]{Categories.OPCDAServer20}, new Category[]{});
System.out.println("在目标主机上发现如下OPC服务器:");
for (ClassDetails details : classDetails) {
System.out.format("\tprogId: '%s' \r\n\tclsId:'%s' \r\n\tdescription:'%s' \r\n\r\n", details.getProgId(), details.getClsId(), details.getClsId());
}
ScheduledExecutorService threadPool = new ScheduledThreadPoolExecutor(5, r -> {
Thread thread = new Thread(r);
thread.setDaemon(true);
thread.setName("客户端-任务-" + thread.getId());
thread.setUncaughtExceptionHandler((Thread t, Throwable e) -> {
log.error("'{}'发生异常!", t.getName(), e);
});
return thread;
});
ConnectionInformation connInfo = new ConnectionInformation();
connInfo.setHost(opcDaConfig.getHost());
if (Objects.nonNull(opcDaConfig.getDomain())) {
connInfo.setDomain(opcDaConfig.getDomain());
}
connInfo.setUser(opcDaConfig.getUser());
connInfo.setPassword(opcDaConfig.getPassword());
connInfo.setClsid(opcDaConfig.getClsId());
if (Objects.nonNull(opcDaConfig.getProgId())) {
connInfo.setProgId(opcDaConfig.getProgId());
}
Server server = new Server(connInfo, scheduledExecutorService);
ServerConnectionStateListener listener = connected -> {
// 建立连接后,若断开,设置无限次10s尝试连接
if (!connected) {
reconnectFuture = scheduledExecutorService.scheduleAtFixedRate(() -> {
try {
server.connect();
} catch (Exception e) {
log.error("监听到连接断开,尝试重连失败!");
}
}, 0, 10, TimeUnit.SECONDS);
} else {
if (Objects.nonNull(reconnectFuture)) {
reconnectFuture.cancel(true);
}
}
};
server.connect();
long connectTime = 10;
while (connectTime-- > 0) {
if (server != null && server.getServerState() != null && server.getServerState().getServerState() != null) {
server.addStateListener(listener);
return server;
} else {
TimeUnit.SECONDS.sleep(2);
log.warn("尝试重新连接!");
}
}
public void dumpTree(Server server, int level) throws JIException, UnknownHostException {
Branch branch = server.getTreeBrowser().browse();
dumpTree(branch, level);
}
private void dumpTree(Branch branch, int level) {
for (final Leaf leaf : branch.getLeaves()) {
System.out.println(printTab(level) + "Leaf: " + leaf.getName() + ":"
+ leaf.getItemId());
}
for (final Branch subBranch : branch.getBranches()) {
System.out.println(printTab(level) + "Branch: " + branch.getName());
dumpTree(subBranch, level + 1);
}
}
private String printTab(int level) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < level; i++) {
sb.append("\t");
}
return sb.toString();
}
JiVariantUtil.java
public static DataItem parseValue(String itemId, ItemState itemState) throws Exception {
Map<String, Object> value = getValue(itemState.getValue());
return new DataItem(
itemId,
value.get("type").toString(),
value.get("value"),
itemState.getQuality(),
itemState.getTimestamp().getTime(),
DateUtil.getRecentMoment()
);
}
public static Map<String, Object> getValue(JIVariant jiVariant) throws Exception {
Object newValue;
Object oldValue = jiVariant.getObject();
String typeName = oldValue.getClass().getTypeName();
if (typeName.startsWith(BASE_TYPE_PRDFIX) || typeName.startsWith(DATE_TYPE_PRDFIX)) {
newValue = jiVariant.getObject();
} else if (oldValue instanceof JIArray) {
newValue = jiVariant.getObjectAsArray();
} else if (oldValue instanceof IJIUnsigned) {
newValue = jiVariant.getObjectAsUnsigned().getValue();
} else if (oldValue instanceof IJIComObject) {
newValue = jiVariant.getObjectAsComObject();
} else if (oldValue instanceof JIString) {
newValue = jiVariant.getObjectAsString().getString();
} else if (oldValue instanceof JIVariant) {
newValue = jiVariant.getObjectAsVariant();
} else {
newValue = oldValue;
log.error("无法解析服务器的数据类型'{}'!原始数据:{}", typeName, oldValue.toString());
}
//newValue = getVal(jiVariant);
HashMap<String, Object> result = new HashMap<>(2);
result.put("type", newValue.getClass().getSimpleName());
result.put("value", newValue);
return result;
}
同步读:
// NETX测试例子
List<String> itemIds = List.of("\\NETxKNX\\BROADCAST\\01/0/000");
Group group = server.addGroup();
Map<String, Item> itemMap = group.addItems(itemIds.toArray(new String[0]));
List<DataItem> result = new ArrayList<>();
for (Map.Entry<String, Item> entry : itemMap.entrySet()) {
Item item = entry.getValue();
ItemState itemState = item.read(true);
DataItem dataItem = JiVariantUtil.parseValue(item.getId(), itemState);
result.add(dataItem);
}
System.out.println(result);
// KEP服务测试例子
String itemId = "通道 1.设备 1.TAG1";
Group group = server.addGroup();
Item item = group.addItem(itemId);
JIVariant jiVariant = new JIVariant(value);
item.write(jiVariant);
// NETX测试例子
List<String> itemIds = List.of("\\NETxKNX\\BROADCAST\\01/0/000","\\NETxKNX\\BROADCAST\\07/0/000","xxx");
// 持续监听多少s,如果为0,
Integer listeningDurationSeconds = 0;
// 启动一个同步的access用来读取地址上的值,线程池每1000ms读值一次
AccessBase access = new Async20Access(server, 1000, false);
for (String itemId : itemIds) {
// 有变化后的回调地址
access.addItem(itemId, new SubscribeDataCallback());
}
// 开始监听
access.bind();
if (listeningDurationSeconds == 0) {
// countDownLatch.await();
} else {
Thread.sleep(listeningDurationSeconds * 1000);
// 结束监听
access.unbind();
}
回调类:
public class SubscribeDataCallback implements DataCallback {
@Override
public void changed(Item item, ItemState itemState) {
log.info("OPC-订阅数据变化,itemId:{},value:{}", item.getId(), itemState);
// TODO::业务逻辑
}
}
KEPServer中文官方文档
OPC在网络防火墙环境下的配置
OPC通讯协议解析-OPC七问
Java OPC client开发踩坑记
JAVA对接OPC协议-Utgard
Kepware软件基本操作及使用Java Utgard实现OPC通信
github opcclient demo
github opc_client
How to Configure the Firewall to Allow DCOM Connections
HowToStartWithUtgard