node-opcua-address-space-for-conformance-testing 2.180.0 → 2.181.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/dist/address_space_for_conformance_testing.d.ts +12 -2
- package/dist/address_space_for_conformance_testing.js +32 -27
- package/dist/address_space_for_conformance_testing.js.map +1 -1
- package/dist/conformance_testing/alarm_input_nodes.d.ts +2 -0
- package/dist/conformance_testing/alarm_input_nodes.js +102 -0
- package/dist/conformance_testing/alarm_input_nodes.js.map +1 -0
- package/dist/conformance_testing/ctt_folder.d.ts +2 -0
- package/dist/conformance_testing/ctt_folder.js +375 -0
- package/dist/conformance_testing/ctt_folder.js.map +1 -0
- package/dist/conformance_testing/ctt_tree.d.ts +35 -0
- package/dist/conformance_testing/ctt_tree.js +85 -0
- package/dist/conformance_testing/ctt_tree.js.map +1 -0
- package/dist/conformance_testing/custom_extension_object.d.ts +63 -0
- package/dist/conformance_testing/custom_extension_object.js +48 -0
- package/dist/conformance_testing/custom_extension_object.js.map +1 -0
- package/dist/conformance_testing/discrete_variables.js +6 -4
- package/dist/conformance_testing/discrete_variables.js.map +1 -1
- package/dist/conformance_testing/helpers.d.ts +2 -1
- package/dist/conformance_testing/helpers.js +23 -5
- package/dist/conformance_testing/helpers.js.map +1 -1
- package/dist/conformance_testing/mass_variables.d.ts +2 -1
- package/dist/conformance_testing/mass_variables.js +7 -6
- package/dist/conformance_testing/mass_variables.js.map +1 -1
- package/dist/conformance_testing/path_and_large_arrays.d.ts +2 -1
- package/dist/conformance_testing/path_and_large_arrays.js +6 -5
- package/dist/conformance_testing/path_and_large_arrays.js.map +1 -1
- package/dist/conformance_testing/simulation_variables.d.ts +2 -1
- package/dist/conformance_testing/simulation_variables.js +9 -9
- package/dist/conformance_testing/simulation_variables.js.map +1 -1
- package/dist/conformance_testing/static_variables.d.ts +2 -1
- package/dist/conformance_testing/static_variables.js +19 -12
- package/dist/conformance_testing/static_variables.js.map +1 -1
- package/dist/conformance_testing/type_defaults.d.ts +15 -1
- package/dist/conformance_testing/type_defaults.js +30 -1
- package/dist/conformance_testing/type_defaults.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +19 -14
- package/src/address_space_for_conformance_testing.ts +37 -18
- package/src/conformance_testing/alarm_input_nodes.ts +151 -0
- package/src/conformance_testing/ctt_folder.ts +440 -0
- package/src/conformance_testing/ctt_tree.ts +105 -0
- package/src/conformance_testing/custom_extension_object.ts +105 -0
- package/src/conformance_testing/discrete_variables.ts +6 -4
- package/src/conformance_testing/helpers.ts +26 -6
- package/src/conformance_testing/mass_variables.ts +5 -4
- package/src/conformance_testing/path_and_large_arrays.ts +5 -4
- package/src/conformance_testing/simulation_variables.ts +6 -6
- package/src/conformance_testing/static_variables.ts +31 -9
- package/src/conformance_testing/type_defaults.ts +31 -2
- package/src/index.ts +1 -1
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `CTT` folder: nodes named after the settings of the OPC Foundation
|
|
3
|
+
* Compliance Test Tool (UACTT) server project.
|
|
4
|
+
*
|
|
5
|
+
* The browse path below Objects/CTT mirrors the setting path below
|
|
6
|
+
* "Server Test/NodeIds", e.g. the setting
|
|
7
|
+
* Server Test/NodeIds/Static/HA Profile/Arrays/Int162D
|
|
8
|
+
* is served by
|
|
9
|
+
* Objects/CTT/Static/HA Profile/Arrays/Int162D (NodeId s=CTT/Static/HA Profile/Arrays/Int162D)
|
|
10
|
+
* so a CTT project generator can fill the setting by browsing, without any
|
|
11
|
+
* hand-maintained map. See ./ctt_tree.ts for how a setting outside that prefix
|
|
12
|
+
* is keyed. Only settings the rest of this address space does not already serve
|
|
13
|
+
* are built here - plus the two Bool settings, see addBooleanScalarAndArray().
|
|
14
|
+
*
|
|
15
|
+
* Not covered, and why:
|
|
16
|
+
* Decimal deprecated in OPC UA 1.05
|
|
17
|
+
* the HA Aggregates nodes need node-opcua-aggregates and a seeded
|
|
18
|
+
* history matching the CTT's StartOfBadData*
|
|
19
|
+
* NodeDoesNotSupportServerTimestamp needs the variable read path to omit it
|
|
20
|
+
* NodeManagement/RootNode the server has no AddNodes service
|
|
21
|
+
* Chattering Alarms needs an alarm that flips state on its own
|
|
22
|
+
* DiscrepancyAlarmType Input Nodes see ./alarm_input_nodes.ts
|
|
23
|
+
*/
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import type { AddressSpace, Namespace, UAObject, UAVariable } from "node-opcua-address-space";
|
|
27
|
+
import { standardUnits } from "node-opcua-data-access";
|
|
28
|
+
import { AccessLevelFlag } from "node-opcua-data-model";
|
|
29
|
+
import { buildVariantArray, DataType, Variant, VariantArrayType } from "node-opcua-variant";
|
|
30
|
+
|
|
31
|
+
import { addAlarmInputNodes } from "./alarm_input_nodes.js";
|
|
32
|
+
import { CttFolder, readWrite, TYPE_ALIAS } from "./ctt_tree.js";
|
|
33
|
+
|
|
34
|
+
// ─── Static / All Profiles ──────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
function addVariantVariables(ctt: CttFolder): void {
|
|
37
|
+
// "Variant" in CTT terms: a Variable whose DataType attribute is BaseDataType
|
|
38
|
+
const scalar = new Variant({ dataType: DataType.Double, value: 3.14 });
|
|
39
|
+
ctt.variable("Static/All Profiles/Scalar/Variant", "BaseDataType", -1, scalar, null);
|
|
40
|
+
// The array is the one case the CTT actually type-checks: Attribute Read 023.js
|
|
41
|
+
// asserts AssertUaValueOfType(BuiltInType.Variant, ...) against this node, so the
|
|
42
|
+
// *value* has to be Variant-encoded (BuiltInType 24), not merely stored on a
|
|
43
|
+
// BaseDataType-typed Variable. A Float64Array reads back as Double (11) and fails
|
|
44
|
+
// that assertion. Mixed element types are also the point of the type, and exercise
|
|
45
|
+
// the encoder the way the test intends.
|
|
46
|
+
ctt.variable(
|
|
47
|
+
"Static/All Profiles/Arrays/Variant",
|
|
48
|
+
"BaseDataType",
|
|
49
|
+
1,
|
|
50
|
+
new Variant({
|
|
51
|
+
dataType: DataType.Variant,
|
|
52
|
+
arrayType: VariantArrayType.Array,
|
|
53
|
+
value: [
|
|
54
|
+
new Variant({ dataType: DataType.Double, value: 1 }),
|
|
55
|
+
new Variant({ dataType: DataType.UInt32, value: 2 }),
|
|
56
|
+
new Variant({ dataType: DataType.String, value: "three" }),
|
|
57
|
+
new Variant({ dataType: DataType.Boolean, value: true }),
|
|
58
|
+
new Variant({ dataType: DataType.Float, value: 5.5 })
|
|
59
|
+
]
|
|
60
|
+
}),
|
|
61
|
+
[5]
|
|
62
|
+
);
|
|
63
|
+
ctt.variable(
|
|
64
|
+
"Static/All Profiles/Multi-Dimensional-Arrays/Variant",
|
|
65
|
+
"BaseDataType",
|
|
66
|
+
2,
|
|
67
|
+
new Variant({
|
|
68
|
+
dataType: DataType.Double,
|
|
69
|
+
arrayType: VariantArrayType.Matrix,
|
|
70
|
+
dimensions: [2, 3],
|
|
71
|
+
value: new Float64Array([1, 2, 3, 4, 5, 6])
|
|
72
|
+
}),
|
|
73
|
+
[2, 3]
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `Static/All Profiles/Scalar/Bool` and `.../Arrays/Bool`, which the Simulation
|
|
79
|
+
* folder would otherwise serve.
|
|
80
|
+
*
|
|
81
|
+
* A generator resolves a setting by overlay, then convention, then the CTT's own
|
|
82
|
+
* default, then by *browse name*, then by rule. The HA Profile nodes below are
|
|
83
|
+
* browse-named after their setting leaf, so `Static/HA Profile/Scalar/Bool` is
|
|
84
|
+
* literally named `Bool` - and the name step matches it against the All Profiles
|
|
85
|
+
* `Bool` settings, for which there is no convention node, while the Simulation
|
|
86
|
+
* node named `Boolean` matches neither. The name step runs before the rule step,
|
|
87
|
+
* so those two settings would silently resolve to a historizing node. Every other
|
|
88
|
+
* type name is safe: `Byte`, `Double` and the rest match both nodes and the rule
|
|
89
|
+
* scoring then prefers the non-historizing one.
|
|
90
|
+
*
|
|
91
|
+
* Building the two nodes here makes the convention step answer first, which is
|
|
92
|
+
* both deterministic and where the CTT-specific extras belong - hence the two
|
|
93
|
+
* Description properties, which View Minimum Continuation Point 01 012 needs
|
|
94
|
+
* (it browses the Scalar/Bool setting node with a NodeClassMask of Variable and
|
|
95
|
+
* wants at least two such references).
|
|
96
|
+
*/
|
|
97
|
+
function addBooleanScalarAndArray(ctt: CttFolder): void {
|
|
98
|
+
const scalar = ctt.typedVariable("Static/All Profiles/Scalar/Bool", "Boolean", -1);
|
|
99
|
+
for (const name of ["Description1", "Description2"]) {
|
|
100
|
+
ctt.namespace.addVariable({
|
|
101
|
+
propertyOf: scalar,
|
|
102
|
+
browseName: name,
|
|
103
|
+
nodeId: ctt.nodeId(`Static/All Profiles/Scalar/Bool/${name}`),
|
|
104
|
+
dataType: "String",
|
|
105
|
+
value: new Variant({ dataType: DataType.String, value: `${name} of the static Boolean` })
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
ctt.typedVariable("Static/All Profiles/Arrays/Bool", "Boolean", 1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function addImageVariable(ctt: CttFolder): void {
|
|
112
|
+
// the abstract Image data type (the concrete ImagePNG/GIF/JPG/BMP nodes live under Simulation)
|
|
113
|
+
let png: Buffer;
|
|
114
|
+
try {
|
|
115
|
+
png = fs.readFileSync(path.join(__dirname, "../../data", "tux.png"));
|
|
116
|
+
} catch (_err) {
|
|
117
|
+
png = Buffer.from("89504e470d0a1a0a", "hex");
|
|
118
|
+
}
|
|
119
|
+
ctt.variable("Static/All Profiles/Scalar/Image", "Image", -1, new Variant({ dataType: DataType.ByteString, value: png }), null);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function addStructureVariables(ctt: CttFolder): void {
|
|
123
|
+
const addressSpace = ctt.addressSpace;
|
|
124
|
+
const structures: { dataType: string; fields: Record<string, unknown> }[] = [
|
|
125
|
+
{ dataType: "Range", fields: { low: -10, high: 10 } },
|
|
126
|
+
{
|
|
127
|
+
dataType: "EUInformation",
|
|
128
|
+
fields: {
|
|
129
|
+
namespaceUri: "http://www.opcfoundation.org/UA/units/un/cefact",
|
|
130
|
+
unitId: 4408652,
|
|
131
|
+
displayName: { text: "°C" },
|
|
132
|
+
description: { text: "degree Celsius" }
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{ dataType: "TimeZoneDataType", fields: { offset: 60, daylightSavingInOffset: false } },
|
|
136
|
+
{
|
|
137
|
+
dataType: "EnumValueType",
|
|
138
|
+
fields: { value: [1, 0], displayName: { text: "One" }, description: { text: "the value one" } }
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
dataType: "Argument",
|
|
142
|
+
fields: {
|
|
143
|
+
name: "arg",
|
|
144
|
+
dataType: "i=6",
|
|
145
|
+
valueRank: -1,
|
|
146
|
+
arrayDimensions: null,
|
|
147
|
+
description: { text: "an Int32 argument" }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
];
|
|
151
|
+
structures.forEach((s, i) => {
|
|
152
|
+
const dataTypeNode = addressSpace.findDataType(s.dataType);
|
|
153
|
+
if (!dataTypeNode) throw new Error(`DataType ${s.dataType} not found`);
|
|
154
|
+
const value = addressSpace.constructExtensionObject(dataTypeNode, s.fields);
|
|
155
|
+
ctt.variable(
|
|
156
|
+
`Static/All Profiles/Structures/Structure${String(i + 1).padStart(3, "0")}`,
|
|
157
|
+
s.dataType,
|
|
158
|
+
-1,
|
|
159
|
+
new Variant({ dataType: DataType.ExtensionObject, value }),
|
|
160
|
+
null
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Static / DA Profile ────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
function addAnalogItemArrays(ctt: CttFolder): void {
|
|
168
|
+
const folder = ctt.folder("Static/DA Profile/AnalogItemType Arrays");
|
|
169
|
+
for (const dataTypeName of ["Double", "Float", "Int16", "Int32", "UInt16", "UInt32"]) {
|
|
170
|
+
const dataType = DataType[dataTypeName as keyof typeof DataType];
|
|
171
|
+
ctt.namespace.addAnalogDataItem({
|
|
172
|
+
organizedBy: folder,
|
|
173
|
+
browseName: dataTypeName,
|
|
174
|
+
nodeId: ctt.nodeId(`Static/DA Profile/AnalogItemType Arrays/${dataTypeName}`),
|
|
175
|
+
dataType: dataTypeName,
|
|
176
|
+
valueRank: 1,
|
|
177
|
+
arrayDimensions: [5],
|
|
178
|
+
arrayType: VariantArrayType.Array,
|
|
179
|
+
engineeringUnitsRange: { low: -100, high: 100 },
|
|
180
|
+
instrumentRange: { low: -1000, high: 1000 },
|
|
181
|
+
engineeringUnits: standardUnits.degree_celsius,
|
|
182
|
+
value: new Variant({ dataType, arrayType: VariantArrayType.Array, value: buildVariantArray(dataType, 5, 0) }),
|
|
183
|
+
accessLevel: readWrite,
|
|
184
|
+
userAccessLevel: readWrite
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function axis(addressSpace: AddressSpace, title: string, low: number, high: number) {
|
|
190
|
+
const axisInformation = addressSpace.findDataType("AxisInformation");
|
|
191
|
+
if (!axisInformation) throw new Error("AxisInformation not found");
|
|
192
|
+
return addressSpace.constructExtensionObject(axisInformation, {
|
|
193
|
+
engineeringUnits: standardUnits.second,
|
|
194
|
+
euRange: { low, high },
|
|
195
|
+
title: { text: title },
|
|
196
|
+
axisScaleType: 0, // Linear
|
|
197
|
+
axisSteps: null
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The five ArrayItemType subtypes: plain variables of the right VariableType,
|
|
203
|
+
* given the mandatory ArrayItemType properties by hand. instantiate() refuses
|
|
204
|
+
* a ValueRank that differs from the type's (ImageItemType and CubeItemType
|
|
205
|
+
* need 2 and 3) and namespace.addYArrayItem() ignores nodeId and organizedBy.
|
|
206
|
+
*/
|
|
207
|
+
function addArrayItems(ctt: CttFolder): void {
|
|
208
|
+
const addressSpace = ctt.addressSpace;
|
|
209
|
+
const namespace = ctt.namespace;
|
|
210
|
+
const group = "Static/DA Profile/ArrayItemType";
|
|
211
|
+
const folder = ctt.folder(group);
|
|
212
|
+
const xAxis = axis(addressSpace, "x", 0, 10);
|
|
213
|
+
const yAxis = axis(addressSpace, "y", 0, 20);
|
|
214
|
+
const zAxis = axis(addressSpace, "z", 0, 30);
|
|
215
|
+
const rangeType = addressSpace.findDataType("Range");
|
|
216
|
+
const xvType = addressSpace.findDataType("XVType");
|
|
217
|
+
if (!rangeType || !xvType) throw new Error("Range or XVType not found");
|
|
218
|
+
|
|
219
|
+
const extensionObject = (value: unknown) => new Variant({ dataType: DataType.ExtensionObject, value });
|
|
220
|
+
const arrayItem = (
|
|
221
|
+
typeName: string,
|
|
222
|
+
options: { dataType: string; valueRank: number; arrayDimensions: number[]; value: Variant },
|
|
223
|
+
axes: Record<string, Variant>
|
|
224
|
+
): UAVariable => {
|
|
225
|
+
const v = namespace.addVariable({
|
|
226
|
+
organizedBy: folder,
|
|
227
|
+
browseName: typeName,
|
|
228
|
+
nodeId: ctt.nodeId(`${group}/${typeName}`),
|
|
229
|
+
typeDefinition: typeName,
|
|
230
|
+
dataType: options.dataType,
|
|
231
|
+
valueRank: options.valueRank,
|
|
232
|
+
arrayDimensions: options.arrayDimensions,
|
|
233
|
+
accessLevel: readWrite,
|
|
234
|
+
userAccessLevel: readWrite,
|
|
235
|
+
value: options.value
|
|
236
|
+
});
|
|
237
|
+
const property = (browseName: string, dataType: string, value: Variant, valueRank = -1) =>
|
|
238
|
+
namespace.addVariable({ propertyOf: v, browseName, dataType, valueRank, value, modellingRule: "Mandatory" });
|
|
239
|
+
property("Title", "LocalizedText", new Variant({ dataType: DataType.LocalizedText, value: { text: typeName } }));
|
|
240
|
+
property("AxisScaleType", "AxisScaleEnumeration", new Variant({ dataType: DataType.Int32, value: 0 }));
|
|
241
|
+
property("EURange", "Range", extensionObject(addressSpace.constructExtensionObject(rangeType, { low: 0, high: 100 })));
|
|
242
|
+
property(
|
|
243
|
+
"InstrumentRange",
|
|
244
|
+
"Range",
|
|
245
|
+
extensionObject(addressSpace.constructExtensionObject(rangeType, { low: 0, high: 1000 }))
|
|
246
|
+
);
|
|
247
|
+
property("EngineeringUnits", "EUInformation", extensionObject(standardUnits.degree_celsius));
|
|
248
|
+
for (const [name, value] of Object.entries(axes)) {
|
|
249
|
+
property(name, "AxisInformation", value, value.arrayType === VariantArrayType.Array ? 1 : -1);
|
|
250
|
+
}
|
|
251
|
+
return v;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
arrayItem(
|
|
255
|
+
"YArrayItemType",
|
|
256
|
+
{
|
|
257
|
+
dataType: "Double",
|
|
258
|
+
valueRank: 1,
|
|
259
|
+
arrayDimensions: [5],
|
|
260
|
+
value: new Variant({
|
|
261
|
+
dataType: DataType.Double,
|
|
262
|
+
arrayType: VariantArrayType.Array,
|
|
263
|
+
value: new Float64Array([1, 2, 3, 4, 5])
|
|
264
|
+
})
|
|
265
|
+
},
|
|
266
|
+
{ XAxisDefinition: extensionObject(xAxis) }
|
|
267
|
+
);
|
|
268
|
+
arrayItem(
|
|
269
|
+
"XYArrayItemType",
|
|
270
|
+
{
|
|
271
|
+
dataType: "XVType",
|
|
272
|
+
valueRank: 1,
|
|
273
|
+
arrayDimensions: [3],
|
|
274
|
+
value: new Variant({
|
|
275
|
+
dataType: DataType.ExtensionObject,
|
|
276
|
+
arrayType: VariantArrayType.Array,
|
|
277
|
+
value: [0, 1, 2].map((i) => addressSpace.constructExtensionObject(xvType, { x: i, value: i * 2 }))
|
|
278
|
+
})
|
|
279
|
+
},
|
|
280
|
+
{ XAxisDefinition: extensionObject(xAxis) }
|
|
281
|
+
);
|
|
282
|
+
arrayItem(
|
|
283
|
+
"ImageItemType",
|
|
284
|
+
{
|
|
285
|
+
dataType: "UInt16",
|
|
286
|
+
valueRank: 2,
|
|
287
|
+
arrayDimensions: [2, 3],
|
|
288
|
+
value: new Variant({
|
|
289
|
+
dataType: DataType.UInt16,
|
|
290
|
+
arrayType: VariantArrayType.Matrix,
|
|
291
|
+
dimensions: [2, 3],
|
|
292
|
+
value: new Uint16Array([1, 2, 3, 4, 5, 6])
|
|
293
|
+
})
|
|
294
|
+
},
|
|
295
|
+
{ XAxisDefinition: extensionObject(xAxis), YAxisDefinition: extensionObject(yAxis) }
|
|
296
|
+
);
|
|
297
|
+
arrayItem(
|
|
298
|
+
"CubeItemType",
|
|
299
|
+
{
|
|
300
|
+
dataType: "Double",
|
|
301
|
+
valueRank: 3,
|
|
302
|
+
arrayDimensions: [2, 2, 2],
|
|
303
|
+
value: new Variant({
|
|
304
|
+
dataType: DataType.Double,
|
|
305
|
+
arrayType: VariantArrayType.Matrix,
|
|
306
|
+
dimensions: [2, 2, 2],
|
|
307
|
+
value: new Float64Array([1, 2, 3, 4, 5, 6, 7, 8])
|
|
308
|
+
})
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
XAxisDefinition: extensionObject(xAxis),
|
|
312
|
+
YAxisDefinition: extensionObject(yAxis),
|
|
313
|
+
ZAxisDefinition: extensionObject(zAxis)
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
arrayItem(
|
|
317
|
+
"NDimensionArrayItemType",
|
|
318
|
+
{
|
|
319
|
+
dataType: "Double",
|
|
320
|
+
valueRank: 2,
|
|
321
|
+
arrayDimensions: [2, 3],
|
|
322
|
+
value: new Variant({
|
|
323
|
+
dataType: DataType.Double,
|
|
324
|
+
arrayType: VariantArrayType.Matrix,
|
|
325
|
+
dimensions: [2, 3],
|
|
326
|
+
value: new Float64Array([1, 2, 3, 4, 5, 6])
|
|
327
|
+
})
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
AxisDefinition: new Variant({
|
|
331
|
+
dataType: DataType.ExtensionObject,
|
|
332
|
+
arrayType: VariantArrayType.Array,
|
|
333
|
+
value: [xAxis, yAxis]
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ─── Static / HA Profile ────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
const HA_TYPES = [
|
|
342
|
+
"Bool",
|
|
343
|
+
"Byte",
|
|
344
|
+
"ByteString",
|
|
345
|
+
"DateTime",
|
|
346
|
+
"Double",
|
|
347
|
+
"Float",
|
|
348
|
+
"Int16",
|
|
349
|
+
"Int32",
|
|
350
|
+
"Int64",
|
|
351
|
+
"SByte",
|
|
352
|
+
"String",
|
|
353
|
+
"UInt16",
|
|
354
|
+
"UInt32",
|
|
355
|
+
"UInt64",
|
|
356
|
+
"XmlElement"
|
|
357
|
+
];
|
|
358
|
+
|
|
359
|
+
/** a variable with in-memory history and a first value, so reads are Good with timestamps */
|
|
360
|
+
function historize(ctt: CttFolder, variable: UAVariable): UAVariable {
|
|
361
|
+
ctt.addressSpace.installHistoricalDataNode(variable);
|
|
362
|
+
const current = variable.readValue().value;
|
|
363
|
+
variable.setValueFromSource(current);
|
|
364
|
+
return variable;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function addHistorizingVariables(ctt: CttFolder): void {
|
|
368
|
+
for (const leaf of HA_TYPES) {
|
|
369
|
+
const dataTypeName = TYPE_ALIAS[leaf] ?? leaf;
|
|
370
|
+
historize(ctt, ctt.typedVariable(`Static/HA Profile/Scalar/${leaf}`, dataTypeName, -1));
|
|
371
|
+
historize(ctt, ctt.typedVariable(`Static/HA Profile/Arrays/${leaf}`, dataTypeName, 1));
|
|
372
|
+
historize(ctt, ctt.typedVariable(`Static/HA Profile/Arrays/${leaf}2D`, dataTypeName, 2));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const range = ctt.addressSpace.findDataType("Range");
|
|
376
|
+
if (!range) throw new Error("Range not found");
|
|
377
|
+
historize(
|
|
378
|
+
ctt,
|
|
379
|
+
ctt.variable(
|
|
380
|
+
"Static/HA Profile/StructureNodeSupportingHistory",
|
|
381
|
+
"Range",
|
|
382
|
+
-1,
|
|
383
|
+
new Variant({
|
|
384
|
+
dataType: DataType.ExtensionObject,
|
|
385
|
+
value: ctt.addressSpace.constructExtensionObject(range, { low: 0, high: 1 })
|
|
386
|
+
}),
|
|
387
|
+
null
|
|
388
|
+
)
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
// AccessLevel_* : the AccessLevel attribute; UserAccessLevel_* : the UserAccessLevel attribute
|
|
392
|
+
const history = AccessLevelFlag.HistoryRead;
|
|
393
|
+
const flags: Record<string, number> = {
|
|
394
|
+
ReadOnly: AccessLevelFlag.CurrentRead | history,
|
|
395
|
+
WriteOnly: AccessLevelFlag.CurrentWrite | history,
|
|
396
|
+
None: history
|
|
397
|
+
};
|
|
398
|
+
for (const attribute of ["AccessLevel", "UserAccessLevel"]) {
|
|
399
|
+
for (const [kind, flag] of Object.entries(flags)) {
|
|
400
|
+
const relPath = `Static/HA Profile/AccessRights/${attribute}_${kind}`;
|
|
401
|
+
const i = relPath.lastIndexOf("/");
|
|
402
|
+
const full = AccessLevelFlag.CurrentRead | AccessLevelFlag.CurrentWrite | history;
|
|
403
|
+
const v = ctt.namespace.addVariable({
|
|
404
|
+
componentOf: ctt.folder(relPath.slice(0, i)),
|
|
405
|
+
browseName: relPath.slice(i + 1),
|
|
406
|
+
nodeId: ctt.nodeId(relPath),
|
|
407
|
+
description: `CTT setting ${relPath}`,
|
|
408
|
+
dataType: "Int32",
|
|
409
|
+
valueRank: -1,
|
|
410
|
+
accessLevel: full,
|
|
411
|
+
userAccessLevel: full,
|
|
412
|
+
value: new Variant({ dataType: DataType.Int32, value: 36 })
|
|
413
|
+
});
|
|
414
|
+
historize(ctt, v);
|
|
415
|
+
// installHistoricalDataNode() adds CurrentRead to both attributes: set the flags after it
|
|
416
|
+
v.accessLevel = attribute === "AccessLevel" ? flag : full;
|
|
417
|
+
v.userAccessLevel = attribute === "UserAccessLevel" ? flag : full;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ─── entry point ────────────────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
export function addCttFolder(namespace: Namespace, objectsFolder: UAObject): UAObject {
|
|
425
|
+
const root = namespace.addFolder(objectsFolder, {
|
|
426
|
+
browseName: "CTT",
|
|
427
|
+
nodeId: "s=CTT",
|
|
428
|
+
description: "Nodes named after the settings of the OPC Foundation Compliance Test Tool server project"
|
|
429
|
+
});
|
|
430
|
+
const ctt = new CttFolder(namespace, root);
|
|
431
|
+
addVariantVariables(ctt);
|
|
432
|
+
addBooleanScalarAndArray(ctt);
|
|
433
|
+
addImageVariable(ctt);
|
|
434
|
+
addStructureVariables(ctt);
|
|
435
|
+
addAnalogItemArrays(ctt);
|
|
436
|
+
addArrayItems(ctt);
|
|
437
|
+
addHistorizingVariables(ctt);
|
|
438
|
+
addAlarmInputNodes(ctt);
|
|
439
|
+
return root;
|
|
440
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The node tree below `Objects/CTT`, shared by the builders that populate it.
|
|
3
|
+
*
|
|
4
|
+
* A CTT project generator fills a NodeId setting by browsing: the browse path
|
|
5
|
+
* below `Objects/CTT` mirrors the setting path, with only the
|
|
6
|
+
* "Server Test/NodeIds/" prefix stripped. So
|
|
7
|
+
* Server Test/NodeIds/Static/HA Profile/Arrays/Int162D
|
|
8
|
+
* is served by
|
|
9
|
+
* Objects/CTT/Static/HA Profile/Arrays/Int162D
|
|
10
|
+
* while a setting outside that prefix keeps its whole path, e.g.
|
|
11
|
+
* Server Test/Alarms and Conditions/Supported Condition Types/LimitAlarmType Input Nodes
|
|
12
|
+
* is served by
|
|
13
|
+
* Objects/CTT/Server Test/Alarms and Conditions/Supported Condition Types/LimitAlarmType Input Nodes
|
|
14
|
+
*/
|
|
15
|
+
import type { AddressSpace, Namespace, UAObject, UAVariable } from "node-opcua-address-space";
|
|
16
|
+
import { makeAccessLevelFlag } from "node-opcua-data-model";
|
|
17
|
+
import { buildVariantArray, DataType, Variant, VariantArrayType } from "node-opcua-variant";
|
|
18
|
+
|
|
19
|
+
import { typeAndDefaultValue } from "./type_defaults.js";
|
|
20
|
+
|
|
21
|
+
export const readWrite = makeAccessLevelFlag("CurrentRead | CurrentWrite");
|
|
22
|
+
|
|
23
|
+
/** setting leaf -> OPC UA DataType name */
|
|
24
|
+
export const TYPE_ALIAS: Record<string, string> = { Bool: "Boolean" };
|
|
25
|
+
|
|
26
|
+
function defaultValueOf(dataTypeName: string): unknown {
|
|
27
|
+
const e = typeAndDefaultValue.find((t) => t.type === dataTypeName);
|
|
28
|
+
if (!e) throw new Error(`no default value for ${dataTypeName}`);
|
|
29
|
+
return typeof e.defaultValue === "function" ? e.defaultValue() : e.defaultValue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function variantFor(dataTypeName: string, valueRank: number): { variant: Variant; arrayDimensions: number[] | null } {
|
|
33
|
+
const dataType = DataType[dataTypeName as keyof typeof DataType];
|
|
34
|
+
const defaultValue = defaultValueOf(dataTypeName);
|
|
35
|
+
if (valueRank === -1) {
|
|
36
|
+
return {
|
|
37
|
+
variant: new Variant({ dataType, arrayType: VariantArrayType.Scalar, value: defaultValue }),
|
|
38
|
+
arrayDimensions: null
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const dimensions = valueRank === 1 ? [5] : [2, 3];
|
|
42
|
+
const length = dimensions.reduce((a, b) => a * b, 1);
|
|
43
|
+
return {
|
|
44
|
+
variant: new Variant({
|
|
45
|
+
dataType,
|
|
46
|
+
arrayType: valueRank === 1 ? VariantArrayType.Array : VariantArrayType.Matrix,
|
|
47
|
+
dimensions: valueRank === 1 ? null : dimensions,
|
|
48
|
+
value: buildVariantArray(dataType, length, defaultValue)
|
|
49
|
+
}),
|
|
50
|
+
arrayDimensions: dimensions
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class CttFolder {
|
|
55
|
+
private readonly folders = new Map<string, UAObject>();
|
|
56
|
+
|
|
57
|
+
constructor(
|
|
58
|
+
readonly namespace: Namespace,
|
|
59
|
+
root: UAObject
|
|
60
|
+
) {
|
|
61
|
+
this.folders.set("", root);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get addressSpace(): AddressSpace {
|
|
65
|
+
return this.namespace.addressSpace as AddressSpace;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** the folder for a setting group, created on demand, e.g. "Static/HA Profile/Arrays" */
|
|
69
|
+
folder(relPath: string): UAObject {
|
|
70
|
+
const existing = this.folders.get(relPath);
|
|
71
|
+
if (existing) return existing;
|
|
72
|
+
const i = relPath.lastIndexOf("/");
|
|
73
|
+
const parent = this.folder(i < 0 ? "" : relPath.slice(0, i));
|
|
74
|
+
const browseName = i < 0 ? relPath : relPath.slice(i + 1);
|
|
75
|
+
const f = this.namespace.addFolder(parent, { browseName, nodeId: `s=CTT/${relPath}` });
|
|
76
|
+
this.folders.set(relPath, f);
|
|
77
|
+
return f;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
nodeId(relPath: string): string {
|
|
81
|
+
return `s=CTT/${relPath}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** a plain read/write variable at the given setting path */
|
|
85
|
+
variable(relPath: string, dataType: string, valueRank: number, value: Variant, arrayDimensions: number[] | null): UAVariable {
|
|
86
|
+
const i = relPath.lastIndexOf("/");
|
|
87
|
+
return this.namespace.addVariable({
|
|
88
|
+
componentOf: this.folder(relPath.slice(0, i)),
|
|
89
|
+
browseName: relPath.slice(i + 1),
|
|
90
|
+
nodeId: this.nodeId(relPath),
|
|
91
|
+
description: `CTT setting ${relPath}`,
|
|
92
|
+
dataType,
|
|
93
|
+
valueRank,
|
|
94
|
+
arrayDimensions,
|
|
95
|
+
accessLevel: readWrite,
|
|
96
|
+
userAccessLevel: readWrite,
|
|
97
|
+
value
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
typedVariable(relPath: string, dataTypeName: string, valueRank: number): UAVariable {
|
|
102
|
+
const { variant, arrayDimensions } = variantFor(dataTypeName, valueRank);
|
|
103
|
+
return this.variable(relPath, dataTypeName, valueRank, variant, arrayDimensions);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The concrete ExtensionObject the Static, Dynamic and Mass folders hold.
|
|
3
|
+
*
|
|
4
|
+
* This package cannot choose one on its own. Namespace zero has no concrete
|
|
5
|
+
* structure worth exercising here - `Structure` (i=22) is abstract and the
|
|
6
|
+
* rest are service types - and a usable one lives in a companion nodeset that
|
|
7
|
+
* only the caller knows it has loaded. So the caller passes the behaviour in:
|
|
8
|
+
* which DataType to build, and how to move its fields for the Dynamic folder.
|
|
9
|
+
*
|
|
10
|
+
* Without these options every ExtensionObject variable keeps a null
|
|
11
|
+
* ExtensionObject, which is what the package did before and what its own tests
|
|
12
|
+
* (namespace zero only) still exercise.
|
|
13
|
+
*
|
|
14
|
+
* A CTT server that has loaded the AutoID nodeset, for instance:
|
|
15
|
+
*
|
|
16
|
+
* build_address_space_for_conformance_testing(addressSpace, {
|
|
17
|
+
* extensionObject: {
|
|
18
|
+
* dataType: "RfidSighting",
|
|
19
|
+
* randomFields: () => ({
|
|
20
|
+
* antenna: 1 + Math.floor(Math.random() * 4),
|
|
21
|
+
* strength: -80 + Math.floor(Math.random() * 60),
|
|
22
|
+
* currentPowerLevel: 10 + Math.floor(Math.random() * 20),
|
|
23
|
+
* timestamp: new Date()
|
|
24
|
+
* })
|
|
25
|
+
* }
|
|
26
|
+
* });
|
|
27
|
+
*/
|
|
28
|
+
import type { AddressSpace } from "node-opcua-address-space";
|
|
29
|
+
import type { NodeIdLike } from "node-opcua-nodeid";
|
|
30
|
+
|
|
31
|
+
export interface CustomExtensionObjectOptions {
|
|
32
|
+
/**
|
|
33
|
+
* DataType of the structure to build: a browse name ("RfidSighting") or a
|
|
34
|
+
* NodeId, in a namespace the caller has already loaded. A DataType this
|
|
35
|
+
* address space cannot find is an error - a silent fallback to null would
|
|
36
|
+
* look like the option had been ignored.
|
|
37
|
+
*/
|
|
38
|
+
dataType: NodeIdLike;
|
|
39
|
+
/** field values of the static instances; the DataType's own defaults when omitted */
|
|
40
|
+
initialFields?: Record<string, unknown>;
|
|
41
|
+
/** field values for the Dynamic folder, called on every simulation tick */
|
|
42
|
+
randomFields?: () => Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CustomExtensionObject {
|
|
46
|
+
/** browse name of the structure, for descriptions */
|
|
47
|
+
readonly typeName: string;
|
|
48
|
+
/** a fresh instance carrying the initial field values */
|
|
49
|
+
initialValue(): unknown;
|
|
50
|
+
/** a fresh instance carrying new field values, or the initial one when no randomFields was given */
|
|
51
|
+
random(): unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `findDataType("RfidSighting")` searches namespace zero only, and a companion
|
|
56
|
+
* structure is never there. A bare browse name is therefore looked up in every
|
|
57
|
+
* loaded namespace, so the caller does not have to know the index the nodeset
|
|
58
|
+
* happened to land on. A NodeId, or a "1:RfidSighting" qualified name, still
|
|
59
|
+
* resolves directly.
|
|
60
|
+
*/
|
|
61
|
+
function findDataTypeAnywhere(addressSpace: AddressSpace, dataType: NodeIdLike) {
|
|
62
|
+
const direct = addressSpace.findDataType(dataType as string);
|
|
63
|
+
if (direct) return direct;
|
|
64
|
+
if (typeof dataType !== "string" || dataType.includes("=") || dataType.includes(":")) return null;
|
|
65
|
+
for (let index = 1; index < addressSpace.getNamespaceArray().length; index++) {
|
|
66
|
+
const found = addressSpace.findDataType(dataType, index);
|
|
67
|
+
if (found) return found;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** one row of the typeAndDefaultValue table, as the builders see it */
|
|
73
|
+
interface TypeEntry {
|
|
74
|
+
type: string;
|
|
75
|
+
realType?: string;
|
|
76
|
+
defaultValue: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The initial value of a typeAndDefaultValue row, with the caller's structure
|
|
81
|
+
* standing in for the ExtensionObject row when one was supplied.
|
|
82
|
+
*/
|
|
83
|
+
export function initialValueFor(entry: TypeEntry, custom?: CustomExtensionObject): unknown {
|
|
84
|
+
if (custom && (entry.realType ?? entry.type) === "ExtensionObject") return custom.initialValue();
|
|
85
|
+
return typeof entry.defaultValue === "function" ? (entry.defaultValue as () => unknown)() : entry.defaultValue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resolveCustomExtensionObject(
|
|
89
|
+
addressSpace: AddressSpace,
|
|
90
|
+
options?: CustomExtensionObjectOptions
|
|
91
|
+
): CustomExtensionObject | undefined {
|
|
92
|
+
if (!options) return undefined;
|
|
93
|
+
const dataType = findDataTypeAnywhere(addressSpace, options.dataType);
|
|
94
|
+
if (!dataType) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`extensionObject.dataType ${options.dataType} is not in the address space: load the nodeset that defines it first`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const build = (fields?: Record<string, unknown>) => addressSpace.constructExtensionObject(dataType, fields ?? {});
|
|
100
|
+
return {
|
|
101
|
+
typeName: dataType.browseName.name ?? String(options.dataType),
|
|
102
|
+
initialValue: () => build(options.initialFields),
|
|
103
|
+
random: () => build(options.randomFields ? options.randomFields() : options.initialFields)
|
|
104
|
+
};
|
|
105
|
+
}
|