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 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
@@ -0,0 +1,4 @@
1
+ const Dumper = require ('./lib/Dumper')
2
+ const Loader = require ('./lib/Loader')
3
+
4
+ module.exports = {Dumper, Loader}
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
+ }
@@ -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
+ }