import { ReferenceTypeIds as OpcReferenceTypeIds } from 'node-opcua/lib/opcua_node_ids'; import { NodeClass } from 'node-opcua/lib/datamodel/nodeclass'; import { VariantArrayType, DataType, Variant } from 'node-opcua/lib/datamodel/variant'; import { ItemOf, KeyOf } from 'node-opcua/lib/misc/enum.js'; import { reverse } from '../helpers/Object'; import { sortReferences } from '../helpers/mapping'; import { ValueOf } from '../helpers/types'; /** * References type ids. */ export const ReferenceTypeIds = { ...OpcReferenceTypeIds, toParent: -1, }; /** A reference type name */ type ReferenceTypeName = keyof typeof ReferenceTypeIds; /** A raw (number) reference type */ type ReferenceType = ValueOf; /** Node references stored in definition files */ export type ReferenceDefinitions = { [type in ReferenceTypeName]?: (number | string)[]; }; /** Node definition stored in definition file */ export interface NodeDefinition { nodeId?: string; nodeClass?: KeyOf; // Defaults to 'Variable' dataType?: KeyOf; arrayType?: KeyOf; references?: ReferenceDefinitions; } /** * Names for references. */ export const ReferenceTypeNames = reverse(ReferenceTypeIds) as { [key: number]: string }; /** * A map specialized for holding references. */ class ReferenceMap extends Map> { /** * Adds a new reference. * @param {number} type The reference id. * @param {string} nodeId The reference target node's id. */ public addReference(type: ReferenceType, nodeId: number | string): void { const set = this.get(type); if (set) { set.add(nodeId); } else { this.set(type, new Set([nodeId])); } } /** * Removes the given reference. * @param {number} type The reference id. * @param {string} nodeId The reference target node's id. */ public deleteReference(type: ReferenceType, nodeId: number | string): number | string { const set = this.get(type); if (set) { const ref = set.delete(nodeId); if (ref) { if (set.size === 0) { this.delete(type); } return nodeId; } } throw new Error(`No ${ReferenceTypeNames[type] || type} reference to ${nodeId}`); } /** * Returns the first entry of a specific type. * @param type The reference type id to look for. * @return The first reference found or undefined. */ public getSingle(type: ReferenceType): number | string | undefined { const set = this.get(type); return set && Array.from(set)[0]; } /** * Returns a plain object of refernces. * @return A string describing the reference map. */ public toJSON(): ReferenceDefinitions { return [...this].reduce( (result, [key, value]) => Object.assign(result, { [ReferenceTypeNames[key] || key]: [...value], }), {} ); } } interface WithValue { value: Variant; } export interface NodeOptions { name: string; parent?: Node; nodeClass: ItemOf; } type NodeResolveKey = 'nodeClass' | 'dataType' | 'arrayType'; /** * The main model class. */ export default abstract class Node { /** The node's name when stored to a file. */ protected fileName: string; /** The node's name when written to the server. */ protected idName: string; /** The id stored in the definition file. */ protected specialId?: string; /** The node's parent node. */ public readonly parent?: Node; /** The node's class. */ public readonly nodeClass: ItemOf; /** A set of resolved properties. */ protected _resolved = new Set(); /** A set of unresolved properties. */ protected _unresolved: Set; /** The node's references. */ public references = new ReferenceMap(); /** The node's unresolved refernces. */ protected _resolvedReferences = new ReferenceMap(); /** The node's resolved refernces. */ protected _unresolvedReferences = new ReferenceMap(); /** If the parent node resolves metadata. */ protected _parentResolvesMetadata = false; /** * Creates a new node. * @param {Object} options The options to use. * @param {string} options.name The node's name. * @param {Node} options.parent The node's parent node. * @param {node-opcua~NodeClass} options.nodeClass The node's class. */ public constructor({ name, parent, nodeClass /* , referenceToParent */ }: NodeOptions) { this.fileName = name; this.idName = name; this.parent = parent; this.nodeClass = nodeClass; this._unresolved = new Set([ 'nodeClass', // Only for variables 'dataType', 'arrayType', ]); } /** * If the parent resolves metadata (for example: split transformer source files). */ public get parentResolvesMetadata(): boolean { return this._parentResolvesMetadata; } public markAsResolved(key: NodeResolveKey): void { const value = this._unresolved.delete(key); // FIXME: Only test if debug / test if (value === false) { throw new Error(`'${key}' is already resolved`); } this._resolved.add(key); } public isResolved(key: NodeResolveKey): boolean { return this._resolved.has(key); } /** * Adds a new reference. * @param {number} type The reference type's id. * @param {string} id The reference target node's id. */ public addReference(type: ReferenceType, id: string): void { this.references.addReference(type, id); this._unresolvedReferences.addReference(type, id); } public setReferences(type: ReferenceType, ids: string[]): void { this.references.set(type, new Set(ids)); this._unresolvedReferences.set(type, new Set(ids)); } public markReferenceAsResolved(name: ReferenceTypeName, value: string): void { const type = ReferenceTypeIds[name]; const ref = this._unresolvedReferences.deleteReference(type, value); this._resolvedReferences.addReference(type, ref); } public markAllReferencesAsResolved(name: ReferenceTypeName): void { const type = ReferenceTypeIds[name]; this._unresolvedReferences.delete(type); } public hasUnresolvedReference(name: ReferenceTypeName): boolean { const type = ReferenceTypeIds[name]; return this._unresolvedReferences.has(type); } /** * The node's file path, used to compute {@link Node#filePath}. */ private get _filePath(): string[] { if (!this.parent) { return [this.fileName]; } return this.parent._filePath.concat(this.fileName); } /** * The node's file path. */ public get filePath(): string[] { if (!this.parent) { return []; } return this.parent._filePath; } /** * The node's id, used to compute {@link Node#nodeId}. */ private get _nodeId(): { id: string; separator: '/' | '.' } { if (this.specialId) { return { id: this.specialId, separator: this.specialId.match(/\.RESOURCES\/?/) ? '/' : '.', }; } if (!this.parent) { return { id: this.idName, separator: '.', }; } const { separator, id } = this.parent._nodeId; if (this._parentResolvesMetadata) { return { separator, id }; } return { separator: this.idName === 'RESOURCES' ? '/' : separator, id: `${id}${separator}${this.idName}`, }; } /** * The node's id. */ public get nodeId(): string { return this._nodeId.id; } /** * The node's type definition if given. */ public get typeDefinition(): number | string | undefined { return this.references.getSingle(ReferenceTypeIds.HasTypeDefinition); } /** * The node's modellingRule if given. * @type {?number} */ public get modellingRule(): number | string | undefined { return this.references.getSingle(ReferenceTypeIds.HasModellingRule); } /** * Returns `true` if the node has the given type definition. * @param typeDefName - The type definition to check. * @return If the node has the given type definition. */ public hasTypeDefinition(typeDefName: number | string): boolean { const def = this.typeDefinition; return def ? def === typeDefName : false; } /** * `true` at the moment. */ public get hasUnresolvedMetadata(): boolean { return true; /* FIXME: Once plugin mapping is implemented const value = !this._parentResolvesMetadata && (Boolean(this._unresolved.size) || Boolean(this._unresolvedReferences.size) || this.specialId); // FIXME: If debug / test if (!value && Object.keys(this.metadata).length > 0) { throw new Error(`#hasUnresolvedMetadata did return invalid result ${ value } for ${ JSON.stringify(Object.assign(this, {parent: undefined, value: undefined }), null, ' ') }`); } else if (value && Object.keys(this.metadata).length === 0) { throw new Error('#metadata did return invalid result'); } return value; */ } /** * The metadata to store in the node's definition file. * @type {Object} */ public get metadata(): NodeDefinition { if (this._parentResolvesMetadata) { return {}; } const meta: Partial = {}; if (this.specialId) { meta.nodeId = this.specialId; } if (this.isVariableNode()) { meta.dataType = this.value.dataType.key; meta.arrayType = this.value.arrayType.key; } else { meta.nodeClass = this.nodeClass.key; } meta.references = sortReferences(this.references.toJSON()); /* FIXME: Once plugin mapping is implemented for (const unresolved of this._unresolved) { let value = this[unresolved]; if (unresolved === 'dataType') { value = this.value.dataType ? this.value.dataType.key : 'UNKNOWN'; } else if (unresolved === 'arrayType') { value = this.value.arrayType ? this.value.arrayType.key : 'UNKNOWN'; } meta[unresolved] = value; } if (this._unresolvedReferences.size) { meta.references = sortReferences(this._unresolvedReferences.toJSON()); } */ return meta; } // Manipulation /** * Creates a new child node. * @param {Object} options The options to use. * @param {string} options.extension The extension to append to the node's name. */ public createChild({ extension }: { extension: string }): Node { // eslint-disable-next-line @typescript-eslint/no-explicit-any const node: Node = new (this.constructor as any)({ name: this.idName, parent: this, nodeClass: this.nodeClass, }); node.fileName = `${this.fileName}${extension}`; node.references = this.references; node._parentResolvesMetadata = true; return node; } // Convenience getters /** * The node's data type. */ public get dataType(): ItemOf { if (!this.isVariableNode()) { throw new TypeError('Not a variable node'); } return this.value.dataType; } /** * The node's array type. */ public get arrayType(): ItemOf { if (!this.isVariableNode()) { throw new TypeError('Not a variable node'); } return this.value.arrayType; } /** * If the node is a variable. * @deprecated Use TypeScript compatible {@link Node#isVariableNode} instead. */ public get isVariable(): boolean { return this.nodeClass === NodeClass.Variable; } public isVariableNode(): this is WithValue { return this.isVariable; } // FIXME: Move to display / script transformers /** * If the node is an object display. */ public get isDisplay(): boolean { return this.hasTypeDefinition('VariableTypes.ATVISE.Display'); } /** * If the node is a serverside script. */ public get isScript(): boolean { return this.hasTypeDefinition('VariableTypes.ATVISE.ScriptCode'); } /** * If the node is a quickdynamic. */ public get isQuickDynamic(): boolean { return this.hasTypeDefinition('VariableTypes.ATVISE.QuickDynamic'); } /** * If the node is a display script. */ public get isDisplayScript(): boolean { return this.hasTypeDefinition('VariableTypes.ATVISE.DisplayScript'); } } /** * A node during a *pull*. */ export abstract class ServerNode extends Node { /** * The node's name. */ public get name(): string { return this.fileName; } /** * Renames a node. * @param name The name to set. */ public renameTo(name: string): void { this.fileName = name; } } /** * A node during a *push*. */ export abstract class SourceNode extends Node { /** * The node's name. */ public get name(): string { return this.idName; } /** * Renames a node. * @param name The name to set. */ public renameTo(name: string): void { this.idName = name; } }