opcua-snapshot 1.0.0
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/LICENSE +24 -0
- package/README.md +98 -0
- package/index.js +4 -0
- package/lib/Dumper.js +149 -0
- package/lib/Loader.js +136 -0
- package/lib/Snapshot.js +30 -0
- package/opcua.json +579216 -0
- package/package.json +28 -0
- package/test.js +103 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
BSD 2-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, do-
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
16
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
17
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
18
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
19
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
20
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
21
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
22
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
23
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
24
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
`node-opcua-snapshot` is a Node.js library that enables snapshot-based management of [OPC UA](https://opcfoundation.org/about/opc-technologies/opc-ua/) server address spaces. It provides two primary capabilities:
|
|
2
|
+
|
|
3
|
+
* **Extraction**: Connect to an existing OPC UA server and capture its address space structure, node configurations, and relationships into a portable JSON format
|
|
4
|
+
* **Recreation**: Load a JSON snapshot and populate a new OPC UA server with the captured configuration
|
|
5
|
+
|
|
6
|
+
The library acts as a configuration management layer on top of the [node-opcua](https://node-opcua.github.io/) protocol implementation, enabling use cases such as:
|
|
7
|
+
|
|
8
|
+
* Creating test servers with production-like configurations
|
|
9
|
+
* Version-controlling OPC UA server structures
|
|
10
|
+
* Cloning servers across different environments
|
|
11
|
+
* Building mock servers for offline development
|
|
12
|
+
|
|
13
|
+
# Installation
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install opcua-snapshot
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
# Usage
|
|
20
|
+
## Taking a Snapshot from an Existing Server
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
const fs = require ('node:fs')
|
|
24
|
+
const {OPCUAClient} = require ('node-opcua')
|
|
25
|
+
const {Dumper} = require ('opcua-snapshot')
|
|
26
|
+
|
|
27
|
+
async function dump (filePath, endpointUrl, rootNode) {
|
|
28
|
+
|
|
29
|
+
const client = OPCUAClient.create ({endpointMustExist: false})
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
|
|
33
|
+
await client.connect (endpointUrl)
|
|
34
|
+
|
|
35
|
+
const session = await client.createSession ()
|
|
36
|
+
|
|
37
|
+
const dumper = new Dumper (session)
|
|
38
|
+
|
|
39
|
+
// dumper.on ('start', _ => console.log (_))
|
|
40
|
+
// dumper.on ('warning', _ => console.log (_))
|
|
41
|
+
// dumper.on ('finish', _ => console.log ('finish'))
|
|
42
|
+
|
|
43
|
+
const snapshot = await dumper.dump (rootNode /* ?? {ns: 0, i: 85}*/) // `Objects` by default
|
|
44
|
+
|
|
45
|
+
fs.writeFileSync (filePath, JSON.stringify (snapshot, null, 2))
|
|
46
|
+
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
|
|
50
|
+
await client.disconnect ()
|
|
51
|
+
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Using a Saved Snapshot to run a New Server
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
|
|
61
|
+
const fs = require ('node:fs')
|
|
62
|
+
const {OPCUAServer} = require ('node-opcua')
|
|
63
|
+
const {Loader} = require ('opcua-snapshot')
|
|
64
|
+
|
|
65
|
+
// mock history data
|
|
66
|
+
const MS_IN_DAY = 1000 * 60 * 60 * 24
|
|
67
|
+
const values = [0.0], dates = [(new Date (new Date ().toJSON ().substring (0, 10) + 'T00:00:00')).getTime ()]
|
|
68
|
+
for (let i = 0; i < 10; i ++) {
|
|
69
|
+
values.push (values [i] + Math.random ())
|
|
70
|
+
dates.unshift (dates [0] - MS_IN_DAY)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function run (fn, port) {
|
|
74
|
+
|
|
75
|
+
const server = new OPCUAServer ({endpoints: [{port}]})
|
|
76
|
+
|
|
77
|
+
await server.initialize () // prior to creating the Loader
|
|
78
|
+
|
|
79
|
+
const loader = new Loader (server)
|
|
80
|
+
|
|
81
|
+
loader.on ('var', varNode => { // will adjust variables upon creation:
|
|
82
|
+
if (!varNode.historizing) return // for historical ones (in this example)...
|
|
83
|
+
varNode.accessLevel = 4 // ... only allow HistoryRead
|
|
84
|
+
loader.setValues (varNode, values, dates) // ... and set the generated values
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
loader.load (JSON.parse (fs.readFileSync (fn))) // actually load the configuration
|
|
88
|
+
|
|
89
|
+
await server.start ()
|
|
90
|
+
|
|
91
|
+
loader.setValue ('ns=1;s=MyObj.MyProp', '1234') // at run time
|
|
92
|
+
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
# See Also
|
|
97
|
+
|
|
98
|
+
More documentaion available at https://deepwiki.com/do-/node-opcua-snapshot
|
package/index.js
ADDED
package/lib/Dumper.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const EventEmitter = require ('node:events')
|
|
2
|
+
const opcua = require ('node-opcua')
|
|
3
|
+
|
|
4
|
+
const DEFAUT_ROOT = {
|
|
5
|
+
ns : 0,
|
|
6
|
+
id : 'i=85',
|
|
7
|
+
type : 'FolderType',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const nodeId = ({ns, id}) => `ns=${ns};${id}`
|
|
11
|
+
|
|
12
|
+
const NS0 = 'ns=0;i=', ns0id = (name, s, postfix = 'TypeIds') => {
|
|
13
|
+
|
|
14
|
+
const k = name + postfix; if (!(k in opcua)) return undefined
|
|
15
|
+
|
|
16
|
+
if (typeof s !== 'string' || !s.startsWith (NS0)) return undefined
|
|
17
|
+
|
|
18
|
+
return opcua [k] [parseInt (s.substring (NS0.length))]
|
|
19
|
+
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = class extends EventEmitter {
|
|
23
|
+
|
|
24
|
+
#session
|
|
25
|
+
|
|
26
|
+
constructor (session) {
|
|
27
|
+
|
|
28
|
+
super ()
|
|
29
|
+
|
|
30
|
+
this.#session = session
|
|
31
|
+
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async browse (nodeId) {
|
|
35
|
+
|
|
36
|
+
this.emit ('start', nodeId)
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
|
|
40
|
+
const {statusCode, references} = await this.#session.browse (nodeId); if (statusCode.isGoodish ()) return references
|
|
41
|
+
|
|
42
|
+
this.emit ('warning', statusCode); return []
|
|
43
|
+
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
|
|
47
|
+
this.emit ('finish')
|
|
48
|
+
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async read (nodeIds) {
|
|
54
|
+
|
|
55
|
+
this.emit ('start', nodeIds)
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
|
|
59
|
+
return await this.#session.read (nodeIds.map (nodeId => ({nodeId})))
|
|
60
|
+
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
|
|
64
|
+
this.emit ('finish')
|
|
65
|
+
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async loadObject (parent) {
|
|
71
|
+
|
|
72
|
+
const references = await this.browse (nodeId (parent)), vars = []
|
|
73
|
+
|
|
74
|
+
for (const reference of references) if (reference.isForward) {
|
|
75
|
+
|
|
76
|
+
const {referenceTypeId, nodeClass, nodeId, browseName: {namespaceIndex, name}, typeDefinition} = reference.toJSON (), dst = {
|
|
77
|
+
class: nodeClass,
|
|
78
|
+
ns: namespaceIndex,
|
|
79
|
+
id: nodeId.substring (1 + nodeId.indexOf (';')),
|
|
80
|
+
name
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
dst.type = ns0id (nodeClass, typeDefinition)
|
|
84
|
+
|
|
85
|
+
if (dst.class === 'Variable') vars.push (dst)
|
|
86
|
+
|
|
87
|
+
await this.loadObject (dst)
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
|
|
91
|
+
const ref = ns0id ('Reference', referenceTypeId)
|
|
92
|
+
|
|
93
|
+
if (!(ref in parent)) parent [ref] = []; parent [ref].push (dst)
|
|
94
|
+
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
|
|
101
|
+
const {length} = vars; if (length !== 0) {
|
|
102
|
+
|
|
103
|
+
const vals = await this.read (vars.map (nodeId))
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < length; i ++) {
|
|
106
|
+
|
|
107
|
+
const v = vars [i], {dataType, value} = vals [i].value
|
|
108
|
+
|
|
109
|
+
v.dataType = dataType
|
|
110
|
+
|
|
111
|
+
if (value != null) v.value = value
|
|
112
|
+
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
toRootObject (arg = {}) {
|
|
122
|
+
|
|
123
|
+
if (typeof arg !== 'object') throw Error (`Not an object: ${arg}`)
|
|
124
|
+
|
|
125
|
+
if (arg.class != null && arg.class !== 'Object') throw Error (`root.class must be 'Object', found ${root.class}`)
|
|
126
|
+
|
|
127
|
+
const {ns, id, name, type} = {...DEFAUT_ROOT, ...arg}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
class: 'Object',
|
|
131
|
+
ns,
|
|
132
|
+
id,
|
|
133
|
+
name: name ?? ns0id ('Object', nodeId ({ns, id}), 'Ids'),
|
|
134
|
+
type
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async dump (arg) {
|
|
140
|
+
|
|
141
|
+
const root = this.toRootObject (arg)
|
|
142
|
+
|
|
143
|
+
await this.loadObject (root)
|
|
144
|
+
|
|
145
|
+
return root
|
|
146
|
+
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
}
|
package/lib/Loader.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const EventEmitter = require ('node:events')
|
|
2
|
+
const {StatusCodes} = require ('node-opcua')
|
|
3
|
+
|
|
4
|
+
const Snapshot = require('./Snapshot')
|
|
5
|
+
, {nodeId} = Snapshot
|
|
6
|
+
, browseName = o => ({namespaceIndex : o.ns , name : o.name})
|
|
7
|
+
, idName = o => ({browseName : browseName(o), nodeId : nodeId (o)})
|
|
8
|
+
|
|
9
|
+
const PROP = new Map ([
|
|
10
|
+
['EngineeringUnits', 'engineeringUnits'],
|
|
11
|
+
['EURange', 'engineeringUnitsRange'],
|
|
12
|
+
['InstrumentRange', 'instrumentRange'],
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
const setValueFromSource = (node, value, status, date) => {
|
|
16
|
+
|
|
17
|
+
const dataType = node.dataType.value
|
|
18
|
+
|
|
19
|
+
if (dataType === 13) value = new Date (value)
|
|
20
|
+
|
|
21
|
+
if (date) date = new Date (date)
|
|
22
|
+
|
|
23
|
+
node.setValueFromSource ({dataType, value}, status, date)
|
|
24
|
+
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = class extends EventEmitter {
|
|
28
|
+
|
|
29
|
+
#addressSpace
|
|
30
|
+
|
|
31
|
+
constructor (server) {
|
|
32
|
+
|
|
33
|
+
super ()
|
|
34
|
+
|
|
35
|
+
this.#addressSpace = server.engine.addressSpace
|
|
36
|
+
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get NS () {
|
|
40
|
+
|
|
41
|
+
return this.#addressSpace.getNamespaceArray()
|
|
42
|
+
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getNs (o) {
|
|
46
|
+
|
|
47
|
+
return this.NS [o.ns]
|
|
48
|
+
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getNode (nodeOrId) {
|
|
52
|
+
|
|
53
|
+
if (typeof nodeOrId === 'object' && nodeOrId.nodeId) return nodeOrId
|
|
54
|
+
|
|
55
|
+
return this.#addressSpace.findNode (nodeOrId)
|
|
56
|
+
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setNamespaceArray (namespaceArray) {
|
|
60
|
+
|
|
61
|
+
this.NS.splice (1, 1)
|
|
62
|
+
|
|
63
|
+
for (let i = 1; i < namespaceArray.length; i++) this.#addressSpace.registerNamespace (namespaceArray [i])
|
|
64
|
+
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setValue (nodeOrId, value, date, status = StatusCodes.Good) {
|
|
68
|
+
|
|
69
|
+
setValueFromSource (this.getNode (nodeOrId), value, status, date)
|
|
70
|
+
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setValues (nodeOrId, values, dates, status = StatusCodes.Good) {
|
|
74
|
+
|
|
75
|
+
const node = this.getNode (nodeOrId)
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < dates.length; i ++)
|
|
78
|
+
|
|
79
|
+
setValueFromSource (node, values [i], status, dates [i])
|
|
80
|
+
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
addVariable (componentOf, o) {
|
|
85
|
+
|
|
86
|
+
let {dataType, value, HasHistoricalConfiguration} = o; if (dataType === 13) value = new Date (value)
|
|
87
|
+
|
|
88
|
+
const ns = this.getNs (o), options = {...idName (o), componentOf, dataType, minimumSamplingInterval: 1000}
|
|
89
|
+
|
|
90
|
+
if (!HasHistoricalConfiguration) options.value = {dataType, value}
|
|
91
|
+
|
|
92
|
+
switch (o.type) {
|
|
93
|
+
case 'BaseDataVariableType': return ns.addVariable (options)
|
|
94
|
+
case 'AnalogItemType' : break // see below
|
|
95
|
+
default : return // unsupported
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (Array.isArray (o.HasProperty)) for (const i of o.HasProperty) options [PROP.get (i.name)] = i.value
|
|
99
|
+
|
|
100
|
+
const node = ns.addAnalogDataItem (options)
|
|
101
|
+
|
|
102
|
+
if (HasHistoricalConfiguration) this.#addressSpace.installHistoricalDataNode (node)
|
|
103
|
+
|
|
104
|
+
return node
|
|
105
|
+
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
addObject (organizedBy, o) {
|
|
109
|
+
|
|
110
|
+
const {Organizes, HasComponent} = o
|
|
111
|
+
|
|
112
|
+
const node = this.getNs (o).addObject ({...idName(o), organizedBy})
|
|
113
|
+
|
|
114
|
+
if (Array.isArray (Organizes)) for (const i of Organizes) this.addObject (node, i)
|
|
115
|
+
|
|
116
|
+
if (Array.isArray (HasComponent)) for (const i of HasComponent) switch (i.class) {
|
|
117
|
+
|
|
118
|
+
case 'Variable':
|
|
119
|
+
this.emit ('var', this.addVariable (node, i))
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
load (root) {
|
|
127
|
+
|
|
128
|
+
const snapshot = new Snapshot (root)
|
|
129
|
+
|
|
130
|
+
this.setNamespaceArray (snapshot.namespaceArray)
|
|
131
|
+
|
|
132
|
+
for (const i of snapshot.objectsFolder) if (i.ns != 0) this.addObject (this.#addressSpace.rootFolder.objects, i)
|
|
133
|
+
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
}
|
package/lib/Snapshot.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module.exports = class {
|
|
2
|
+
|
|
3
|
+
static nodeId = ({ns, id}) => `ns=${ns};${id}`
|
|
4
|
+
|
|
5
|
+
#root
|
|
6
|
+
|
|
7
|
+
constructor (root) {
|
|
8
|
+
|
|
9
|
+
this.#root = root
|
|
10
|
+
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get objectsFolder () {
|
|
14
|
+
|
|
15
|
+
return this.#root
|
|
16
|
+
.Organizes.find (i => i.name === 'Objects')
|
|
17
|
+
.Organizes
|
|
18
|
+
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get namespaceArray () {
|
|
22
|
+
|
|
23
|
+
return this.objectsFolder
|
|
24
|
+
.find (i => i.name === 'Server')
|
|
25
|
+
.HasProperty.find (i => i.name === 'NamespaceArray')
|
|
26
|
+
.value
|
|
27
|
+
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
}
|