node-opcua-alias-name-server 2.176.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 +22 -0
- package/README.md +361 -0
- package/dist/add_alias.d.ts +73 -0
- package/dist/add_alias.js +262 -0
- package/dist/add_alias.js.map +1 -0
- package/dist/address_space_alias_store.d.ts +130 -0
- package/dist/address_space_alias_store.js +360 -0
- package/dist/address_space_alias_store.js.map +1 -0
- package/dist/alias_hierarchy.d.ts +45 -0
- package/dist/alias_hierarchy.js +135 -0
- package/dist/alias_hierarchy.js.map +1 -0
- package/dist/alias_index.d.ts +60 -0
- package/dist/alias_index.js +119 -0
- package/dist/alias_index.js.map +1 -0
- package/dist/alias_name_archive.d.ts +45 -0
- package/dist/alias_name_archive.js +75 -0
- package/dist/alias_name_archive.js.map +1 -0
- package/dist/bind_alias_category.d.ts +173 -0
- package/dist/bind_alias_category.js +417 -0
- package/dist/bind_alias_category.js.map +1 -0
- package/dist/bind_configuration_methods.d.ts +44 -0
- package/dist/bind_configuration_methods.js +175 -0
- package/dist/bind_configuration_methods.js.map +1 -0
- package/dist/bind_find_alias.d.ts +61 -0
- package/dist/bind_find_alias.js +227 -0
- package/dist/bind_find_alias.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/install_alias_names.d.ts +229 -0
- package/dist/install_alias_names.js +146 -0
- package/dist/install_alias_names.js.map +1 -0
- package/dist/last_change.d.ts +87 -0
- package/dist/last_change.js +174 -0
- package/dist/last_change.js.map +1 -0
- package/dist/well_known.d.ts +88 -0
- package/dist/well_known.js +93 -0
- package/dist/well_known.js.map +1 -0
- package/package.json +54 -0
- package/source/add_alias.ts +318 -0
- package/source/address_space_alias_store.ts +417 -0
- package/source/alias_hierarchy.ts +141 -0
- package/source/alias_index.ts +127 -0
- package/source/alias_name_archive.ts +84 -0
- package/source/bind_alias_category.ts +546 -0
- package/source/bind_configuration_methods.ts +219 -0
- package/source/bind_find_alias.ts +290 -0
- package/source/index.ts +74 -0
- package/source/install_alias_names.ts +342 -0
- package/source/last_change.ts +201 -0
- package/source/well_known.ts +101 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node-opcua-alias-name-server
|
|
3
|
+
*
|
|
4
|
+
* Install OPC 10000-17 AliasName support on a Server.
|
|
5
|
+
*
|
|
6
|
+
* Every node-opcua Server that loads the standard nodeset already exposes
|
|
7
|
+
* `Aliases`, `TagVariables` and `Topics`, each carrying a MANDATORY `FindAlias`
|
|
8
|
+
* Method that is bound to nothing. A conformance tester therefore sees the SDK
|
|
9
|
+
* advertise the AliasName feature and then fail its only required Method. This
|
|
10
|
+
* binds them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { IAddressSpace, ISessionContext, UAObject } from "node-opcua-address-space-base";
|
|
14
|
+
import type { IAliasStore, LikeOptions } from "node-opcua-alias-name-common";
|
|
15
|
+
import type { NodeId } from "node-opcua-nodeid";
|
|
16
|
+
import { AddressSpaceAliasStore } from "./address_space_alias_store.js";
|
|
17
|
+
import { collectAllCategories } from "./alias_hierarchy.js";
|
|
18
|
+
import {
|
|
19
|
+
type BindAliasCategoryOptions,
|
|
20
|
+
bindAliasCategory,
|
|
21
|
+
getInstalledAliasNames,
|
|
22
|
+
setInstalledAliasNames
|
|
23
|
+
} from "./bind_alias_category.js";
|
|
24
|
+
import type { AliasComparator } from "./bind_find_alias.js";
|
|
25
|
+
import { LastChangeTracker } from "./last_change.js";
|
|
26
|
+
import { DEFAULT_MAX_RESULTS, WellKnownCategories } from "./well_known.js";
|
|
27
|
+
|
|
28
|
+
export { DEFAULT_MAX_RESULTS };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Supplies the `AliasNameCategoryType` instances to bind.
|
|
32
|
+
*
|
|
33
|
+
* Defaults to {@link defaultCategoryProvider}, which walks down from `Aliases`.
|
|
34
|
+
* Replace it when the category set is not a static tree — one category per
|
|
35
|
+
* customer, one per upstream Server, categories that appear and disappear at
|
|
36
|
+
* runtime. May be asynchronous, since the set may have to be fetched.
|
|
37
|
+
*
|
|
38
|
+
* A provider only decides *what installation binds*. Categories created after
|
|
39
|
+
* installation are bound with {@link bindAliasCategory}, or created already
|
|
40
|
+
* bound with {@link addAliasCategory}.
|
|
41
|
+
*/
|
|
42
|
+
export type CategoryProvider = (addressSpace: IAddressSpace) => UAObject[] | Promise<UAObject[]>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The default provider: every `AliasNameCategoryType` instance at or below
|
|
46
|
+
* `Aliases`, plus any roots named in `additionalCategoryRoots`.
|
|
47
|
+
*
|
|
48
|
+
* `additionalCategoryRoots` is expressed through this rather than as a parallel
|
|
49
|
+
* mechanism, so a custom provider can reuse it — `defaultCategoryProvider(roots)`
|
|
50
|
+
* composes with whatever else the Server knows about.
|
|
51
|
+
*/
|
|
52
|
+
export function defaultCategoryProvider(additionalRoots?: Array<NodeId | UAObject>): CategoryProvider {
|
|
53
|
+
return (addressSpace: IAddressSpace) => collectAllCategories(addressSpace, additionalRoots);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface InstallAliasNamesOptions {
|
|
57
|
+
/**
|
|
58
|
+
* Where aliases come from. Defaults to an {@link AddressSpaceAliasStore},
|
|
59
|
+
* which reads them straight out of the address space — so a Server whose
|
|
60
|
+
* NodeSet2.xml already models `AliasNameType` instances needs no options at
|
|
61
|
+
* all.
|
|
62
|
+
*/
|
|
63
|
+
store?: IAliasStore;
|
|
64
|
+
/** Result cap per call (clause 6.3.2 Table 4). Defaults to {@link DEFAULT_MAX_RESULTS}. */
|
|
65
|
+
maxResults?: number;
|
|
66
|
+
/**
|
|
67
|
+
* Also bind `FindAliasVerbose` (clause 6.3.3, conformance unit
|
|
68
|
+
* AliasName FindAliasVerbose). On by default: it costs one extra Method per
|
|
69
|
+
* category and is what lets a Client see which category held a hit.
|
|
70
|
+
*/
|
|
71
|
+
verbose?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Also expose `AddAliasesToCategory` and `DeleteAliasesFromCategory`
|
|
74
|
+
* (clauses 6.3.4 and 6.3.5, conformance unit *AliasName Configuration
|
|
75
|
+
* Support*, CU 5874).
|
|
76
|
+
*
|
|
77
|
+
* **Off by default**: both Methods are Optional, and a write surface that
|
|
78
|
+
* appears without being asked for is a surface nobody reviewed. Turning it
|
|
79
|
+
* on is not by itself dangerous — every call is denied until
|
|
80
|
+
* {@link isWriteAllowed} says otherwise — but the Methods do become visible
|
|
81
|
+
* to a browsing Client.
|
|
82
|
+
*/
|
|
83
|
+
configurationMethods?: boolean;
|
|
84
|
+
/** Passed to the `Like` matcher used by the default store. */
|
|
85
|
+
likeOptions?: LikeOptions;
|
|
86
|
+
/** Result ordering (clause 6.3.2, "best match first"). */
|
|
87
|
+
comparator?: AliasComparator;
|
|
88
|
+
/**
|
|
89
|
+
* Read gate, consulted **per category** and allowed to be asynchronous.
|
|
90
|
+
* Defaults to allowing everyone, so a Server that simply publishes its
|
|
91
|
+
* aliases is unaffected.
|
|
92
|
+
*
|
|
93
|
+
* A direct call on a denied category answers `Bad_UserAccessDenied`; a
|
|
94
|
+
* denied category reached by a recursive search is omitted and the call
|
|
95
|
+
* still returns `Good`, so nothing reveals that it exists.
|
|
96
|
+
*
|
|
97
|
+
* OPC 10000-17 defines no security model — four `Bad_UserAccessDenied` rows,
|
|
98
|
+
* no Security clause, no Roles, and no `RolePermissions` on any Part 17 node
|
|
99
|
+
* in the standard nodeset — so every Server has to supply its own rule.
|
|
100
|
+
*/
|
|
101
|
+
isReadAllowed?: (context: ISessionContext, categoryNodeId: NodeId) => boolean | Promise<boolean>;
|
|
102
|
+
/**
|
|
103
|
+
* Write gate for the configuration Methods, mirroring
|
|
104
|
+
* {@link isReadAllowed}. **Defaults to denying everyone**: the write surface
|
|
105
|
+
* is the one place where a permissive default would be a security defect
|
|
106
|
+
* rather than a convenience.
|
|
107
|
+
*/
|
|
108
|
+
isWriteAllowed?: (context: ISessionContext, categoryNodeId: NodeId) => boolean | Promise<boolean>;
|
|
109
|
+
/**
|
|
110
|
+
* File backing the persisted `LastChange` values (clause 6.3.1: *"The
|
|
111
|
+
* LastChange shall be persisted"*).
|
|
112
|
+
*
|
|
113
|
+
* Without it, every restart resets `LastChange` to zero — and a Client that
|
|
114
|
+
* sees a value older than the one it cached is required by clause 6.3.1 to
|
|
115
|
+
* clear its cache. So an unpersisted Server silently orders every connected
|
|
116
|
+
* Client to discard a still-valid cache on every restart. Set this on any
|
|
117
|
+
* Server that Clients cache against.
|
|
118
|
+
*
|
|
119
|
+
* The file is small JSON: a version and a map of category NodeId to
|
|
120
|
+
* VersionTime. Writes are atomic.
|
|
121
|
+
*/
|
|
122
|
+
persistencePath?: string;
|
|
123
|
+
/**
|
|
124
|
+
* Add a `LastChange` Property to every category, not only the `Aliases` root.
|
|
125
|
+
*
|
|
126
|
+
* On by default. `LastChange` is Optional on `AliasNameCategoryType` and the
|
|
127
|
+
* shipped nodeset instantiates it only on the root, which clause 9.2 makes
|
|
128
|
+
* mandatory — but the clause 6.3.1 rollup is only observable where the
|
|
129
|
+
* Property exists, so a Client watching one branch needs it there. Turn it
|
|
130
|
+
* off to keep the address space exactly as the nodeset ships it.
|
|
131
|
+
*/
|
|
132
|
+
lastChangeOnAllCategories?: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* Supplies "now" as a VersionTime, for tests that need to pin it. A
|
|
135
|
+
* VersionTime has one-second resolution, which is otherwise awkward to
|
|
136
|
+
* assert against.
|
|
137
|
+
*/
|
|
138
|
+
nowVersionTime?: () => number;
|
|
139
|
+
/**
|
|
140
|
+
* Extra roots to search for `AliasNameCategoryType` instances.
|
|
141
|
+
*
|
|
142
|
+
* Categories are discovered by walking down from `Aliases`, which is where
|
|
143
|
+
* clause 9.1 puts them. Name a category here if the Server models one
|
|
144
|
+
* outside that hierarchy, otherwise its MANDATORY `FindAlias` stays unbound.
|
|
145
|
+
*
|
|
146
|
+
* Ignored when {@link categoryProvider} is supplied — compose it in with
|
|
147
|
+
* {@link defaultCategoryProvider} instead of passing both.
|
|
148
|
+
*/
|
|
149
|
+
additionalCategoryRoots?: Array<NodeId | UAObject>;
|
|
150
|
+
/**
|
|
151
|
+
* Replace category discovery entirely. See {@link CategoryProvider}.
|
|
152
|
+
*/
|
|
153
|
+
categoryProvider?: CategoryProvider;
|
|
154
|
+
/**
|
|
155
|
+
* Declare the `ALIAS` ServerCapability (OPC 10000-12 Annex D Table D.1).
|
|
156
|
+
* **On by default.**
|
|
157
|
+
*
|
|
158
|
+
* A Server that does not advertise it is never discovered by anything
|
|
159
|
+
* looking for alias-capable Servers, and nothing reports the failure — so
|
|
160
|
+
* leaving it to each caller to remember means it will sometimes be
|
|
161
|
+
* forgotten, invisibly. Installing the feature and declaring it are the same
|
|
162
|
+
* decision, so they happen together.
|
|
163
|
+
*
|
|
164
|
+
* **Call `installAliasNames` before `server.start()`.** The address space
|
|
165
|
+
* exists from `initialize()` onwards, and mDNS/LDS registration reads the
|
|
166
|
+
* capability list during `start()` — afterwards the list is still correct
|
|
167
|
+
* for anything reading `ServerConfiguration`, but the registration has
|
|
168
|
+
* already gone out without it.
|
|
169
|
+
*
|
|
170
|
+
* Set false if the Server manages its own capability list.
|
|
171
|
+
*/
|
|
172
|
+
advertiseCapability?: boolean;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface InstallAliasNamesResult {
|
|
176
|
+
/** The store that was used — the injected one, or the default. */
|
|
177
|
+
store: IAliasStore;
|
|
178
|
+
/** Every `AliasNameCategoryType` instance that had its Methods bound. */
|
|
179
|
+
categories: NodeId[];
|
|
180
|
+
/** True when this call did the work; false when AliasNames were already installed. */
|
|
181
|
+
installed: boolean;
|
|
182
|
+
/**
|
|
183
|
+
* Keeps `LastChange` correct across the hierarchy, and persists it
|
|
184
|
+
* (clause 6.3.1).
|
|
185
|
+
*/
|
|
186
|
+
lastChange?: LastChangeTracker;
|
|
187
|
+
/**
|
|
188
|
+
* The options every category was bound with.
|
|
189
|
+
*
|
|
190
|
+
* Pass these to {@link bindAliasCategory} to bind a category created later
|
|
191
|
+
* exactly as the installed ones were bound, without reassembling them by
|
|
192
|
+
* hand and risking a different store or result cap.
|
|
193
|
+
*/
|
|
194
|
+
bindingOptions: BindAliasCategoryOptions;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The server-like object we need: the address space, and the capability list. */
|
|
198
|
+
export interface IServerForAliasNames {
|
|
199
|
+
engine: {
|
|
200
|
+
addressSpace: IAddressSpace | null;
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* The Server's OPC 10000-12 Annex D capability identifiers — on an
|
|
204
|
+
* `OPCUAServer` this is `capabilitiesForMDNS`. Optional so a caller can pass
|
|
205
|
+
* anything address-space-shaped; when present, `ALIAS` is added to it.
|
|
206
|
+
*/
|
|
207
|
+
capabilitiesForMDNS?: string[];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The `ALIAS` ServerCapability identifier (OPC 10000-12 Annex D Table D.1).
|
|
212
|
+
*
|
|
213
|
+
* Part 17's prose writes it `Alias`; Part 12 Annex D is the normative source and
|
|
214
|
+
* writes `ALIAS`, matched case-insensitively.
|
|
215
|
+
*/
|
|
216
|
+
export const ALIAS_SERVER_CAPABILITY_ID = "ALIAS";
|
|
217
|
+
|
|
218
|
+
/** The placeholder node-opcua uses for "no capabilities declared". */
|
|
219
|
+
const NO_CAPABILITY_PLACEHOLDER = "NA";
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Add `ALIAS` to a Server's Annex D capability list, in place.
|
|
223
|
+
*
|
|
224
|
+
* Idempotent, case-insensitive, and replaces the `NA` placeholder rather than
|
|
225
|
+
* producing the meaningless `["NA", "ALIAS"]`.
|
|
226
|
+
*
|
|
227
|
+
* @returns true when the list was changed.
|
|
228
|
+
*/
|
|
229
|
+
export function advertiseAliasCapability(capabilities: string[]): boolean {
|
|
230
|
+
if (capabilities.some((c) => c.toUpperCase() === ALIAS_SERVER_CAPABILITY_ID)) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
// "NA" means "none"; it cannot coexist with a real capability
|
|
234
|
+
const placeholderIndex = capabilities.findIndex((c) => c.toUpperCase() === NO_CAPABILITY_PLACEHOLDER);
|
|
235
|
+
if (placeholderIndex >= 0) {
|
|
236
|
+
capabilities.splice(placeholderIndex, 1);
|
|
237
|
+
}
|
|
238
|
+
capabilities.push(ALIAS_SERVER_CAPABILITY_ID);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Install AliasName support on a Server.
|
|
244
|
+
*
|
|
245
|
+
* Call it **between `initialize()` and `start()`**: the address space exists
|
|
246
|
+
* from `initialize()`, and the `ALIAS` capability has to be in place before
|
|
247
|
+
* `start()` performs the mDNS/LDS registration that reads it.
|
|
248
|
+
*
|
|
249
|
+
* ```ts
|
|
250
|
+
* await server.initialize();
|
|
251
|
+
* await installAliasNames(server);
|
|
252
|
+
* await server.start();
|
|
253
|
+
* ```
|
|
254
|
+
*/
|
|
255
|
+
export async function installAliasNames(
|
|
256
|
+
server: IServerForAliasNames,
|
|
257
|
+
options?: InstallAliasNamesOptions
|
|
258
|
+
): Promise<InstallAliasNamesResult> {
|
|
259
|
+
const addressSpace = server.engine.addressSpace;
|
|
260
|
+
if (!addressSpace) {
|
|
261
|
+
throw new Error("installAliasNames: address space is not available. Call this after server.initialize().");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const result = await installAliasNamesOnAddressSpace(addressSpace, options);
|
|
265
|
+
|
|
266
|
+
// Declaring the capability is part of installing the feature, not a separate
|
|
267
|
+
// thing to remember: a Server that omits it is never discovered, silently.
|
|
268
|
+
if ((options?.advertiseCapability ?? true) && server.capabilitiesForMDNS) {
|
|
269
|
+
advertiseAliasCapability(server.capabilitiesForMDNS);
|
|
270
|
+
}
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Install AliasName support directly on an address space.
|
|
276
|
+
*
|
|
277
|
+
* The two-tier form used elsewhere in the SDK: this one needs no Server, so it
|
|
278
|
+
* can be driven from a test or from a tool that only has an address space.
|
|
279
|
+
*/
|
|
280
|
+
export async function installAliasNamesOnAddressSpace(
|
|
281
|
+
addressSpace: IAddressSpace,
|
|
282
|
+
options?: InstallAliasNamesOptions
|
|
283
|
+
): Promise<InstallAliasNamesResult> {
|
|
284
|
+
const already = getInstalledAliasNames(addressSpace);
|
|
285
|
+
if (already) {
|
|
286
|
+
return { ...already, installed: false };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const aliasesRoot = addressSpace.findNode(WellKnownCategories.Aliases);
|
|
290
|
+
if (!aliasesRoot) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
"installAliasNames: the Aliases Object (i=23470) is not in the address space. " +
|
|
293
|
+
"Load the standard nodeset (Opc.Ua.NodeSet2.xml) first."
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const store = options?.store ?? new AddressSpaceAliasStore(addressSpace, { likeOptions: options?.likeOptions });
|
|
298
|
+
|
|
299
|
+
const lastChange = new LastChangeTracker(addressSpace, {
|
|
300
|
+
persistencePath: options?.persistencePath,
|
|
301
|
+
now: options?.nowVersionTime
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const bindingOptions: BindAliasCategoryOptions = {
|
|
305
|
+
store,
|
|
306
|
+
maxResults: options?.maxResults ?? DEFAULT_MAX_RESULTS,
|
|
307
|
+
verbose: options?.verbose ?? true,
|
|
308
|
+
comparator: options?.comparator,
|
|
309
|
+
isReadAllowed: options?.isReadAllowed,
|
|
310
|
+
isWriteAllowed: options?.isWriteAllowed,
|
|
311
|
+
lastChangeProperty: options?.lastChangeOnAllCategories ?? true,
|
|
312
|
+
configurationMethods: options?.configurationMethods ?? false,
|
|
313
|
+
onChanged: (categoryNodeId: NodeId) => lastChange.touch(categoryNodeId).then(() => undefined)
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const provider = options?.categoryProvider ?? defaultCategoryProvider(options?.additionalCategoryRoots);
|
|
317
|
+
const categories = await provider(addressSpace);
|
|
318
|
+
|
|
319
|
+
// one binding path, shared with bindAliasCategory, so a category created
|
|
320
|
+
// after installation cannot end up bound differently
|
|
321
|
+
for (const category of categories) {
|
|
322
|
+
bindAliasCategory(addressSpace, category, bindingOptions);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const installed = {
|
|
326
|
+
store,
|
|
327
|
+
categories: categories.map((c) => c.nodeId),
|
|
328
|
+
bindingOptions,
|
|
329
|
+
lastChange
|
|
330
|
+
};
|
|
331
|
+
// Recorded before restore, so anything the restore triggers can already find
|
|
332
|
+
// the tracker on the address space.
|
|
333
|
+
setInstalledAliasNames(addressSpace, installed);
|
|
334
|
+
|
|
335
|
+
// Bring persisted values back and publish them, including the zeros for
|
|
336
|
+
// categories that have never changed - a Property with no value at all reads
|
|
337
|
+
// as Bad_WaitingForInitialData rather than "nothing has happened yet".
|
|
338
|
+
await lastChange.restore();
|
|
339
|
+
lastChange.publishAll(installed.categories);
|
|
340
|
+
|
|
341
|
+
return { ...installed, installed: true };
|
|
342
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node-opcua-alias-name-server
|
|
3
|
+
*
|
|
4
|
+
* `LastChange` (OPC 10000-17 clause 6.3.1).
|
|
5
|
+
*
|
|
6
|
+
* A `VersionTime` — **a UInt32 count of seconds since 2000-01-01T00:00:00Z, not
|
|
7
|
+
* a DateTime**. Every other "last changed" Property in the SDK is a DateTime and
|
|
8
|
+
* the name gives no hint, which is what makes this the easiest thing here to get
|
|
9
|
+
* wrong.
|
|
10
|
+
*
|
|
11
|
+
* Clause 6.3.1 lists three things that move it:
|
|
12
|
+
*
|
|
13
|
+
* - an AliasName was added to or deleted from the category,
|
|
14
|
+
* - an AliasNameCategory was added or deleted,
|
|
15
|
+
* - the referenced Nodes of an AliasName in the category changed.
|
|
16
|
+
*
|
|
17
|
+
* and one rule that makes it a tree rather than a set of independent counters:
|
|
18
|
+
* *"For AliasNameCategoryType instances that are nested, the value of LastChange
|
|
19
|
+
* shall always be the latest VersionTime of all Organized AliasNames and
|
|
20
|
+
* AliasNameCategories."* So a change deep in the hierarchy moves every ancestor
|
|
21
|
+
* up to the root.
|
|
22
|
+
*
|
|
23
|
+
* ## One second of resolution
|
|
24
|
+
*
|
|
25
|
+
* Two changes inside the same second are indistinguishable — the value simply
|
|
26
|
+
* does not move. A Client must therefore treat an **equal** `LastChange` as
|
|
27
|
+
* "re-browse to be sure" rather than "nothing changed"; only a value *older*
|
|
28
|
+
* than the cached one carries the strong meaning clause 6.3.1 gives it, namely
|
|
29
|
+
* clear the cache. This is a property of `VersionTime`, not of this
|
|
30
|
+
* implementation, and there is nothing a Server can do about it.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { BaseNode, IAddressSpace, UAObject, UAVariable } from "node-opcua-address-space-base";
|
|
34
|
+
import { maxVersionTime, nowVersionTime } from "node-opcua-alias-name-common";
|
|
35
|
+
import { BrowseDirection, NodeClass } from "node-opcua-data-model";
|
|
36
|
+
import type { NodeId } from "node-opcua-nodeid";
|
|
37
|
+
import { DataType } from "node-opcua-variant";
|
|
38
|
+
import {
|
|
39
|
+
ALIAS_NAME_ARCHIVE_VERSION,
|
|
40
|
+
type AliasNameArchive,
|
|
41
|
+
readAliasNameArchive,
|
|
42
|
+
writeAliasNameArchive
|
|
43
|
+
} from "./alias_name_archive.js";
|
|
44
|
+
import { WellKnownCategories } from "./well_known.js";
|
|
45
|
+
|
|
46
|
+
/** BrowseName of the Property, per clause 6.3.1 Table 2. */
|
|
47
|
+
export const LAST_CHANGE_BROWSE_NAME = "LastChange";
|
|
48
|
+
|
|
49
|
+
export interface LastChangeTrackerOptions {
|
|
50
|
+
/** File backing the persisted values. Omit to keep them in memory only. */
|
|
51
|
+
persistencePath?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Supplies "now" as a VersionTime. Injectable so a test can pin it; a
|
|
54
|
+
* one-second resolution is otherwise painful to assert against.
|
|
55
|
+
*/
|
|
56
|
+
now?: () => number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Keeps every category's `LastChange` Property correct, including the rollup to
|
|
61
|
+
* ancestors, and persists the values across restart.
|
|
62
|
+
*/
|
|
63
|
+
export class LastChangeTracker {
|
|
64
|
+
private readonly addressSpace: IAddressSpace;
|
|
65
|
+
private readonly persistencePath?: string;
|
|
66
|
+
private readonly now: () => number;
|
|
67
|
+
/** Category NodeId string to VersionTime. */
|
|
68
|
+
private readonly values = new Map<string, number>();
|
|
69
|
+
/** Serialises saves so two rapid changes cannot interleave their writes. */
|
|
70
|
+
private savingChain: Promise<void> = Promise.resolve();
|
|
71
|
+
|
|
72
|
+
constructor(addressSpace: IAddressSpace, options?: LastChangeTrackerOptions) {
|
|
73
|
+
this.addressSpace = addressSpace;
|
|
74
|
+
this.persistencePath = options?.persistencePath;
|
|
75
|
+
this.now = options?.now ?? nowVersionTime;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The `VersionTime` currently recorded for a category. */
|
|
79
|
+
public get(categoryNodeId: NodeId): number {
|
|
80
|
+
return this.values.get(categoryNodeId.toString()) ?? 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Record that a category changed, and roll the change up to every ancestor.
|
|
85
|
+
*
|
|
86
|
+
* Ancestors are walked at write time rather than computed at read time, so
|
|
87
|
+
* the Property a Client subscribes to actually carries the value — a
|
|
88
|
+
* rollup that existed only inside the Server would be invisible on the
|
|
89
|
+
* wire, which is the whole point of the Property.
|
|
90
|
+
*/
|
|
91
|
+
public async touch(categoryNodeId: NodeId, versionTime?: number): Promise<number> {
|
|
92
|
+
const value = versionTime ?? this.now();
|
|
93
|
+
for (const nodeId of this.selfAndAncestors(categoryNodeId)) {
|
|
94
|
+
const key = nodeId.toString();
|
|
95
|
+
this.values.set(key, maxVersionTime(this.values.get(key) ?? 0, value));
|
|
96
|
+
this.writeProperty(nodeId);
|
|
97
|
+
}
|
|
98
|
+
await this.save();
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Restore persisted values and publish them to the address space. */
|
|
103
|
+
public async restore(): Promise<void> {
|
|
104
|
+
if (this.persistencePath) {
|
|
105
|
+
const archive = await readAliasNameArchive(this.persistencePath);
|
|
106
|
+
if (archive) {
|
|
107
|
+
for (const [key, value] of Object.entries(archive.lastChange)) {
|
|
108
|
+
this.values.set(key, value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const key of this.values.keys()) {
|
|
113
|
+
const node = this.addressSpace.findNode(key);
|
|
114
|
+
if (node) {
|
|
115
|
+
this.writeProperty(node.nodeId);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Publish the current value of every known category, including zeros. */
|
|
121
|
+
public publishAll(categoryNodeIds: NodeId[]): void {
|
|
122
|
+
for (const nodeId of categoryNodeIds) {
|
|
123
|
+
this.writeProperty(nodeId);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Snapshot for persistence or inspection. */
|
|
128
|
+
public snapshot(): Record<string, number> {
|
|
129
|
+
return Object.fromEntries(this.values.entries());
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Persist, if a path was given. Saves are serialised, never concurrent. */
|
|
133
|
+
public async save(): Promise<void> {
|
|
134
|
+
if (!this.persistencePath) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const path = this.persistencePath;
|
|
138
|
+
const archive: AliasNameArchive = { version: ALIAS_NAME_ARCHIVE_VERSION, lastChange: this.snapshot() };
|
|
139
|
+
this.savingChain = this.savingChain.then(() => writeAliasNameArchive(path, archive));
|
|
140
|
+
await this.savingChain;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Write the value onto the category's `LastChange` Property, if it has one. */
|
|
144
|
+
private writeProperty(categoryNodeId: NodeId): void {
|
|
145
|
+
const node = this.addressSpace.findNode(categoryNodeId);
|
|
146
|
+
if (!node || node.nodeClass !== NodeClass.Object) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const property = (node as UAObject).getPropertyByName(LAST_CHANGE_BROWSE_NAME);
|
|
150
|
+
if (!property) {
|
|
151
|
+
// LastChange is Optional on AliasNameCategoryType; a category
|
|
152
|
+
// without one simply does not publish its version
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// VersionTime (i=20998) is a UInt32 subtype - not a DateTime
|
|
156
|
+
(property as UAVariable).setValueFromSource({
|
|
157
|
+
dataType: DataType.UInt32,
|
|
158
|
+
value: this.values.get(categoryNodeId.toString()) ?? 0
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The category itself, then every `AliasNameCategoryType` ancestor up to and
|
|
164
|
+
* including the `Aliases` root.
|
|
165
|
+
*
|
|
166
|
+
* Cycle-safe: an address space that nests categories in a loop would
|
|
167
|
+
* otherwise hang the Method call that triggered the change.
|
|
168
|
+
*/
|
|
169
|
+
private selfAndAncestors(categoryNodeId: NodeId): NodeId[] {
|
|
170
|
+
const result: NodeId[] = [];
|
|
171
|
+
const seen = new Set<string>();
|
|
172
|
+
|
|
173
|
+
const visit = (nodeId: NodeId): void => {
|
|
174
|
+
const key = nodeId.toString();
|
|
175
|
+
if (seen.has(key)) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
seen.add(key);
|
|
179
|
+
result.push(nodeId);
|
|
180
|
+
|
|
181
|
+
const node = this.addressSpace.findNode(nodeId);
|
|
182
|
+
if (!node) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
for (const parent of (node as BaseNode).findReferencesExAsObject("HierarchicalReferences", BrowseDirection.Inverse)) {
|
|
186
|
+
if (parent.nodeClass === NodeClass.Object) {
|
|
187
|
+
visit(parent.nodeId);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
visit(categoryNodeId);
|
|
193
|
+
// the root is always included even when the category was reached by a
|
|
194
|
+
// path that does not pass through it
|
|
195
|
+
const rootKey = WellKnownCategories.Aliases.toString();
|
|
196
|
+
if (!seen.has(rootKey) && this.addressSpace.findNode(WellKnownCategories.Aliases)) {
|
|
197
|
+
result.push(WellKnownCategories.Aliases);
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module node-opcua-alias-name-server
|
|
3
|
+
*
|
|
4
|
+
* The fixed NodeIds of OPC 10000-17.
|
|
5
|
+
*
|
|
6
|
+
* Every well-known instance is resolved **by NodeId**, never by walking
|
|
7
|
+
* BrowseNames. Clause 9.1 gives these Objects static NodeIds precisely so they
|
|
8
|
+
* can be found without browsing, and a BrowseName walk would break on a Server
|
|
9
|
+
* that publishes the hierarchy under a localised DisplayName or that has a
|
|
10
|
+
* vendor Object of the same BrowseName elsewhere.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { DataTypeIds, MethodIds, ObjectIds, ObjectTypeIds, ReferenceTypeIds, VariableIds } from "node-opcua-constants";
|
|
14
|
+
import { type NodeId, resolveNodeId } from "node-opcua-nodeid";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default result cap; beyond it a call answers `Bad_ResponseTooLarge`
|
|
18
|
+
* (clause 6.3.2 Table 4).
|
|
19
|
+
*
|
|
20
|
+
* Lives here rather than in `install_alias_names` so that `bind_alias_category`
|
|
21
|
+
* can use it without the two importing each other.
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_MAX_RESULTS = 1000;
|
|
24
|
+
|
|
25
|
+
/** `AliasNameType` ObjectType (clause 6.2). */
|
|
26
|
+
export const ALIAS_NAME_TYPE: NodeId = resolveNodeId(ObjectTypeIds.AliasNameType);
|
|
27
|
+
|
|
28
|
+
/** `AliasNameCategoryType` ObjectType (clause 6.3). */
|
|
29
|
+
export const ALIAS_NAME_CATEGORY_TYPE: NodeId = resolveNodeId(ObjectTypeIds.AliasNameCategoryType);
|
|
30
|
+
|
|
31
|
+
/** `VersionTime` DataType (OPC 10000-4 clause 7.43) - a UInt32 subtype. */
|
|
32
|
+
export const VERSION_TIME_DATA_TYPE: NodeId = resolveNodeId(DataTypeIds.VersionTime);
|
|
33
|
+
|
|
34
|
+
/** `AliasFor` ReferenceType (clause 8.2); the default link to a target Node. */
|
|
35
|
+
export const ALIAS_FOR: NodeId = resolveNodeId(ReferenceTypeIds.AliasFor);
|
|
36
|
+
|
|
37
|
+
/** `PublishedDataSetType` (OPC 10000-14), the target type `Topics` restricts to. */
|
|
38
|
+
export const PUBLISHED_DATA_SET_TYPE: NodeId = resolveNodeId(ObjectTypeIds.PublishedDataSetType);
|
|
39
|
+
|
|
40
|
+
/** The three well-known category instances of clauses 9.2, 9.3 and 9.4. */
|
|
41
|
+
export const WellKnownCategories = {
|
|
42
|
+
/** `Aliases`, the root of the hierarchy (clause 9.2). */
|
|
43
|
+
Aliases: resolveNodeId(ObjectIds.Aliases),
|
|
44
|
+
/** `TagVariables`; targets restricted to Variables (clause 9.3). */
|
|
45
|
+
TagVariables: resolveNodeId(ObjectIds.TagVariables),
|
|
46
|
+
/** `Topics`; targets restricted to PublishedDataSetType (clause 9.4). */
|
|
47
|
+
Topics: resolveNodeId(ObjectIds.Topics)
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
/** `LastChange` on each well-known category (clause 6.3.1). */
|
|
51
|
+
export const WellKnownLastChange = {
|
|
52
|
+
Aliases: resolveNodeId(VariableIds.Aliases_LastChange),
|
|
53
|
+
TagVariables: resolveNodeId(VariableIds.TagVariables_LastChange),
|
|
54
|
+
Topics: resolveNodeId(VariableIds.Topics_LastChange)
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
/** The Method declarations on `AliasNameCategoryType`. */
|
|
58
|
+
export const MethodDeclarations = {
|
|
59
|
+
FindAlias: resolveNodeId(MethodIds.AliasNameCategoryType_FindAlias),
|
|
60
|
+
FindAliasVerbose: resolveNodeId(MethodIds.AliasNameCategoryType_FindAliasVerbose),
|
|
61
|
+
AddAliasesToCategory: resolveNodeId(MethodIds.AliasNameCategoryType_AddAliasesToCategory),
|
|
62
|
+
DeleteAliasesFromCategory: resolveNodeId(MethodIds.AliasNameCategoryType_DeleteAliasesFromCategory)
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The NodeIds OPC 10000-17 reserves for the *optional* Methods on the three
|
|
67
|
+
* well-known categories.
|
|
68
|
+
*
|
|
69
|
+
* The shipped `Opc.Ua.NodeSet2.xml` declares these Methods on
|
|
70
|
+
* `AliasNameCategoryType` but does not instantiate them on `Aliases`,
|
|
71
|
+
* `TagVariables` or `Topics` — yet upstream still assigns them fixed NodeIds.
|
|
72
|
+
* When installation adds them it uses these rather than server-assigned ones,
|
|
73
|
+
* so an aggregating Server sees the standard NodeId it expects.
|
|
74
|
+
*/
|
|
75
|
+
export const WellKnownOptionalMethods = {
|
|
76
|
+
Aliases: {
|
|
77
|
+
FindAliasVerbose: resolveNodeId(MethodIds.Aliases_FindAliasVerbose),
|
|
78
|
+
AddAliasesToCategory: resolveNodeId(MethodIds.Aliases_AddAliasesToCategory),
|
|
79
|
+
DeleteAliasesFromCategory: resolveNodeId(MethodIds.Aliases_DeleteAliasesFromCategory)
|
|
80
|
+
},
|
|
81
|
+
TagVariables: {
|
|
82
|
+
FindAliasVerbose: resolveNodeId(MethodIds.TagVariables_FindAliasVerbose),
|
|
83
|
+
AddAliasesToCategory: resolveNodeId(MethodIds.TagVariables_AddAliasesToCategory),
|
|
84
|
+
DeleteAliasesFromCategory: resolveNodeId(MethodIds.TagVariables_DeleteAliasesFromCategory)
|
|
85
|
+
},
|
|
86
|
+
Topics: {
|
|
87
|
+
FindAliasVerbose: resolveNodeId(MethodIds.Topics_FindAliasVerbose),
|
|
88
|
+
AddAliasesToCategory: resolveNodeId(MethodIds.Topics_AddAliasesToCategory),
|
|
89
|
+
DeleteAliasesFromCategory: resolveNodeId(MethodIds.Topics_DeleteAliasesFromCategory)
|
|
90
|
+
}
|
|
91
|
+
} as const;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The `ALIAS` ServerCapability identifier (OPC 10000-12 Annex D Table D.1).
|
|
95
|
+
*
|
|
96
|
+
* A Server that does not advertise this will never be found by anything looking
|
|
97
|
+
* for alias-capable Servers, and nothing reports that failure. Part 17's prose
|
|
98
|
+
* writes it `Alias`; Part 12 Annex D is the normative source and writes `ALIAS`,
|
|
99
|
+
* matched case-insensitively.
|
|
100
|
+
*/
|
|
101
|
+
export const ALIAS_SERVER_CAPABILITY = "ALIAS";
|