src/lib/model/Node.ts
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<typeof ReferenceTypeIds>;
/** 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<typeof NodeClass>; // Defaults to 'Variable'
dataType?: KeyOf<typeof DataType>;
arrayType?: KeyOf<typeof VariantArrayType>;
references?: ReferenceDefinitions;
}
/**
* Names for references.
*/
export const ReferenceTypeNames = reverse(ReferenceTypeIds) as { [key: number]: string };
/**
* A map specialized for holding references.
*/
class ReferenceMap extends Map<ReferenceType, Set<number | string>> {
/**
* 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<typeof NodeClass>;
}
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<typeof NodeClass>;
/** A set of resolved properties. */
protected _resolved = new Set<NodeResolveKey>();
/** A set of unresolved properties. */
protected _unresolved: Set<NodeResolveKey>;
/** 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<NodeDefinition> = {};
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<typeof DataType> {
if (!this.isVariableNode()) {
throw new TypeError('Not a variable node');
}
return this.value.dataType;
}
/**
* The node's array type.
*/
public get arrayType(): ItemOf<typeof VariantArrayType> {
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;
}
}