@tier0/node-red-contrib-opcda-client 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OPCDA_Bridge_Setup_Guide.md +132 -0
- package/README.md +9 -16
- package/docker-compose.yml +460 -0
- package/opcda/opcda-read.js +34 -7
- package/opcda/opcda-server.html +63 -15
- package/opcda/opcda-server.js +94 -22
- package/opctest.py +214 -0
- package/package.json +9 -6
- package/patch-debug.js +13 -0
- package/patch-ntlm.js +46 -0
- package/patch-pwdcheck.js +17 -0
- package/test-connect.js +69 -0
- package/test-connect2.js +30 -0
- package/test-direct.js +53 -0
- package/test-ntlm-vectors.js +100 -0
- package/test-ntlm-verify.js +77 -0
- package/uns_import.json +204 -0
package/opcda/opcda-server.html
CHANGED
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
defaults: {
|
|
51
51
|
name: {value: ""},
|
|
52
52
|
address: {value: "", required: true},
|
|
53
|
-
domain: {value: ""
|
|
53
|
+
domain: {value: ""},
|
|
54
54
|
clsid: {value: "", required: true},
|
|
55
55
|
timeout: {value: 5000},
|
|
56
56
|
},
|
|
@@ -62,16 +62,41 @@
|
|
|
62
62
|
return this.name||"tier0-opcda-server";
|
|
63
63
|
},
|
|
64
64
|
oneditprepare: function () {
|
|
65
|
+
let self = this;
|
|
66
|
+
/**
|
|
67
|
+
* Admin route RED.httpAdmin.get('/opcda/browse').
|
|
68
|
+
* Tier0 + Kong: public path is /nodered/home/... — must NOT request bare /opcda/browse (hits wrong service → HTML 200).
|
|
69
|
+
* Node-RED sets RED.settings.apiRootUrl for proxy subpaths; use it first.
|
|
70
|
+
*/
|
|
71
|
+
function browseApiUrl() {
|
|
72
|
+
var api = RED.settings && RED.settings.apiRootUrl;
|
|
73
|
+
if (api) {
|
|
74
|
+
var u = String(api);
|
|
75
|
+
if (!/\/$/.test(u)) {
|
|
76
|
+
u += '/';
|
|
77
|
+
}
|
|
78
|
+
return u + 'tier0-opcda/browse';
|
|
79
|
+
}
|
|
80
|
+
var s = RED.settings && RED.settings.httpAdminRoot;
|
|
81
|
+
if (s != null && String(s).trim() !== '' && String(s) !== '/') {
|
|
82
|
+
var root = String(s).replace(/\/+$/, '');
|
|
83
|
+
return (root ? root : '') + '/tier0-opcda/browse';
|
|
84
|
+
}
|
|
85
|
+
var p = window.location.pathname || '';
|
|
86
|
+
p = p.replace(/\/+$/, '');
|
|
87
|
+
if (p && p !== '/') {
|
|
88
|
+
return (p + '/tier0-opcda/browse').replace(/\/+/g, '/');
|
|
89
|
+
}
|
|
90
|
+
return '/tier0-opcda/browse';
|
|
91
|
+
}
|
|
65
92
|
let browseBtn = $('#node-config-btn-item-browse');
|
|
66
93
|
let browseBtnIcon = browseBtn.children('i');
|
|
67
94
|
let exportBtn = $('#node-config-btn-item-export');
|
|
68
95
|
let exportBtnIcon = exportBtn.children('i');
|
|
69
96
|
let alertArea = $('#node-config-alert');
|
|
70
97
|
let itemList = $('#node-config-item-list');
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
$("#node-config-input-timeout").spinner().val(5000);
|
|
98
|
+
|
|
99
|
+
$("#node-config-input-timeout").spinner().val(self.timeout != null ? self.timeout : 5000);
|
|
75
100
|
|
|
76
101
|
itemList.hide();
|
|
77
102
|
alertArea.hide();
|
|
@@ -88,6 +113,7 @@
|
|
|
88
113
|
exportBtn.addClass('disabled').attr('disabled', 'disabled');
|
|
89
114
|
|
|
90
115
|
var queryData = {
|
|
116
|
+
id: self.id,
|
|
91
117
|
address: $('#node-config-input-address').val(),
|
|
92
118
|
domain: $('#node-config-input-domain').val(),
|
|
93
119
|
username: $('#node-config-input-username').val(),
|
|
@@ -95,23 +121,45 @@
|
|
|
95
121
|
clsid: $('#node-config-input-clsid').val(),
|
|
96
122
|
timeout: $('#node-config-input-timeout').val()
|
|
97
123
|
};
|
|
98
|
-
|
|
99
|
-
console.log(queryData);
|
|
100
124
|
|
|
101
|
-
$.
|
|
102
|
-
|
|
103
|
-
|
|
125
|
+
$.ajax({
|
|
126
|
+
url: browseApiUrl(),
|
|
127
|
+
method: 'GET',
|
|
128
|
+
data: queryData,
|
|
129
|
+
dataType: 'json'
|
|
130
|
+
})
|
|
131
|
+
.done(function( data /*, status, jqXHR */ ) {
|
|
132
|
+
var items = data && data.items;
|
|
133
|
+
if (!Array.isArray(items)) {
|
|
134
|
+
alertArea.text('Browse returned an invalid response (expected JSON { items: [...] }). See Node-RED log.');
|
|
135
|
+
itemList.empty().hide();
|
|
136
|
+
exportBtn.addClass('disabled').attr('disabled', 'disabled');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
alertArea.text(items.length + ' items found');
|
|
104
140
|
itemList.empty();
|
|
105
|
-
$.each(
|
|
141
|
+
$.each(items, function(i, item){
|
|
106
142
|
$('<li/>').text(item).appendTo(itemList);
|
|
107
143
|
});
|
|
108
|
-
|
|
144
|
+
|
|
109
145
|
itemList.show();
|
|
110
146
|
exportBtn.removeClass('disabled').removeAttr('disabled');
|
|
111
147
|
|
|
112
148
|
})
|
|
113
|
-
.fail(function( jqXHR,
|
|
114
|
-
|
|
149
|
+
.fail(function( jqXHR, textStatus, errorThrown ) {
|
|
150
|
+
var errText;
|
|
151
|
+
if (jqXHR.status === 403) {
|
|
152
|
+
errText = 'Permission denied. Node-RED 4+: grant role permission "OPC DA: browse address space" (node-opc-da.list), then reload the editor.';
|
|
153
|
+
} else if (textStatus === 'parsererror') {
|
|
154
|
+
errText = 'Browse got HTML instead of JSON (wrong path). Tried: ' + browseApiUrl() +
|
|
155
|
+
'. Open DevTools → Network and confirm this URL returns JSON. Status ' + (jqXHR.status || '?') + '.';
|
|
156
|
+
} else {
|
|
157
|
+
errText = (jqXHR.responseJSON && jqXHR.responseJSON.error) ||
|
|
158
|
+
jqXHR.responseText || errorThrown || textStatus || 'Request failed';
|
|
159
|
+
}
|
|
160
|
+
alertArea.text(errText);
|
|
161
|
+
itemList.empty().hide();
|
|
162
|
+
exportBtn.addClass('disabled').attr('disabled', 'disabled');
|
|
115
163
|
})
|
|
116
164
|
.always(function(){
|
|
117
165
|
browseBtnIcon.removeClass('fa-spinner fa-spin fa-fw').addClass('fa-search');
|
|
@@ -137,7 +185,7 @@
|
|
|
137
185
|
});
|
|
138
186
|
},
|
|
139
187
|
oneditsave: function () {
|
|
140
|
-
|
|
188
|
+
this.timeout = parseInt($("#node-config-input-timeout").val(), 10) || 5000;
|
|
141
189
|
}
|
|
142
190
|
});
|
|
143
191
|
</script>
|
package/opcda/opcda-server.js
CHANGED
|
@@ -2,10 +2,12 @@ module.exports = function(RED) {
|
|
|
2
2
|
const opcda = require('node-opc-da');
|
|
3
3
|
const { OPCServer } = opcda;
|
|
4
4
|
const { ComServer, Session, Clsid } = opcda.dcom;
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
const ACCESS_DENIED_HINT = " Check DOMAIN (Windows PC name for local accounts, e.g. DESKTOP-XXX), password, DCOM rights on the OPC host, and Deploy the server config so Browse uses saved credentials.";
|
|
7
|
+
|
|
6
8
|
const errorCode = {
|
|
7
9
|
0x80040154 : "Clsid is not found.",
|
|
8
|
-
0x00000005 : "Access denied.
|
|
10
|
+
0x00000005 : "Access denied (DCOM)." + ACCESS_DENIED_HINT,
|
|
9
11
|
0xC0040006 : "The Items AccessRights do not allow the operation.",
|
|
10
12
|
0xC0040004 : "The server cannot convert the data between the specified format/ requested data type and the canonical data type.",
|
|
11
13
|
0xC004000C : "Duplicate name not allowed.",
|
|
@@ -22,30 +24,101 @@ module.exports = function(RED) {
|
|
|
22
24
|
0x0004000E : "A value passed to WRITE was accepted but the output was clamped.",
|
|
23
25
|
0x0004000F : "The operation cannot be performed because the object is being referenced.",
|
|
24
26
|
0x0004000D : "The server does not support the requested data rate but will use the closest available rate.",
|
|
25
|
-
0x00000061 : "Clsid syntax is invalid"
|
|
27
|
+
0x00000061 : "Clsid syntax is invalid",
|
|
28
|
+
0x80004002 : "No such interface (E_NOINTERFACE)."
|
|
26
29
|
};
|
|
27
30
|
|
|
28
31
|
function resolveError(e) {
|
|
32
|
+
if (typeof e === "number") {
|
|
33
|
+
const u = e >>> 0;
|
|
34
|
+
if (u === 0x80070005 || e === 5 || e === -2147024891) {
|
|
35
|
+
return "Access denied (DCOM 0x80070005 / 0x5)." + ACCESS_DENIED_HINT;
|
|
36
|
+
}
|
|
37
|
+
if (errorCode[e] !== undefined) return errorCode[e];
|
|
38
|
+
if (errorCode[u] !== undefined) return errorCode[u];
|
|
39
|
+
return "HRESULT 0x" + u.toString(16).toUpperCase() + " (" + e + ")";
|
|
40
|
+
}
|
|
41
|
+
if (e instanceof Error && e.message) {
|
|
42
|
+
const asNum = Number(e.message);
|
|
43
|
+
if (!Number.isNaN(asNum) && String(asNum) === String(e.message).trim()) {
|
|
44
|
+
return resolveError(asNum);
|
|
45
|
+
}
|
|
46
|
+
return e.message;
|
|
47
|
+
}
|
|
29
48
|
if (errorCode[e]) return errorCode[e];
|
|
30
|
-
if (typeof e ===
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
49
|
+
if (typeof e === "string") return e;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.stringify(e);
|
|
52
|
+
} catch (_) {
|
|
53
|
+
return String(e);
|
|
54
|
+
}
|
|
34
55
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Use deployed tier0-opcda-server credentials when the editor password field is empty.
|
|
59
|
+
*/
|
|
60
|
+
function resolveBrowseParams(query) {
|
|
61
|
+
const params = Object.assign({}, query);
|
|
62
|
+
// Node-RED puts this in the password field when creds exist but user did not re-type the password.
|
|
63
|
+
if (params.password === "__PWRD__" || params.password === "__PASSWORD__") {
|
|
64
|
+
delete params.password;
|
|
65
|
+
}
|
|
66
|
+
const id = params.id;
|
|
67
|
+
if (id) {
|
|
68
|
+
const srv = RED.nodes.getNode(id);
|
|
69
|
+
if (srv && srv.credentials) {
|
|
70
|
+
if (srv.address) params.address = srv.address;
|
|
71
|
+
if (srv.clsid) params.clsid = srv.clsid;
|
|
72
|
+
if (srv.timeout != null && srv.timeout !== "") params.timeout = srv.timeout;
|
|
73
|
+
if (srv.domain != null && String(srv.domain).trim() !== "") {
|
|
74
|
+
params.domain = String(srv.domain).trim();
|
|
75
|
+
}
|
|
76
|
+
if (srv.credentials.username) {
|
|
77
|
+
params.username = srv.credentials.username;
|
|
78
|
+
}
|
|
79
|
+
if (srv.credentials.password) {
|
|
80
|
+
params.password = srv.credentials.password;
|
|
81
|
+
}
|
|
82
|
+
} else if (id) {
|
|
83
|
+
RED.log.warn("OPC DA browse: config node id not in runtime (Deploy flows?) — using form/query fields only.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
params.domain = params.domain != null && String(params.domain).trim() !== "" ?
|
|
87
|
+
String(params.domain).trim() : "";
|
|
88
|
+
params.username = String(params.username || "").trim();
|
|
89
|
+
params.password = String(params.password || "");
|
|
90
|
+
if (params.password === "__PWRD__" || params.password === "__PASSWORD__") {
|
|
91
|
+
params.password = "";
|
|
92
|
+
}
|
|
93
|
+
const t = Number(params.timeout);
|
|
94
|
+
params.timeout = Number.isFinite(t) && t > 0 ? t : 15000;
|
|
95
|
+
return params;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
RED.httpAdmin.get("/tier0-opcda/browse", RED.auth.needsPermission("node-opc-da.list"), function (req, res) {
|
|
99
|
+
async function browseItems() {
|
|
100
|
+
const params = resolveBrowseParams(req.query);
|
|
101
|
+
try {
|
|
102
|
+
if (!params.address || !params.clsid) {
|
|
103
|
+
res.status(400).send({error: "Missing address or clsid."});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!params.username || !params.password) {
|
|
107
|
+
res.status(400).send({
|
|
108
|
+
error: "Missing username or password. Deploy flows first (Browse uses stored credentials from the tier0-opcda-server node). If the browser showed password=__PWRD__, that is not a real password — Node-RED only sends the real one after Deploy, or re-type the password in the server config and deploy."
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
40
113
|
var session = new Session();
|
|
41
114
|
session = session.createSession(params.domain, params.username, params.password);
|
|
42
115
|
session.setGlobalSocketTimeout(params.timeout);
|
|
43
116
|
|
|
44
117
|
var comServer = new ComServer(new Clsid(params.clsid), params.address, session);
|
|
45
118
|
await comServer.init();
|
|
46
|
-
|
|
119
|
+
|
|
47
120
|
var comObject = await comServer.createInstance();
|
|
48
|
-
|
|
121
|
+
|
|
49
122
|
var opcServer = new opcda.OPCServer();
|
|
50
123
|
await opcServer.init(comObject);
|
|
51
124
|
|
|
@@ -55,20 +128,19 @@ module.exports = function(RED) {
|
|
|
55
128
|
opcBrowser.end()
|
|
56
129
|
.then(() => opcServer.end())
|
|
57
130
|
.then(() => comServer.closeStub())
|
|
58
|
-
.catch(e => RED.log.error(`Error closing browse session: ${e}`));
|
|
131
|
+
.catch((e) => RED.log.error(`Error closing browse session: ${e}`));
|
|
59
132
|
|
|
60
|
-
res.status(200).send({items
|
|
61
|
-
}
|
|
62
|
-
catch(e){
|
|
133
|
+
res.status(200).send({items: itemList});
|
|
134
|
+
} catch (e) {
|
|
63
135
|
var msg = resolveError(e);
|
|
64
136
|
RED.log.error(`OPC DA browse error: ${msg}`);
|
|
65
|
-
RED.log.error(e);
|
|
66
|
-
res.status(500).send({error
|
|
137
|
+
if (e && e.stack) RED.log.error(e.stack);
|
|
138
|
+
res.status(500).send({error: msg});
|
|
67
139
|
}
|
|
68
140
|
}
|
|
69
|
-
|
|
141
|
+
|
|
70
142
|
browseItems();
|
|
71
|
-
|
|
143
|
+
});
|
|
72
144
|
|
|
73
145
|
function OPCDAServer(config) {
|
|
74
146
|
RED.nodes.createNode(this,config);
|
package/opctest.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
import OpenOPC
|
|
3
|
+
import paho.mqtt.client as mqtt
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
reload(sys)
|
|
9
|
+
sys.setdefaultencoding('utf-8')
|
|
10
|
+
|
|
11
|
+
OPC_SERVER = 'Kepware.KEPServerEX.V6'
|
|
12
|
+
OPC_HOST = '192.168.31.75'
|
|
13
|
+
|
|
14
|
+
MQTT_BROKER = '192.168.31.45'
|
|
15
|
+
MQTT_PORT = 1883
|
|
16
|
+
MQTT_TOPIC_PREFIX = 'opcda'
|
|
17
|
+
|
|
18
|
+
POLL_INTERVAL = 1
|
|
19
|
+
|
|
20
|
+
TAGS = [
|
|
21
|
+
u'通道 1.设备 1.标记 1',
|
|
22
|
+
u'TI2022.PV',
|
|
23
|
+
u'TI2022.SV',
|
|
24
|
+
u'TI2022.MV',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
running = True
|
|
28
|
+
|
|
29
|
+
def on_exit(sig, frame):
|
|
30
|
+
global running
|
|
31
|
+
print("\n[*] Shutting down...")
|
|
32
|
+
running = False
|
|
33
|
+
|
|
34
|
+
signal.signal(signal.SIGINT, on_exit)
|
|
35
|
+
signal.signal(signal.SIGTERM, on_exit)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def make_topic(tag):
|
|
39
|
+
parts = tag.split('.')
|
|
40
|
+
if len(parts) >= 2:
|
|
41
|
+
parent = '/'.join(parts[:-1])
|
|
42
|
+
leaf = parts[-1]
|
|
43
|
+
topic = '%s/%s/Metric/%s' % (MQTT_TOPIC_PREFIX, parent, leaf)
|
|
44
|
+
else:
|
|
45
|
+
topic = '%s/Metric/%s' % (MQTT_TOPIC_PREFIX, tag)
|
|
46
|
+
return topic.replace(' ', '_')
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
UNS_EXPORT_FILE = 'uns_import.json'
|
|
50
|
+
|
|
51
|
+
TOPIC_FIELDS = [
|
|
52
|
+
{"name": "timeStamp", "type": "DATETIME", "unique": True, "systemField": True},
|
|
53
|
+
{"name": "tag", "type": "LONG", "unique": True, "tbValueName": "tag", "systemField": True},
|
|
54
|
+
{"name": "value", "type": "FLOAT", "index": "double_1"},
|
|
55
|
+
{"name": "quality", "type": "LONG", "systemField": True},
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def generate_uns_json(tags):
|
|
60
|
+
def get_or_create(children, name, node_type="path", extra=None):
|
|
61
|
+
for child in children:
|
|
62
|
+
if child["name"] == name:
|
|
63
|
+
return child
|
|
64
|
+
node = {"type": node_type, "name": name}
|
|
65
|
+
if extra:
|
|
66
|
+
node.update(extra)
|
|
67
|
+
if node_type == "path":
|
|
68
|
+
node["children"] = []
|
|
69
|
+
children.append(node)
|
|
70
|
+
return node
|
|
71
|
+
|
|
72
|
+
root_children = []
|
|
73
|
+
for tag in tags:
|
|
74
|
+
parts = tag.replace(' ', '_').split('.')
|
|
75
|
+
if len(parts) >= 2:
|
|
76
|
+
path_parts = parts[:-1]
|
|
77
|
+
leaf = parts[-1]
|
|
78
|
+
else:
|
|
79
|
+
path_parts = []
|
|
80
|
+
leaf = parts[0]
|
|
81
|
+
|
|
82
|
+
current = root_children
|
|
83
|
+
prefix_node = get_or_create(current, MQTT_TOPIC_PREFIX)
|
|
84
|
+
current = prefix_node["children"]
|
|
85
|
+
|
|
86
|
+
for p in path_parts:
|
|
87
|
+
node = get_or_create(current, p)
|
|
88
|
+
current = node["children"]
|
|
89
|
+
|
|
90
|
+
metric_node = get_or_create(current, "Metric", extra={
|
|
91
|
+
"displayName": "Metric",
|
|
92
|
+
"dataType": "METRIC",
|
|
93
|
+
})
|
|
94
|
+
current = metric_node["children"]
|
|
95
|
+
|
|
96
|
+
topic_node = {
|
|
97
|
+
"type": "topic",
|
|
98
|
+
"name": leaf,
|
|
99
|
+
"fields": TOPIC_FIELDS,
|
|
100
|
+
"dataType": "TIME_SEQUENCE_TYPE",
|
|
101
|
+
"generateDashboard": "TRUE",
|
|
102
|
+
"enableHistory": "TRUE",
|
|
103
|
+
"mockData": "FALSE",
|
|
104
|
+
"writeData": "FALSE",
|
|
105
|
+
"topicType": "METRIC",
|
|
106
|
+
}
|
|
107
|
+
exists = False
|
|
108
|
+
for child in current:
|
|
109
|
+
if child["name"] == leaf:
|
|
110
|
+
exists = True
|
|
111
|
+
break
|
|
112
|
+
if not exists:
|
|
113
|
+
current.append(topic_node)
|
|
114
|
+
|
|
115
|
+
result = {"UNS": root_children}
|
|
116
|
+
with open(UNS_EXPORT_FILE, 'w') as f:
|
|
117
|
+
json.dump(result, f, indent=2, ensure_ascii=False)
|
|
118
|
+
print("[+] UNS import JSON written to %s" % UNS_EXPORT_FILE)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def run_bridge():
|
|
122
|
+
global running
|
|
123
|
+
|
|
124
|
+
print("[*] OPC DA -> MQTT Bridge")
|
|
125
|
+
print(" OPC Server: %s @ %s" % (OPC_SERVER, OPC_HOST))
|
|
126
|
+
print(" MQTT Broker: %s:%d" % (MQTT_BROKER, MQTT_PORT))
|
|
127
|
+
print(" Tags: %d" % len(TAGS))
|
|
128
|
+
print("")
|
|
129
|
+
|
|
130
|
+
opc = None
|
|
131
|
+
mqtt_client = None
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
mqtt_client = mqtt.Client()
|
|
135
|
+
mqtt_client.connect(MQTT_BROKER, MQTT_PORT)
|
|
136
|
+
mqtt_client.loop_start()
|
|
137
|
+
print("[+] MQTT connected")
|
|
138
|
+
|
|
139
|
+
opc = OpenOPC.client()
|
|
140
|
+
opc.connect(OPC_SERVER, OPC_HOST)
|
|
141
|
+
print("[+] OPC DA connected")
|
|
142
|
+
|
|
143
|
+
generate_uns_json(TAGS)
|
|
144
|
+
|
|
145
|
+
print("[*] Publishing data every %ds (Ctrl+C to stop)..." % POLL_INTERVAL)
|
|
146
|
+
print("-" * 50)
|
|
147
|
+
|
|
148
|
+
while running:
|
|
149
|
+
try:
|
|
150
|
+
results = opc.read(TAGS)
|
|
151
|
+
except Exception as e:
|
|
152
|
+
print("[!] OPC read error: %s, reconnecting..." % str(e))
|
|
153
|
+
try:
|
|
154
|
+
opc.close()
|
|
155
|
+
except:
|
|
156
|
+
pass
|
|
157
|
+
opc = OpenOPC.client()
|
|
158
|
+
opc.connect(OPC_SERVER, OPC_HOST)
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
for tag_name, value, quality, timestamp in results:
|
|
162
|
+
topic = make_topic(tag_name)
|
|
163
|
+
|
|
164
|
+
if isinstance(quality, str):
|
|
165
|
+
quality_code = 0 if quality == 'Good' else 1
|
|
166
|
+
else:
|
|
167
|
+
quality_code = 0 if quality is not None and int(quality) >= 192 else 1
|
|
168
|
+
|
|
169
|
+
if value is None:
|
|
170
|
+
out_value = 0.0
|
|
171
|
+
elif isinstance(value, (int, float)):
|
|
172
|
+
out_value = float(value)
|
|
173
|
+
else:
|
|
174
|
+
try:
|
|
175
|
+
out_value = float(value)
|
|
176
|
+
except (ValueError, TypeError):
|
|
177
|
+
out_value = str(value)
|
|
178
|
+
|
|
179
|
+
payload = json.dumps({
|
|
180
|
+
'value': out_value,
|
|
181
|
+
'quality': quality_code,
|
|
182
|
+
'timestamp': str(timestamp),
|
|
183
|
+
}, ensure_ascii=False)
|
|
184
|
+
mqtt_client.publish(topic, payload, retain=True)
|
|
185
|
+
|
|
186
|
+
ts = time.strftime('%H:%M:%S')
|
|
187
|
+
print("[%s] Published %d tags" % (ts, len(results)))
|
|
188
|
+
|
|
189
|
+
time.sleep(POLL_INTERVAL)
|
|
190
|
+
|
|
191
|
+
except Exception as e:
|
|
192
|
+
print("\n[!] Fatal error: %s" % str(e))
|
|
193
|
+
print(" 1. Is MQTT broker running? ")
|
|
194
|
+
print(" 2. Is OPC server reachable?")
|
|
195
|
+
print(" 3. Run as Administrator if DCOM access is needed.")
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
|
|
198
|
+
finally:
|
|
199
|
+
if opc:
|
|
200
|
+
try:
|
|
201
|
+
opc.close()
|
|
202
|
+
except:
|
|
203
|
+
pass
|
|
204
|
+
print("[-] OPC DA disconnected")
|
|
205
|
+
if mqtt_client:
|
|
206
|
+
mqtt_client.loop_stop()
|
|
207
|
+
mqtt_client.disconnect()
|
|
208
|
+
print("[-] MQTT disconnected")
|
|
209
|
+
|
|
210
|
+
print("[*] Bridge stopped.")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
run_bridge()
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tier0/node-red-contrib-opcda-client",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Node-RED OPC DA Reading and Writing Node",
|
|
5
5
|
"node-red": {
|
|
6
6
|
"nodes": {
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
7
|
+
"opc-read": "opcda/opcda-read.js",
|
|
8
|
+
"opc-write": "opcda/opcda-write.js",
|
|
9
|
+
"opc-server": "opcda/opcda-server.js"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
12
12
|
"opcda",
|
|
@@ -33,13 +33,16 @@
|
|
|
33
33
|
"type": "git",
|
|
34
34
|
"url": "git+https://github.com/FREEZONEX/opcda-client.git"
|
|
35
35
|
},
|
|
36
|
-
"author": "FREEZONEX",
|
|
36
|
+
"author": "FREEZONEX (forked from emrebekar)",
|
|
37
37
|
"bugs": {
|
|
38
38
|
"url": "https://github.com/FREEZONEX/opcda-client/issues"
|
|
39
39
|
},
|
|
40
40
|
"homepage": "https://github.com/FREEZONEX/opcda-client#readme",
|
|
41
41
|
"license": "Apache-2.0",
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
42
45
|
"dependencies": {
|
|
43
|
-
"node-opc-da": "^1.0.
|
|
46
|
+
"@tier0/node-opc-da": "^1.0.7"
|
|
44
47
|
}
|
|
45
48
|
}
|
package/patch-debug.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const file = '/usr/src/node-red/node_modules/node-dcom/dcom/rpc/security/ntlmauthentication.js';
|
|
3
|
+
let code = fs.readFileSync(file, 'utf8');
|
|
4
|
+
code = code.replace(
|
|
5
|
+
'let target = null;',
|
|
6
|
+
'let target = null;\n console.log("[NTLM-DBG] domain=" + info.domain + " user=" + this.credentials.username);'
|
|
7
|
+
);
|
|
8
|
+
code = code.replace(
|
|
9
|
+
"if (target == '') {",
|
|
10
|
+
"console.log('[NTLM-DBG] target=' + target);\n if (target == '') {"
|
|
11
|
+
);
|
|
12
|
+
fs.writeFileSync(file, code);
|
|
13
|
+
console.log('Patched.');
|
package/patch-ntlm.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const file = '/usr/src/node-red/node_modules/node-dcom/dcom/rpc/security/ntlmauthentication.js';
|
|
3
|
+
let code = fs.readFileSync(file, 'utf8');
|
|
4
|
+
|
|
5
|
+
// Fix 1: Replace all Encdec references with inline uint16le decode
|
|
6
|
+
code = code.replace(
|
|
7
|
+
/new Encdec\(\)\.dec_uint16le\(targetInformation,\s*i\)/g,
|
|
8
|
+
'(targetInformation[i] | (targetInformation[i+1] << 8))'
|
|
9
|
+
);
|
|
10
|
+
code = code.replace(
|
|
11
|
+
/new Encdec\.dec_uint16le\(targetInformation,\s*i\)/g,
|
|
12
|
+
'(targetInformation[i] | (targetInformation[i+1] << 8))'
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
// Fix 2: Add debug logging for NTLM credentials
|
|
16
|
+
code = code.replace(
|
|
17
|
+
'let target = null;',
|
|
18
|
+
'let target = null;\n console.log("[NTLM-DBG] domain=" + info.domain + " user=" + this.credentials.username);'
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
// Verify no syntax errors
|
|
22
|
+
try {
|
|
23
|
+
new Function(code);
|
|
24
|
+
console.log('ERROR: wrapped in function - not a module');
|
|
25
|
+
} catch(e) {
|
|
26
|
+
// Expected since it's a class/module, not a function body
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fs.writeFileSync(file, code);
|
|
30
|
+
|
|
31
|
+
// Verify it loads
|
|
32
|
+
try {
|
|
33
|
+
delete require.cache[require.resolve(file)];
|
|
34
|
+
require(file);
|
|
35
|
+
console.log('Patched and verified OK');
|
|
36
|
+
} catch(e) {
|
|
37
|
+
console.log('WARNING: Load check result:', e.message);
|
|
38
|
+
// May fail due to missing dependencies in standalone context, that's OK
|
|
39
|
+
// as long as it's not a syntax error
|
|
40
|
+
if (e.message.includes('Unexpected token')) {
|
|
41
|
+
console.log('SYNTAX ERROR - reverting!');
|
|
42
|
+
// Don't revert for now, let's see the error
|
|
43
|
+
} else {
|
|
44
|
+
console.log('Non-syntax error (expected in standalone context)');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const file = '/usr/src/node-red/node_modules/node-dcom/dcom/rpc/security/ntlmauthentication.js';
|
|
3
|
+
let code = fs.readFileSync(file, 'utf8');
|
|
4
|
+
|
|
5
|
+
// Add password length and MD4 check debug
|
|
6
|
+
code = code.replace(
|
|
7
|
+
'let target = null;',
|
|
8
|
+
`let target = null;
|
|
9
|
+
console.log("[NTLM-DBG] domain=" + info.domain + " user=" + this.credentials.username + " pwdLen=" + (this.credentials.password ? this.credentials.password.length : "NULL"));
|
|
10
|
+
try { const _h = Crypto.createHash('md4'); _h.update(Buffer.from(this.credentials.password || '', 'utf16le')); console.log("[NTLM-DBG] md4-ok ntHash=" + _h.digest('hex')); } catch(e) { console.log("[NTLM-DBG] md4-FAIL: " + e.message); }`
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
fs.writeFileSync(file, code);
|
|
14
|
+
|
|
15
|
+
// Verify loads
|
|
16
|
+
try { require('node-opc-da'); console.log('Patched OK, module loads'); }
|
|
17
|
+
catch(e) { console.log('Load result:', e.message); }
|
package/test-connect.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const opcda = require('node-opc-da');
|
|
2
|
+
const { OPCServer } = opcda;
|
|
3
|
+
const { ComServer, Session, Clsid } = opcda.dcom;
|
|
4
|
+
|
|
5
|
+
const config = {
|
|
6
|
+
address: '192.168.31.75',
|
|
7
|
+
domain: 'DESKTOP-BBD7VBL',
|
|
8
|
+
username: 'Administrator',
|
|
9
|
+
password: 'Supos@1304',
|
|
10
|
+
clsid: '7BC0CC8E-482C-47CA-ABDC-0FE7F9C6E729',
|
|
11
|
+
timeout: 10000
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
async function test() {
|
|
15
|
+
console.log('=== OPC DA Connection Test ===');
|
|
16
|
+
console.log('Address:', config.address);
|
|
17
|
+
console.log('Domain:', config.domain);
|
|
18
|
+
console.log('Username:', config.username);
|
|
19
|
+
console.log('CLSID:', config.clsid);
|
|
20
|
+
console.log('');
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
console.log('[1] Creating session...');
|
|
24
|
+
var session = new Session();
|
|
25
|
+
session = session.createSession(config.domain, config.username, config.password);
|
|
26
|
+
session.setGlobalSocketTimeout(config.timeout);
|
|
27
|
+
console.log('[1] Session created OK');
|
|
28
|
+
|
|
29
|
+
console.log('[2] Creating ComServer...');
|
|
30
|
+
var comServer = new ComServer(new Clsid(config.clsid), config.address, session);
|
|
31
|
+
console.log('[2] ComServer created, calling init()...');
|
|
32
|
+
|
|
33
|
+
await comServer.init();
|
|
34
|
+
console.log('[2] ComServer.init() OK!');
|
|
35
|
+
|
|
36
|
+
console.log('[3] Creating COM instance...');
|
|
37
|
+
var comObject = await comServer.createInstance();
|
|
38
|
+
console.log('[3] COM instance created OK!');
|
|
39
|
+
|
|
40
|
+
console.log('[4] Creating OPC Server...');
|
|
41
|
+
var opcServer = new OPCServer();
|
|
42
|
+
await opcServer.init(comObject);
|
|
43
|
+
console.log('[4] OPC Server connected!');
|
|
44
|
+
|
|
45
|
+
console.log('[5] Getting browser...');
|
|
46
|
+
var browser = await opcServer.getBrowser();
|
|
47
|
+
var items = await browser.browseAllFlat();
|
|
48
|
+
console.log('[5] Found', items.length, 'items');
|
|
49
|
+
items.slice(0, 10).forEach(i => console.log(' ', i));
|
|
50
|
+
|
|
51
|
+
await browser.end();
|
|
52
|
+
await opcServer.end();
|
|
53
|
+
await comServer.closeStub();
|
|
54
|
+
console.log('\n=== SUCCESS ===');
|
|
55
|
+
} catch (e) {
|
|
56
|
+
console.log('\n=== FAILED ===');
|
|
57
|
+
console.log('Error type:', typeof e);
|
|
58
|
+
console.log('Error value:', e);
|
|
59
|
+
if (e instanceof Error) {
|
|
60
|
+
console.log('Message:', e.message);
|
|
61
|
+
console.log('Stack:', e.stack);
|
|
62
|
+
}
|
|
63
|
+
if (typeof e === 'number') {
|
|
64
|
+
console.log('Hex:', '0x' + (e >>> 0).toString(16));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test();
|