diff --git a/packages/node-opcua-address-space-base/source/ua_dynamic_variable_array.ts b/packages/node-opcua-address-space-base/source/ua_dynamic_variable_array.ts index 3b506490e9..7c79374d63 100644 --- a/packages/node-opcua-address-space-base/source/ua_dynamic_variable_array.ts +++ b/packages/node-opcua-address-space-base/source/ua_dynamic_variable_array.ts @@ -5,10 +5,10 @@ import { UAVariable } from "./ua_variable"; import { UAVariableType } from "./ua_variable_type"; // {{ Dynamic Array Variable -export interface UADynamicVariableArray extends UAVariable { +export interface UADynamicVariableArray extends UAVariable { $$variableType: UAVariableType; $$dataType: UADataType; $$extensionObjectArray: T[]; - $$getElementBrowseName: (obj: T) => QualifiedName; + $$getElementBrowseName: (obj: T, index: number) => QualifiedName; $$indexPropertyName: string; } diff --git a/packages/node-opcua-address-space-base/source/ua_variable.ts b/packages/node-opcua-address-space-base/source/ua_variable.ts index 2cda974cf1..a1a5ec6c5c 100644 --- a/packages/node-opcua-address-space-base/source/ua_variable.ts +++ b/packages/node-opcua-address-space-base/source/ua_variable.ts @@ -346,12 +346,12 @@ export interface UAVariable extends BaseNode, VariableAttributes, IPropertyAndCo ): ExtensionObject | ExtensionObject[] | null; bindExtensionObjectScalar( - optionalExtensionObject: ExtensionObject, + optionalExtensionObject?: ExtensionObject, options?: BindExtensionObjectOptions ): ExtensionObject | null; bindExtensionObjectArray( - optionalExtensionObjectArray: ExtensionObject[], + optionalExtensionObjectArray?: ExtensionObject[], options?: BindExtensionObjectOptions ): ExtensionObject[] | null; diff --git a/packages/node-opcua-address-space/src/extension_object_array_node.ts b/packages/node-opcua-address-space/src/extension_object_array_node.ts index c961355158..e4fa2fe900 100644 --- a/packages/node-opcua-address-space/src/extension_object_array_node.ts +++ b/packages/node-opcua-address-space/src/extension_object_array_node.ts @@ -23,7 +23,7 @@ const errorLog = make_errorLog(__filename); * */ -function getExtObjArrayNodeValue(this: UADynamicVariableArray) { +function getExtObjArrayNodeValue(this: UADynamicVariableArray) { return new Variant({ arrayType: VariantArrayType.Array, dataType: DataType.ExtensionObject, @@ -38,7 +38,7 @@ function removeElementByIndex(uaArrayVariableNode: UA const addressSpace = uaArrayVariableNode.addressSpace; const extObj = _array[elementIndex]; - const browseName = uaArrayVariableNode.$$getElementBrowseName(extObj); + const browseName = uaArrayVariableNode.$$getElementBrowseName(extObj, elementIndex); // remove element from global array (inefficient) uaArrayVariableNode.$$extensionObjectArray.splice(elementIndex, 1); @@ -68,14 +68,6 @@ function removeElementByIndex(uaArrayVariableNode: UA /** * * create a node Variable that contains a array of ExtensionObject of a given type - * @method createExtObjArrayNode - * @param parentFolder - * @param options - * @param options.browseName - * @param options.complexVariableType - * @param options.variableType the type of Extension objects stored in the array. - * @param options.indexPropertyName - * @return {Object|UAVariable} */ export function createExtObjArrayNode(parentFolder: UAObject, options: any): UADynamicVariableArray { assert(typeof options.variableType === "string"); @@ -126,7 +118,9 @@ export function createExtObjArrayNode(parentFolder: U return uaArrayVariableNode; } -function _getElementBrowseName(this: UADynamicVariableArray, extObj: ExtensionObject) { + +function _getElementBrowseName + (this: UADynamicVariableArray, extObj: ExtensionObject, index: number | number[]) { const indexPropertyName1 = this.$$indexPropertyName; if (!Object.prototype.hasOwnProperty.call(extObj, indexPropertyName1)) { @@ -137,13 +131,7 @@ function _getElementBrowseName(this: UADynamicVariabl const browseName = (extObj as any)[indexPropertyName1].toString(); return browseName; }; -/** - * @method bindExtObjArrayNode - * @param uaArrayVariableNode - * @param variableTypeNodeId - * @param indexPropertyName - * @return - */ + export function bindExtObjArrayNode( uaArrayVariableNode: UADynamicVariableArray, variableTypeNodeId: string | NodeId, @@ -155,26 +143,23 @@ export function bindExtObjArrayNode( const addressSpace = uaArrayVariableNode.addressSpace; const variableType = addressSpace.findVariableType(variableTypeNodeId); - // istanbul ignore next if (!variableType || variableType.nodeId.isEmpty()) { throw new Error("Cannot find VariableType " + variableTypeNodeId.toString()); } const structure = addressSpace.findDataType("Structure"); - // istanbul ignore next if (!structure) { throw new Error("Structure Type not found: please check your nodeset file"); } let dataType = addressSpace.findDataType(variableType.dataType); - // istanbul ignore next if (!dataType) { throw new Error("Cannot find DataType " + variableType.dataType.toString()); } - + assert(dataType.isSupertypeOf(structure), "expecting a structure (= ExtensionObject) here "); assert(!uaArrayVariableNode.$$variableType, "uaArrayVariableNode has already been bound !"); @@ -197,7 +182,6 @@ export function bindExtObjArrayNode( }; // bind the readonly uaArrayVariableNode.bindVariable(bindOptions, true); - return uaArrayVariableNode; } @@ -208,20 +192,9 @@ export function bindExtObjArrayNode( * @param uaArrayVariableNode {UAVariable} * @return {UAVariable} * - * @method addElement - * add a new element in a ExtensionObject Array variable - * @param nodeVariable a variable already exposing an extension objects - * @param uaArrayVariableNode {UAVariable} - * @return {UAVariable} - * - * @method addElement - * add a new element in a ExtensionObject Array variable - * @param constructor constructor of the extension object to create - * @param uaArrayVariableNode {UAVariable} - * @return {UAVariable} */ export function addElement( - options: any /* ExtensionObjectConstructor | ExtensionObject | UAVariable*/, + options: UAVariableImpl | ExtensionObject | Record, uaArrayVariableNode: UADynamicVariableArray ): UAVariable { assert(uaArrayVariableNode, " must provide an UAVariable containing the array"); @@ -256,19 +229,20 @@ export function addElement( }); // xx elVar.bindExtensionObject(); } else { - if (options instanceof Constructor) { + if (options instanceof ExtensionObject) { // extension object has already been created extensionObject = options as T; } else { extensionObject = addressSpace.constructExtensionObject(uaArrayVariableNode.$$dataType, options) as T; } - browseName = uaArrayVariableNode.$$getElementBrowseName(extensionObject); + const index = uaArrayVariableNode.$$extensionObjectArray?.length || 0; + browseName = uaArrayVariableNode.$$getElementBrowseName(extensionObject, index); elVar = uaArrayVariableNode.$$variableType.instantiate({ browseName, componentOf: uaArrayVariableNode.nodeId, value: { dataType: DataType.ExtensionObject, value: extensionObject } }) as UAVariableImpl; - elVar.bindExtensionObject(extensionObject, { force: true }); + elVar.bindExtensionObject(extensionObject, { force: true }); elVar.$extensionObject = extensionObject; } @@ -279,19 +253,6 @@ export function addElement( } /** - * - * @method removeElement - * @param uaArrayVariableNode {UAVariable} - * @param element {number} index of element to remove in array - * - * - * @method removeElement - * @param uaArrayVariableNode {UAVariable} - * @param element {UAVariable} node of element to remove in array - * - * @method removeElement - * @param uaArrayVariableNode {UAVariable} - * @param element {ExtensionObject} extension object of the node of element to remove in array * */ export function removeElement( @@ -305,7 +266,7 @@ export function removeElement( if (_array.length === 0) { throw new Error(" cannot remove an element from an empty array "); } - + let elementIndex = -1; if (typeof element === "number") { @@ -316,7 +277,7 @@ export function removeElement( // find element by name const browseNameToFind = element.browseName.name!.toString(); elementIndex = _array.findIndex((obj: any, i: number) => { - const browseName = uaArrayVariableNode.$$getElementBrowseName(obj).toString(); + const browseName = uaArrayVariableNode.$$getElementBrowseName(obj, elementIndex).toString(); return browseName === browseNameToFind; }); } else if (typeof element === "function") { @@ -327,7 +288,7 @@ export function removeElement( assert(_array[0].constructor.name === (element as any).constructor.name, "element must match"); elementIndex = _array.findIndex((x: any) => x === element); } - + // istanbul ignore next if (elementIndex < 0) { throw new Error("removeElement: cannot find element matching " + element.toString()); diff --git a/packages/node-opcua-address-space/src/idx_iterator.ts b/packages/node-opcua-address-space/src/idx_iterator.ts new file mode 100644 index 0000000000..b27dbaca65 --- /dev/null +++ b/packages/node-opcua-address-space/src/idx_iterator.ts @@ -0,0 +1,52 @@ + + + + +export class IndexIterator { + + public current: number[] | null = null; + constructor(private limits: number[]) { + this.reset(); + } + public reset() { + this.current = []; + for (let i = 0; i < this.limits.length; i++) { + this.current[i] = 0; + } + } + public increment() { + if (!this.current) return; + + + const increase = (n: number): boolean => { + if (n < 0) { + return false; + } + if (!this.current) return false; + if (this.current[n] + 1 >= this.limits[n]) { + if (n==0) { + this.current = null; + return false; + } + this.current[n] = 0; + return increase(n - 1); + } + this.current[n] = this.current[n] + 1; + return true; + } + const n = this.limits.length - 1; + if (!increase(n)) { + this.current = null; + } + } + public next(): number[] { + if (!this.current) { + throw new Error("Outof bond"); + } + const r = [... this.current]; + this.increment(); + return r; + } + + +} \ No newline at end of file diff --git a/packages/node-opcua-address-space/src/ua_variable_impl.ts b/packages/node-opcua-address-space/src/ua_variable_impl.ts index 3393a6c703..281c7e8f7e 100644 --- a/packages/node-opcua-address-space/src/ua_variable_impl.ts +++ b/packages/node-opcua-address-space/src/ua_variable_impl.ts @@ -28,7 +28,8 @@ import { AccessLevelFlag, makeAccessLevelFlag, AttributeIds, - isDataEncoding + isDataEncoding, + QualifiedName } from "node-opcua-data-model"; import { extractRange, sameDataValue, DataValue, DataValueLike, DataValueT } from "node-opcua-data-value"; import { coerceClock, getCurrentClock, PreciseClock } from "node-opcua-date-time"; @@ -87,6 +88,8 @@ import { propagateTouchValueUpward, setExtensionObjectValue, _bindExtensionObject, + _bindExtensionObjectArray, + _bindExtensionObjectMatrix, _installExtensionObjectBindingOnProperties, _setExtensionObject, _touchValue @@ -1471,24 +1474,41 @@ export class UAVariableImpl extends BaseNodeImpl implements UAVariable { * @return {ExtensionObject} */ public bindExtensionObjectScalar( - optionalExtensionObject: ExtensionObject, + optionalExtensionObject?: ExtensionObject, options?: BindExtensionObjectOptions ): ExtensionObject | null { - return this.bindExtensionObject(optionalExtensionObject, options) as ExtensionObject | null; + return _bindExtensionObject(this, optionalExtensionObject, options) as ExtensionObject; } public bindExtensionObjectArray( - optionalExtensionObject: ExtensionObject[], + optionalExtensionObject?: ExtensionObject[], options?: BindExtensionObjectOptions ): ExtensionObject[] | null { - return this.bindExtensionObject(optionalExtensionObject, options) as ExtensionObject[] | null; + assert(this.valueRank === 1, "expecting a Array variable here"); + return _bindExtensionObjectArray(this, optionalExtensionObject, options) as ExtensionObject[]; } public bindExtensionObject( optionalExtensionObject?: ExtensionObject | ExtensionObject[], options?: BindExtensionObjectOptions ): ExtensionObject | ExtensionObject[] | null { - return _bindExtensionObject(this, optionalExtensionObject, options); + if (optionalExtensionObject) { + if (optionalExtensionObject instanceof Array) { + return this.bindExtensionObjectArray(optionalExtensionObject, options); + } else { + return this.bindExtensionObjectScalar(optionalExtensionObject, options); + } + } + assert(optionalExtensionObject === undefined); + if (this.valueRank === -1) { + return this.bindExtensionObjectScalar(undefined, options); + } else if (this.valueRank === 1) { + return this.bindExtensionObjectArray(undefined, options); + } else if (this.valueRank > 1) { + return _bindExtensionObjectMatrix(this, undefined, options); + } + // unsupported case ... + return null; } public updateExtensionObjectPartial(partialExtensionObject?: { [key: string]: any }): ExtensionObject { @@ -1830,14 +1850,15 @@ UAVariableImpl.prototype.writeAttribute = thenify.withCallback(UAVariableImpl.pr UAVariableImpl.prototype.historyRead = thenify.withCallback(UAVariableImpl.prototype.historyRead); UAVariableImpl.prototype.readValueAsync = thenify.withCallback(UAVariableImpl.prototype.readValueAsync); -export interface UAVariableImpl { - $$variableType?: any; - $$dataType?: any; - $$getElementBrowseName: any; - $$extensionObjectArray: any; - $$indexPropertyName: any; +export interface UAVariableImplExtArray { + $$variableType?: UAVariableType; + $$dataType: UADataType; + $$getElementBrowseName: (extObject: ExtensionObject, index: number | number[]) => QualifiedName; + $$extensionObjectArray: ExtensionObject[]; + $$indexPropertyName: string; +} +export interface UAVariableImpl extends UAVariableImplExtArray { } - function check_valid_array(dataType: DataType, array: any): boolean { if (Array.isArray(array)) { return true; diff --git a/packages/node-opcua-address-space/src/ua_variable_impl_ext_obj.ts b/packages/node-opcua-address-space/src/ua_variable_impl_ext_obj.ts index 1dd1a7d9ba..31d246c198 100644 --- a/packages/node-opcua-address-space/src/ua_variable_impl_ext_obj.ts +++ b/packages/node-opcua-address-space/src/ua_variable_impl_ext_obj.ts @@ -1,12 +1,12 @@ import * as chalk from "chalk"; import assert from "node-opcua-assert"; -import { BindExtensionObjectOptions, UADataType, UAVariable, UAVariableType } from "node-opcua-address-space-base"; -import { NodeClass } from "node-opcua-data-model"; -import { getCurrentClock, PreciseClock } from "node-opcua-date-time"; +import { BindExtensionObjectOptions, UADataType, UADynamicVariableArray, UAVariable, UAVariableType } from "node-opcua-address-space-base"; +import { coerceQualifiedName, NodeClass } from "node-opcua-data-model"; +import { getCurrentClock, PreciseClock, DateWithPicoseconds } from "node-opcua-date-time"; import { DataValue } from "node-opcua-data-value"; import { make_debugLog, make_warningLog, checkDebugFlag, make_errorLog } from "node-opcua-debug"; import { ExtensionObject } from "node-opcua-extension-object"; -import { NodeId } from "node-opcua-nodeid"; +import { coerceNodeId, NodeId, NodeIdType } from "node-opcua-nodeid"; import { StatusCodes, CallbackT, StatusCode } from "node-opcua-status-code"; import { StructureField } from "node-opcua-types"; import { lowerFirstLetter } from "node-opcua-utils"; @@ -15,6 +15,8 @@ import { DataType, Variant, VariantLike, VariantArrayType } from "node-opcua-var import { valueRankToString } from "./base_node_private"; import { UAVariableImpl } from "./ua_variable_impl"; import { UADataTypeImpl } from "./ua_data_type_impl"; +import { bindExtObjArrayNode } from "./extension_object_array_node"; +import { IndexIterator } from "./idx_iterator"; const doDebug = checkDebugFlag(__filename); const debugLog = make_debugLog(__filename); @@ -114,7 +116,7 @@ function propagateTouchValueDownward(self: UAVariableImpl, now: PreciseClock): v } } -export function _setExtensionObject(self: UAVariableImpl, ext: ExtensionObject | ExtensionObject[]): void { +export function _setExtensionObject(self: UAVariableImpl, ext: ExtensionObject | ExtensionObject[], sourceTimestamp?: PreciseClock): void { // assert(!(ext as any).$isProxy, "internal error ! ExtensionObject has already been proxied !"); if (Array.isArray(ext)) { assert(self.valueRank === 1, "Only Array is supported for the time being"); @@ -133,7 +135,7 @@ export function _setExtensionObject(self: UAVariableImpl, ext: ExtensionObject | self.$dataValue.statusCode = StatusCodes.Good; } - const now = getCurrentClock(); + const now = sourceTimestamp || getCurrentClock(); propagateTouchValueUpward(self, now); propagateTouchValueDownward(self, now); } @@ -193,7 +195,10 @@ function getOrCreateProperty( browseName: { namespaceIndex: structureNamespace, name: field.name!.toString() }, componentOf: variableNode, dataType: field.dataType, - minimumSamplingInterval: variableNode.minimumSamplingInterval + minimumSamplingInterval: variableNode.minimumSamplingInterval, + accessLevel: variableNode.accessLevel, + accessRestrictions: variableNode.accessRestrictions, + rolePermissions: variableNode.rolePermissions }) as UAVariableImpl; assert(property.minimumSamplingInterval === variableNode.minimumSamplingInterval); } @@ -226,17 +231,46 @@ function bindProperty(variableNode: UAVariableImpl, propertyNode: UAVariableImpl return new DataValue(propertyNode.$dataValue); }, timestamped_set: (_dataValue: DataValue, callback: CallbackT) => { - callback(null, StatusCodes.BadNotWritable); + + propertyNode.setValueFromSource(_dataValue.value, _dataValue.statusCode, _dataValue.sourceTimestamp || new Date()); + // callback(null, StatusCodes.BadNotWritable); + callback(null, StatusCodes.Good); } }, true ); } + +function _initial_setup(variableNode: UAVariableImpl) { + const dataValue = variableNode.readValue(); + const extObj = dataValue.value.value; + if (extObj instanceof ExtensionObject) { + variableNode.bindExtensionObject(extObj, { createMissingProp: true, force: true }); + } else if (extObj instanceof Array) { + if (dataValue.value.arrayType === VariantArrayType.Array) { + variableNode.bindExtensionObjectArray(extObj, { createMissingProp: true, force: true }); + } else if (dataValue.value.arrayType === VariantArrayType.Matrix) { + _bindExtensionObjectMatrix(variableNode, extObj, { createMissingProp: true, force: true }); + } else { + throw new Error("Internal Error, unexpected case"); + } + } else { + const msg = `variableNode ${variableNode.browseName.toString()} doesn't have $extensionObject property`; + errorLog(msg); + errorLog(dataValue.toString()); + throw new Error(msg); + } +} export function _installExtensionObjectBindingOnProperties( variableNode: UAVariableImpl, options: BindExtensionObjectOptions ): void { + + if (!variableNode.$extensionObject) { + _initial_setup(variableNode); + return; + } const addressSpace = variableNode.addressSpace; const dt = variableNode.getDataTypeNode(); const definition = dt.getStructureDefinition(); @@ -256,7 +290,12 @@ export function _installExtensionObjectBindingOnProperties( continue; } propertyNode.$dataValue.statusCode = StatusCodes.Good; - propertyNode.touchValue(); + propertyNode.$dataValue.sourceTimestamp = variableNode.$dataValue.sourceTimestamp; + propertyNode.$dataValue.sourcePicoseconds = variableNode.$dataValue.sourcePicoseconds; + propertyNode.$dataValue.serverTimestamp = variableNode.$dataValue.serverTimestamp; + propertyNode.$dataValue.serverPicoseconds = variableNode.$dataValue.serverPicoseconds; + + //xx propertyNode.touchValue(); const basicDataType = addressSpace.findCorrespondingBasicDataType(field.dataType); @@ -322,36 +361,43 @@ export function _installExtensionObjectBindingOnProperties( } } -// eslint-disable-next-line complexity -export function _bindExtensionObject( - self: UAVariableImpl, - optionalExtensionObject?: ExtensionObject | ExtensionObject[], - options?: BindExtensionObjectOptions -): ExtensionObject | ExtensionObject[] | null { - options = options || { createMissingProp: false }; - - - const addressSpace = self.addressSpace; +function isVariableContainingExtensionObject(uaVariable: UAVariableImpl): boolean { + const addressSpace = uaVariable.addressSpace; const structure = addressSpace.findDataType("Structure"); - let extensionObject_; if (!structure) { // the addressSpace is limited and doesn't provide extension object // bindExtensionObject cannot be performed and shall finish here. - return null; + return false; } assert(structure.browseName.toString() === "Structure", "expecting DataType Structure to be in IAddressSpace"); - const dt = self.getDataTypeNode() as UADataTypeImpl; + const dt = uaVariable.getDataTypeNode() as UADataTypeImpl; if (!dt.isSupertypeOf(structure)) { + return false; + } + return true; + +} +// eslint-disable-next-line complexity +export function _bindExtensionObject( + self: UAVariableImpl, + optionalExtensionObject?: ExtensionObject | ExtensionObject[], + options?: BindExtensionObjectOptions +): ExtensionObject | ExtensionObject[] | null { + options = options || { createMissingProp: false }; + + if (!isVariableContainingExtensionObject(self)) { return null; } + const addressSpace = self.addressSpace; + let extensionObject_; - if (self.valueRank !== -1 && self.valueRank !==1) { + if (self.valueRank !== -1 && self.valueRank !== 1) { throw new Error("Cannot bind an extension object here, valueRank must be scalar (-1) or one-dimensional (1)"); } - + // istanbul ignore next if (doDebug) { debugLog(" ------------------------------ binding ", self.browseName.toString(), self.nodeId.toString()); @@ -365,6 +411,7 @@ export function _bindExtensionObject( ) { const parentDataType = (self.parent as UAVariable | UAVariableType).dataType; const dataTypeNode = addressSpace.findNode(parentDataType) as UADataType; + const structure = addressSpace.findDataType("Structure")!; // istanbul ignore next if (dataTypeNode && dataTypeNode.isSupertypeOf(structure)) { // warningLog( @@ -392,9 +439,9 @@ export function _bindExtensionObject( warningLog(self.$extensionObject?.toString()); throw new Error( "bindExtensionObject: $extensionObject is incorrect: we are expecting a " + - self.dataType.toString({ addressSpace: self.addressSpace }) + - " but we got a " + - self.$extensionObject?.constructor.name + self.dataType.toString({ addressSpace: self.addressSpace }) + + " but we got a " + + self.$extensionObject?.constructor.name ); } return self.$extensionObject; @@ -445,7 +492,9 @@ export function _bindExtensionObject( }, true ); - _setExtensionObject(self, extensionObject_); + const currentTime: PreciseClock = { timestamp: self.$dataValue.sourceTimestamp! as DateWithPicoseconds, picoseconds: self.$dataValue.sourcePicoseconds || 0 }; + _setExtensionObject(self, extensionObject_, currentTime); + } else if (self.valueRank === 1 /** Array */) { // create a structure and bind it @@ -488,6 +537,102 @@ export function _bindExtensionObject( } } +const getIndexAsText = (index: number | number[]): string => { + if (typeof index === "number") + return `${index}`; + return `${index.map((a) => a.toString()).join(",")}`; +} +const composeBrowseNameAndNodeId = (uaVariable: UAVariable, indexes: number[]) => { + const iAsText = getIndexAsText(indexes); + const browseName = coerceQualifiedName(iAsText); + let nodeId: NodeId | undefined; + if (uaVariable.nodeId.identifierType === NodeIdType.STRING) { + nodeId = new NodeId(NodeIdType.STRING, uaVariable.nodeId.value as string + `[${iAsText}]`, uaVariable.nodeId.namespace); + } + return { browseName, nodeId }; +} + +export function _bindExtensionObjectArray( + uaVariable: UAVariableImpl, + optionalExtensionObjectArray?: ExtensionObject[], + options?: BindExtensionObjectOptions +): ExtensionObject[] { + return _bindExtensionObjectMatrix(uaVariable, optionalExtensionObjectArray, options); +} +export function _bindExtensionObjectMatrix( + uaVariable: UAVariableImpl, + optionalExtensionObjectArray?: ExtensionObject[], + options?: BindExtensionObjectOptions +): ExtensionObject[] { + + if (uaVariable.valueRank < 1) { + throw new Error("Variable must be a MultiDimensional array"); + } + const arrayDimensions = uaVariable.arrayDimensions || []; + + if (!isVariableContainingExtensionObject(uaVariable)) { + return []; + } + if ((arrayDimensions.length === 0 || arrayDimensions.length === 1 && arrayDimensions[0] === 0) && optionalExtensionObjectArray) { + arrayDimensions[0] = optionalExtensionObjectArray.length; + } + + const totalLength = arrayDimensions.reduce((p, c) => p * c, 1); + + /** */ + const addressSpace = uaVariable.addressSpace; + if (optionalExtensionObjectArray) { + if (optionalExtensionObjectArray.length !== totalLength) { + throw new Error(`optionalExtensionObjectArray must have the expected number of element matching ${arrayDimensions}`) + } + } + if (!optionalExtensionObjectArray) { + optionalExtensionObjectArray = []; + for (let i = 0; i < totalLength; i++) { + optionalExtensionObjectArray[i] = addressSpace.constructExtensionObject(uaVariable.dataType, {});; + } + } + uaVariable.$$extensionObjectArray = optionalExtensionObjectArray; + + // uaVariable.$$getElementBrowseName = getElementBrowseNameByIndex; + + uaVariable.bindVariable({ + get: () => new Variant({ + arrayType: VariantArrayType.Array, + dataType: DataType.ExtensionObject, + value: uaVariable.$$extensionObjectArray + }) + }, true); + const namespace = uaVariable.namespace; + const indexIterator = new IndexIterator(arrayDimensions); + for (let i = 0; i < totalLength; i++) { + + const index = indexIterator.next(); + + const { browseName, nodeId } = composeBrowseNameAndNodeId(uaVariable, index); + + const uaElement = namespace.addVariable({ + browseName, + nodeId, + componentOf: uaVariable, + dataType: uaVariable.dataType, + valueRank: -1, + accessLevel:uaVariable.userAccessLevel + }); + { + const capturedIndex = i; + uaElement.bindVariable({ + get: () => new Variant({ + dataType: DataType.ExtensionObject, + arrayType: VariantArrayType.Scalar, + value: uaVariable.$$extensionObjectArray[capturedIndex] + }) + }) + } + } + return uaVariable.$$extensionObjectArray; +} + export function extractPartialData(path: string | string[], extensionObject: ExtensionObject) { let name; if (typeof path === "string") { diff --git a/packages/node-opcua-address-space/test/test_address_space_construct_extension_object.ts b/packages/node-opcua-address-space/test/test_address_space_construct_extension_object.ts index f6f08f2676..ec2ac3e009 100644 --- a/packages/node-opcua-address-space/test/test_address_space_construct_extension_object.ts +++ b/packages/node-opcua-address-space/test/test_address_space_construct_extension_object.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-statements */ // tslint:disable:no-bitwise // ===================================================================================================================== // the purpose of this test is to check the ability to create a extension object from it's node @@ -20,7 +21,7 @@ import { assert } from "node-opcua-assert"; import { ExtensionObject } from "node-opcua-extension-object"; import { StatusCodes } from "node-opcua-status-code"; import { ServerState } from "node-opcua-types"; -import { AccessLevelFlag, NodeClass, makeAccessLevelFlag } from "node-opcua-data-model"; +import { AccessLevelFlag, NodeClass, makeAccessLevelFlag, accessLevelFlagToString } from "node-opcua-data-model"; import { AttributeIds } from "node-opcua-data-model"; import { DataType } from "node-opcua-variant"; import { Variant } from "node-opcua-variant"; @@ -215,6 +216,8 @@ describe("testing address space namespace loading", function (this: any) { serverStatus.startTime.readValue().value.dataType.should.eql(DataType.DateTime); serverStatus.readValue().value.dataType.should.eql(DataType.ExtensionObject); + accessLevelFlagToString( serverStatus.accessLevel).should.eql("CurrentRead"); + // Xx value.startTime.should.eql(DataType.Null); // xx debugLog("serverStatus.startTime =",serverStatus.startTime.readValue().value.toString()); @@ -259,10 +262,26 @@ describe("testing address space namespace loading", function (this: any) { serverStatus.readValue().value.value.buildInfo.productName!.should.eql("productName2"); serverStatus.buildInfo.productName.readValue().value.value!.should.eql("productName2"); + accessLevelFlagToString( serverStatus.buildInfo.productName.accessLevel).should.eql("CurrentRead"); + const writeValue0 = new WriteValue({ + attributeId: AttributeIds.Value, // value + value: { + statusCode: StatusCodes.Good, + value: { + dataType: DataType.String, + value: "productName3" + } + } + }); + const statusCode0 = await serverStatus.buildInfo.productName.writeAttribute(null, writeValue0); + statusCode0.should.eql(StatusCodes.BadNotWritable); + + // now use WriteValue instead // make sure value is writable const rw = makeAccessLevelFlag("CurrentRead | CurrentWrite"); assert(rw === (AccessLevelFlag.CurrentRead | AccessLevelFlag.CurrentWrite)); + serverStatus.buildInfo.productName.accessLevel = rw; serverStatus.buildInfo.productName.userAccessLevel = rw; @@ -282,11 +301,12 @@ describe("testing address space namespace loading", function (this: any) { } } }); + accessLevelFlagToString( serverStatus.buildInfo.productName.accessLevel).should.eql("CurrentRead | CurrentWrite"); const statusCode = await serverStatus.buildInfo.productName.writeAttribute(null, writeValue); - statusCode.should.eql(StatusCodes.BadNotWritable); + statusCode.should.eql(StatusCodes.Good); - serverStatus.buildInfo.productName.readValue().value.value!.should.not.eql("productName3"); - serverStatus.readValue().value.value.buildInfo.productName!.should.not.eql("productName3"); + serverStatus.buildInfo.productName.readValue().value.value!.should.eql("productName3"); + serverStatus.readValue().value.value.buildInfo.productName!.should.eql("productName3"); }); it("should instantiate SessionDiagnostics in a linear time", () => { diff --git a/packages/node-opcua-address-space/test/test_bindExtensionObject.ts b/packages/node-opcua-address-space/test/test_bindExtensionObject.ts index 9c19e31fb6..2859de9925 100644 --- a/packages/node-opcua-address-space/test/test_bindExtensionObject.ts +++ b/packages/node-opcua-address-space/test/test_bindExtensionObject.ts @@ -103,6 +103,19 @@ describe("Extension Object binding and sub components\n", () => { extensionObjectVar.readValue().value.value.length.should.eql(2); extensionObjectVar.readValue().value.value[0].constructor.name.should.eql("ServiceCounterDataType"); extensionObjectVar.readValue().value.value[1].constructor.name.should.eql("ServiceCounterDataType"); + + // to do + const el1 = extensionObjectVar.getComponentByName("0") as UAVariable; + const dataValueEl1 = el1.readValue(); + dataValueEl1.value.dataType.should.eql(DataType.ExtensionObject); + dataValueEl1.value.arrayType.should.eql(VariantArrayType.Scalar); + dataValueEl1.value.value.constructor.name.should.eql("ServiceCounterDataType"); + + const el2 = extensionObjectVar.getComponentByName("1") as UAVariable; + const dataValueEl2 = el2.readValue(); + dataValueEl2.value.dataType.should.eql(DataType.ExtensionObject); + dataValueEl2.value.arrayType.should.eql(VariantArrayType.Scalar); + dataValueEl2.value.value.constructor.name.should.eql("ServiceCounterDataType"); }); it("BEO1 - should handle a Variable containing a ServiceCounterDataType", () => { @@ -412,7 +425,7 @@ describe("Extension Object binding and sub components\n", () => { it( "ZA3- updateExtensionObjectPartial: it should be possible to cascade changes " + - "by acting on the whole ExtensionObject", + "by acting on the whole ExtensionObject", () => { spy_on_sessionDiagnostics_clientDescription_value_changed.callCount.should.eql(0); @@ -445,7 +458,7 @@ describe("Extension Object binding and sub components\n", () => { it( "ZA4- updateExtensionObjectPartial: it should be possible to cascade changes " + - "by acting on the whole ExtensionObject - middle", + "by acting on the whole ExtensionObject - middle", () => { spy_on_sessionDiagnostics_totalRequestCount_value_changed.callCount.should.eql(0); spy_on_sessionDiagnostics_totalRequestCount_errorCount_value_changed.callCount.should.eql(0); @@ -476,7 +489,7 @@ describe("Extension Object binding and sub components\n", () => { it( "ZA5- incrementExtensionObjectPartial: it should be possible to cascade changes " + - "by increasing a value on ExtensionObject", + "by increasing a value on ExtensionObject", () => { sessionDiagnostics.totalRequestCount.totalCount.readValue().value.value.should.eql(0); sessionDiagnostics.totalRequestCount.readValue().value.value.totalCount.should.eql(0); @@ -509,7 +522,7 @@ describe("Extension Object binding and sub components\n", () => { it( "ZA6- changing property values in extension object directly should propagates changes and notification " + - "to NodeVariables", + "to NodeVariables", () => { _sessionDiagnostics.clientDescription.applicationUri = "applicationUri-1"; @@ -522,7 +535,7 @@ describe("Extension Object binding and sub components\n", () => { }); // tslint:disable-next-line: no-empty-interface -interface UAMeasIdDataType extends UAVariable {} +interface UAMeasIdDataType extends UAVariable { } // tslint:disable-next-line: no-empty-interface interface UAPartIdDataType extends UAVariable { id: UAVariableT; diff --git a/packages/node-opcua-address-space/test/test_expand_ext_obj_variable.ts b/packages/node-opcua-address-space/test/test_expand_ext_obj_variable.ts new file mode 100644 index 0000000000..84e27db6ce --- /dev/null +++ b/packages/node-opcua-address-space/test/test_expand_ext_obj_variable.ts @@ -0,0 +1,322 @@ +import * as should from "should"; +import { resolveNodeId } from "node-opcua-nodeid"; +import { nodesets } from "node-opcua-nodesets"; +import { DataTypeIds } from "node-opcua-constants"; +import { DataType, VariantArrayType } from "node-opcua-variant"; +import { ThreeDCartesianCoordinates } from "node-opcua-types"; +import { StatusCodes } from "node-opcua-status-code"; +import { AttributeIds } from "node-opcua-basic-types"; +import { makeAccessLevelFlag } from "node-opcua-data-model"; + +import { AddressSpace, INamespace, PseudoSession, UAVariable } from ".."; +import { generateAddressSpace } from "../nodeJS"; + + +// tslint:disable-next-line:no-var-requires +const describe = require("node-opcua-leak-detector").describeWithLeakDetector; +describe("Extending extension object variables", function () { + + this.timeout(Math.max(this.timeout(), 100000)); + let addressSpace: AddressSpace; + let namespace: INamespace; + before(async () => { + addressSpace = AddressSpace.create(); + await generateAddressSpace(addressSpace, [nodesets.standard]); + namespace = addressSpace.registerNamespace("urn:private"); + }); + after(() => { + addressSpace.shutdown(); + addressSpace.dispose(); + }); + const extensionObjectDataType = resolveNodeId(DataTypeIds.ThreeDCartesianCoordinates); + const p1 = new ThreeDCartesianCoordinates({ x: 1, y: 2, z: 3 }); + const p2 = new ThreeDCartesianCoordinates({ x: 4, y: 5, z: 6 }); + const p3 = new ThreeDCartesianCoordinates({ x: 7, y: 8, z: 9 }); + const p4 = new ThreeDCartesianCoordinates({ x: 10, y: 11, z: 12 }); + + it("should expand a scalar extension object variable", async () => { + const v = namespace.addVariable({ + dataType: extensionObjectDataType, + browseName: "V1", + organizedBy: addressSpace.rootFolder.objects.server, + accessLevel: makeAccessLevelFlag("CurrentRead | CurrentWrite") + }); + v.setValueFromSource({ + value: p1.clone(), + arrayType: VariantArrayType.Scalar, + dataType: DataType.ExtensionObject + }, StatusCodes.Good, new Date(Date.UTC(2022, 0, 1, 0, 0, 0))); + v.readValue().sourceTimestamp?.toISOString().should.eql("2022-01-01T00:00:00.000Z"); + + v.installExtensionObjectVariables(); + + // the inner propertis of the extension should now be exposed + const uaX = v.getComponentByName("X")! as UAVariable; + const uaY = v.getComponentByName("Y")! as UAVariable; + const uaZ = v.getComponentByName("Z")! as UAVariable; + + uaX.typeDefinitionObj.browseName.toString().should.eql("BaseDataVariableType"); + uaY.typeDefinitionObj.browseName.toString().should.eql("BaseDataVariableType"); + uaZ.typeDefinitionObj.browseName.toString().should.eql("BaseDataVariableType"); + + should.exist(uaX); + should.exist(uaY); + should.exist(uaZ); + + const verify = ({ x, y, z }: { x: number, y: number, z: number }) => { + const dataValue = v.readValue(); + dataValue.value.dataType.should.eql(DataType.ExtensionObject); + dataValue.value.value.x.should.eql(x); + dataValue.value.value.y.should.eql(y); + dataValue.value.value.z.should.eql(z); + + const xDataValue = uaX.readValue(); + xDataValue.value.dataType.should.eql(DataType.Double); + xDataValue.value.value.should.eql(x); + + const yDataValue = uaY.readValue(); + yDataValue.value.dataType.should.eql(DataType.Double); + yDataValue.value.value.should.eql(y); + + const zDataValue = uaZ.readValue(); + zDataValue.value.dataType.should.eql(DataType.Double); + zDataValue.value.value.should.eql(z); + + //s dataValue.sourceTimestamp?.toISOString().should.eql("2023-01-02T06:47:55.065Z"); + xDataValue.sourceTimestamp?.getTime().should.lessThanOrEqual(dataValue.sourceTimestamp!.getTime()); + yDataValue.sourceTimestamp?.getTime().should.lessThanOrEqual(dataValue.sourceTimestamp!.getTime()); + zDataValue.sourceTimestamp?.getTime().should.lessThanOrEqual(dataValue.sourceTimestamp!.getTime()); + + } + verify({ x: 1, y: 2, z: 3 }); + // + // + const session = new PseudoSession(addressSpace); + const statusCode1 = await session.write({ + nodeId: uaZ.nodeId, + value: { value: { dataType: DataType.Double, value: 33 } }, + attributeId: AttributeIds.Value + }); + + statusCode1.should.eql(StatusCodes.Good); + + const zDataValue2 = uaZ.readValue(); + zDataValue2.value.dataType.should.eql(DataType.Double); + zDataValue2.value.value.should.eql(33); + + verify({ x: 1, y: 2, z: 33 }); + // + + const statusCode2 = await session.write({ + nodeId: v.nodeId, + value: { value: { dataType: DataType.ExtensionObject, value: p2.clone() } }, + attributeId: AttributeIds.Value + }); + statusCode2.should.eql(StatusCodes.Good); + verify({ x: 4, y: 5, z: 6 }); + + }); + + it("should expand a array extension object variable", () => { + const v = namespace.addVariable({ + dataType: extensionObjectDataType, + nodeId: "s=\"SomeData\"", + browseName: "V2", + organizedBy: addressSpace.rootFolder.objects.server, + valueRank: 1, + }); + v.setValueFromSource({ + value: [p1, p2], + arrayType: VariantArrayType.Array, + dataType: DataType.ExtensionObject + }, StatusCodes.Good, new Date(Date.UTC(2022, 0, 1))); + + v.installExtensionObjectVariables(); + + const el0 = v.getComponentByName("0") as UAVariable; + el0?.nodeId.toString().should.eql(v.nodeId?.toString() + "[0]"); + + const el1 = v.getComponentByName("1") as UAVariable; + el1?.nodeId.toString().should.eql(v.nodeId?.toString() + "[1]"); + + console.log(v.toString()); + should.exist(el0); + should.exist(el1); + + const dataValue = v.readValue(); + dataValue.value.dataType.should.eql(DataType.ExtensionObject); + dataValue.value.arrayType.should.eql(VariantArrayType.Array); + dataValue.value.value.length.should.eql(2); + dataValue.value.value[0].x.should.eql(1); + dataValue.value.value[0].y.should.eql(2); + dataValue.value.value[0].z.should.eql(3); + dataValue.value.value[1].x.should.eql(4); + dataValue.value.value[1].y.should.eql(5); + dataValue.value.value[1].z.should.eql(6); + + { + if (!el0) return; + const x = el0.getComponentByName("X"); + const y = el0.getComponentByName("Y"); + const z = el0.getComponentByName("Z"); + + should.exist(x); + should.exist(y); + should.exist(z); + + } + { + if (!el1) return; + const x = el1.getComponentByName("X"); + const y = el1.getComponentByName("Y"); + const z = el1.getComponentByName("Z"); + + should.exist(x); + should.exist(y); + should.exist(z); + + } + const verify = (array: { x: number, y: number, z: number }[]) => { + + const dataValue = v.readValue(); + dataValue.value.dataType.should.eql(DataType.ExtensionObject); + dataValue.value.arrayType.should.eql(VariantArrayType.Array); + + const dataValue00 = el0.readValue(); + dataValue00.value.dataType.should.eql(DataType.ExtensionObject); + dataValue00.value.arrayType.should.eql(VariantArrayType.Scalar); + dataValue00.value.value.x.should.eql(array[0].x); + dataValue00.value.value.y.should.eql(array[0].y); + dataValue00.value.value.z.should.eql(array[0].z); + + const dataValue01 = el1.readValue(); + dataValue01.value.value.x.should.eql(array[1].x); + dataValue01.value.value.y.should.eql(array[1].y); + dataValue01.value.value.z.should.eql(array[1].z); + + } + verify([{ x: 1, y: 2, z: 3 }, { x: 4, y: 5, z: 6 }]) + }); + + it("should expand a matrix extension object variable", async () => { + const v = namespace.addVariable({ + dataType: extensionObjectDataType, + browseName: "V2", + organizedBy: addressSpace.rootFolder.objects.server, + valueRank: 2, + arrayDimensions: [2, 3] + }); + v.setValueFromSource({ + value: [p1, p2, p3, p1, p2, p3,], + arrayType: VariantArrayType.Matrix, + dimensions: [2, 3], + dataType: DataType.ExtensionObject + }); + + v.installExtensionObjectVariables(); + + console.log(v.toString()); + + const el00 = v.getComponentByName("0,0") as UAVariable; + const el01 = v.getComponentByName("0,1") as UAVariable; + const el02 = v.getComponentByName("0,2") as UAVariable; + const el10 = v.getComponentByName("1,0") as UAVariable; + const el11 = v.getComponentByName("1,1") as UAVariable; + const el12 = v.getComponentByName("1,2") as UAVariable; + + const verify = (array: { x: number, y: number, z: number }[]) => { + + const dataValue = v.readValue(); + dataValue.value.dataType.should.eql(DataType.ExtensionObject); + dataValue.value.arrayType.should.eql(VariantArrayType.Array); + + const dataValue00 = el00.readValue(); + dataValue00.value.dataType.should.eql(DataType.ExtensionObject); + dataValue00.value.arrayType.should.eql(VariantArrayType.Scalar); + dataValue00.value.value.x.should.eql(array[0].x); + dataValue00.value.value.y.should.eql(array[0].y); + dataValue00.value.value.z.should.eql(array[0].z); + + const dataValue01 = el01.readValue(); + dataValue01.value.value.x.should.eql(array[1].x); + dataValue01.value.value.y.should.eql(array[1].y); + dataValue01.value.value.z.should.eql(array[1].z); + + const dataValue02 = el02.readValue(); + dataValue02.value.value.x.should.eql(array[2].x); + dataValue02.value.value.y.should.eql(array[2].y); + dataValue02.value.value.z.should.eql(array[2].z); + + const dataValue10 = el10.readValue(); + dataValue10.value.value.x.should.eql(array[3].x); + dataValue10.value.value.y.should.eql(array[3].y); + dataValue10.value.value.z.should.eql(array[3].z); + + const dataValue11 = el11.readValue(); + dataValue11.value.value.x.should.eql(array[4].x); + dataValue11.value.value.y.should.eql(array[4].y); + dataValue11.value.value.z.should.eql(array[4].z); + + const dataValue12 = el12.readValue(); + dataValue12.value.value.x.should.eql(array[5].x); + dataValue12.value.value.y.should.eql(array[5].y); + dataValue12.value.value.z.should.eql(array[5].z); + } + should.exist(el00); + should.exist(el01); + should.exist(el02); + should.exist(el10); + should.exist(el11); + should.exist(el12); + + verify([ + { x: 1, y: 2, z: 3 }, { x: 4, y: 5, z: 6 }, { x: 7, y: 8, z: 9 }, + { x: 1, y: 2, z: 3 }, { x: 4, y: 5, z: 6 }, { x: 7, y: 8, z: 9 }, + ]); + + + { + + const session = new PseudoSession(addressSpace); + const statusCode1 = await session.write({ + nodeId: el11.getComponentByName("X")!.nodeId, + value: { value: { dataType: DataType.Double, value: 33 } }, + attributeId: AttributeIds.Value + }); + + statusCode1.should.eql(StatusCodes.Good); + + const zDataValue2 = (el11.getComponentByName("X")! as UAVariable).readValue(); + zDataValue2.value.dataType.should.eql(DataType.Double); + zDataValue2.value.value.should.eql(33); + + verify([ + { x: 1, y: 2, z: 3 }, { x: 4, y: 5, z: 6 }, { x: 7, y: 8, z: 9 }, + { x: 1, y: 2, z: 3 }, { x: 33, y: 5, z: 6 }, { x: 7, y: 8, z: 9 }, + ]); + + } + { + + const session = new PseudoSession(addressSpace); + const statusCode1 = await session.write({ + nodeId: el11.nodeId, + value: { value: { value: p4.clone(), dataType: DataType.ExtensionObject } }, + attributeId: AttributeIds.Value + }); + + statusCode1.should.eql(StatusCodes.Good); + + const dataValue = el11.readValue(); + dataValue.value.dataType.should.eql(DataType.ExtensionObject); + dataValue.value.value.x.should.eql(p4.x); + dataValue.value.value.y.should.eql(p4.y); + dataValue.value.value.z.should.eql(p4.z); + verify([ + { x: 1, y: 2, z: 3 }, { x: 4, y: 5, z: 6 }, { x: 7, y: 8, z: 9 }, + { x: 1, y: 2, z: 3 }, p4, { x: 7, y: 8, z: 9 }, + ]); + } + + }); +}); \ No newline at end of file diff --git a/packages/node-opcua-address-space/test/test_extension_object_array_node.ts b/packages/node-opcua-address-space/test/test_extension_object_array_node.ts index e7f027e2a9..75a833a976 100644 --- a/packages/node-opcua-address-space/test/test_extension_object_array_node.ts +++ b/packages/node-opcua-address-space/test/test_extension_object_array_node.ts @@ -216,7 +216,7 @@ describe("Extension Object Array Node (or Complex Variable)", () => { subscriptionId: 1123455 }); - const browseName = arrA.$$getElementBrowseName(extObj); + const browseName = arrA.$$getElementBrowseName(extObj, 0); const item1 = subscriptionDiagnosticsType.instantiate({ browseName, diff --git a/packages/node-opcua-address-space/test/test_index_iterator.ts b/packages/node-opcua-address-space/test/test_index_iterator.ts new file mode 100644 index 0000000000..54cc802f0f --- /dev/null +++ b/packages/node-opcua-address-space/test/test_index_iterator.ts @@ -0,0 +1,55 @@ +import "should"; +import * as should from "should"; +import { IndexIterator} from "../src/idx_iterator"; + +describe("index iterator", function(){ + + it("should iterate", ()=>{ + const iterator = new IndexIterator([2]); + iterator.current?.should.eql([0]); + iterator.increment(); + iterator.current?.should.eql([1]); + iterator.increment(); + iterator.current?.should.eql(null); + iterator.increment(); + }); + + it("should iterate on a two dimension array", ()=>{ + const iterator = new IndexIterator([2,3]); + iterator.current?.should.eql([0,0]); + iterator.increment(); + iterator.current?.should.eql([0,1]); + iterator.increment(); + iterator.current?.should.eql([0,2]); + iterator.increment(); + iterator.current?.should.eql([1,0]); + iterator.increment(); + iterator.current?.should.eql([1,1]); + iterator.increment(); + iterator.current?.should.eql([1,2]); + iterator.increment(); + iterator.current?.should.eql(null); + iterator.increment(); + + }); + it("should iterate on a three dimension array - using increment", ()=>{ + const iterator = new IndexIterator([1,2,2]); + iterator.current?.should.eql([0,0,0]); + iterator.increment(); + iterator.current?.should.eql([0,0,1]); + iterator.increment(); + iterator.current?.should.eql([0,1,0]); + iterator.increment(); + iterator.current?.should.eql([0,1,1]); + iterator.increment(); + iterator.current?.should.eql(null); + }); + it("should iterate on a three dimension array - using next", ()=>{ + const iterator = new IndexIterator([1,2,2]); + iterator.next().should.eql([0,0,0]); + iterator.next().should.eql([0,0,1]); + iterator.next().should.eql([0,1,0]); + iterator.next().should.eql([0,1,1]); + should(iterator.current).eql(null); + }); +}) \ No newline at end of file