From 100a9fd5951c961eab1fcff545587d368a457eae Mon Sep 17 00:00:00 2001 From: Etienne Rossignon Date: Mon, 27 Oct 2025 08:27:11 +0100 Subject: [PATCH 1/6] chore(binding-opcua): remove redundant findBasicDataType --- .../src/opcua-protocol-client.ts | 50 ++----------------- 1 file changed, 5 insertions(+), 45 deletions(-) diff --git a/packages/binding-opcua/src/opcua-protocol-client.ts b/packages/binding-opcua/src/opcua-protocol-client.ts index 08bb99278..f9bc45602 100644 --- a/packages/binding-opcua/src/opcua-protocol-client.ts +++ b/packages/binding-opcua/src/opcua-protocol-client.ts @@ -14,7 +14,6 @@ ********************************************************************************/ import { Subscription } from "rxjs/Subscription"; -import { promisify } from "util"; import { Readable } from "stream"; import { URL } from "url"; import { @@ -49,18 +48,19 @@ import { import { AnonymousIdentity, ArgumentDefinition, + findBasicDataType, getBuiltInDataType, readNamespaceArray, UserIdentityInfo, } from "node-opcua-pseudo-session"; -import { makeNodeId, NodeId, NodeIdLike, NodeIdType, resolveNodeId } from "node-opcua-nodeid"; -import { AttributeIds, BrowseDirection, makeResultMask } from "node-opcua-data-model"; +import { NodeId, NodeIdLike, resolveNodeId } from "node-opcua-nodeid"; +import { AttributeIds } from "node-opcua-data-model"; import { makeBrowsePath } from "node-opcua-service-translate-browse-path"; import { StatusCodes } from "node-opcua-status-code"; import { coercePrivateKeyPem, readPrivateKey } from "node-opcua-crypto"; import { opcuaJsonEncodeVariant } from "node-opcua-json"; -import { Argument, BrowseDescription, BrowseResult, MessageSecurityMode, UserTokenType } from "node-opcua-types"; -import { isGoodish2, ReferenceTypeIds } from "node-opcua"; +import { Argument, MessageSecurityMode, UserTokenType } from "node-opcua-types"; +import { isGoodish2 } from "node-opcua"; import { schemaDataValue } from "./codec"; import { OPCUACAuthenticationScheme, OPCUAChannelSecurityScheme } from "./security-scheme"; @@ -101,46 +101,6 @@ interface OPCUAConnectionEx extends OPCUAConnection { pending?: Resolver[]; } -export function findBasicDataTypeC( - session: IBasicSession, - dataTypeId: NodeId, - callback: (err: Error | null, dataType?: DataType) => void -): void { - const resultMask = makeResultMask("ReferenceType"); - - if (dataTypeId.identifierType === NodeIdType.NUMERIC && Number(dataTypeId.value) <= 25) { - // we have a well-known DataType - callback(null, dataTypeId.value as DataType); - } else { - // let's browse for the SuperType of this object - const nodeToBrowse = new BrowseDescription({ - browseDirection: BrowseDirection.Inverse, - includeSubtypes: false, - nodeId: dataTypeId, - referenceTypeId: makeNodeId(ReferenceTypeIds.HasSubtype), - resultMask, - }); - - session.browse(nodeToBrowse, (err: Error | null, browseResult?: BrowseResult) => { - /* istanbul ignore next */ - if (err) { - return callback(err); - } - - /* istanbul ignore next */ - if (!browseResult) { - return callback(new Error("Internal Error")); - } - - browseResult.references = browseResult.references ?? /* istanbul ignore next */ []; - const baseDataType = browseResult.references[0].nodeId; - return findBasicDataTypeC(session, baseDataType, callback); - }); - } -} -const findBasicDataType: (session: IBasicSession, dataTypeId: NodeId) => Promise = - promisify(findBasicDataTypeC); - function _variantToJSON(variant: Variant, contentType: string) { contentType = contentType.split(";")[0]; From 21ce56a3eef8f39cae5f05eba8667fbdd7792016 Mon Sep 17 00:00:00 2001 From: Etienne Rossignon Date: Mon, 27 Oct 2025 08:51:03 +0100 Subject: [PATCH 2/6] feat(binding-opcua): fix connection mutualisation bug - and refactor connection management --- .../src/opcua-protocol-client.ts | 202 +++++++++++------- .../test/opcua-protocol-client-test.ts | 67 ++++++ 2 files changed, 186 insertions(+), 83 deletions(-) create mode 100644 packages/binding-opcua/test/opcua-protocol-client-test.ts diff --git a/packages/binding-opcua/src/opcua-protocol-client.ts b/packages/binding-opcua/src/opcua-protocol-client.ts index f9bc45602..c5d48c7fc 100644 --- a/packages/binding-opcua/src/opcua-protocol-client.ts +++ b/packages/binding-opcua/src/opcua-protocol-client.ts @@ -95,12 +95,6 @@ interface OPCUAConnection { namespaceArray?: string[]; } -type Resolver = (...arg: [...unknown[]]) => void; - -interface OPCUAConnectionEx extends OPCUAConnection { - pending?: Resolver[]; -} - function _variantToJSON(variant: Variant, contentType: string) { contentType = contentType.split(";")[0]; @@ -118,95 +112,130 @@ function _variantToJSON(variant: Variant, contentType: string) { } export class OPCUAProtocolClient implements ProtocolClient { - private _connections: Map = new Map(); + private _connections = new Map>(); private _securityMode: MessageSecurityMode = MessageSecurityMode.None; private _securityPolicy: SecurityPolicy = SecurityPolicy.None; private _useAutoChannel: boolean = false; private _userIdentity: UserIdentityInfo = { type: UserTokenType.Anonymous }; - private async _withConnection(form: OPCUAForm, next: (connection: OPCUAConnection) => Promise): Promise { - const endpoint = form.href; - const matchesScheme: boolean = endpoint?.match(/^opc.tcp:\/\//) != null; - if (!matchesScheme) { - debug(`invalid opcua:endpoint ${endpoint} specified`); - throw new Error("Invalid OPCUA endpoint " + endpoint); - } - let c: OPCUAConnectionEx | undefined = this._connections.get(endpoint); - if (!c) { - const clientCertificateManager = await CertificateManagerSingleton.getCertificateManager(); - - if (this._useAutoChannel) { - if (this._securityMode === MessageSecurityMode.Invalid) { - const { messageSecurityMode, securityPolicy } = await findMostSecureChannel(endpoint); - this._securityMode = messageSecurityMode; - this._securityPolicy = securityPolicy; - } + /** + * return the number of active connections to an OPCUA Server + */ + public get connectionCount() { + return this._connections.size; + } + private async _createConnection(endpoint: string): Promise { + debug(`_createConnection: creating new connection to ${endpoint}`); + + const clientCertificateManager = await CertificateManagerSingleton.getCertificateManager(); + + let securityMode = this._securityMode; + let securityPolicy = this._securityPolicy; + + if (this._useAutoChannel) { + if (securityMode === MessageSecurityMode.Invalid) { + const mostSecure = await findMostSecureChannel(endpoint); + securityMode = mostSecure.messageSecurityMode; + securityPolicy = mostSecure.securityPolicy; + debug( + `_createConnection: auto-selected security mode ${MessageSecurityMode[securityMode]} with policy ${securityPolicy}` + ); } - const client = OPCUAClient.create({ - endpointMustExist: false, - connectionStrategy: { - maxRetry: 1, - }, - securityMode: this._securityMode, - securityPolicy: this._securityPolicy, - clientCertificateManager, - }); - client.on("backoff", () => { - debug(`connection:backoff: cannot connection to ${endpoint}`); - }); + } - c = { - client, - pending: [] as Resolver[], - } as OPCUAConnectionEx; // but incomplete still + const client = OPCUAClient.create({ + endpointMustExist: false, + connectionStrategy: { + maxRetry: 1, + }, + securityMode, + securityPolicy, + clientCertificateManager, + }); - this._connections.set(endpoint, c); - try { - await client.connect(endpoint); - } catch (err) { - const errMessage = "Cannot connected to endpoint " + endpoint + "\nmsg = " + (err).message; - debug(errMessage); - throw new Error(errMessage); + client.on("backoff", () => { + debug(`connection:backoff: cannot connect to ${endpoint}`); + }); + + try { + await client.connect(endpoint); + debug(`_createConnection: client connected to ${endpoint}`); + + // adjust with private key + if (this._userIdentity.type === UserTokenType.Certificate && !this._userIdentity.privateKey) { + const internalKey = readPrivateKey(client.clientCertificateManager.privateKey); + const privateKeyPem = coercePrivateKeyPem(internalKey); + this._userIdentity.privateKey = privateKeyPem; } - try { - // adjust with private key - if (this._userIdentity.type === UserTokenType.Certificate && !this._userIdentity.privateKey) { - const internalKey = readPrivateKey(client.clientCertificateManager.privateKey); - const privateKeyPem = coercePrivateKeyPem(internalKey); - this._userIdentity.privateKey = privateKeyPem; - } - const session = await client.createSession(this._userIdentity); - c.session = session; - - const subscription = await session.createSubscription2({ - maxNotificationsPerPublish: 100, - publishingEnabled: true, - requestedLifetimeCount: 100, - requestedPublishingInterval: 250, - requestedMaxKeepAliveCount: 10, - priority: 1, - }); - c.subscription = subscription; + const session = await client.createSession(this._userIdentity); + debug(`_createConnection: session created for ${endpoint}`); + + const subscription = await session.createSubscription2({ + maxNotificationsPerPublish: 100, + publishingEnabled: true, + requestedLifetimeCount: 100, + requestedPublishingInterval: 250, + requestedMaxKeepAliveCount: 10, + priority: 1, + }); + debug(`_createConnection: subscription created for ${endpoint}`); + + return { client, session, subscription }; + } catch (err) { + // Make sure to disconnect if any post-connection step fails + await client.disconnect(); + const errMessage = `Failed to establish a full connection to ${endpoint}: ${(err as Error).message}`; + debug(errMessage); + throw new Error(errMessage); + } + } - const p = c.pending; - c.pending = undefined; - p && p.forEach((t) => t()); + private async _withConnection(form: OPCUAForm, next: (connection: OPCUAConnection) => Promise): Promise { + const href = form.href; + if (!href) { + const err = new Error("Invalid OPCUA endpoint: href is missing in form"); + debug(err.message); + throw err; + } - this._connections.set(endpoint, c); - } catch (err) { - await client.disconnect(); - const errMessage = "Cannot handle session on " + endpoint + "\nmsg = " + (err).message; - debug(errMessage); - throw new Error(errMessage); + // Use modern URL API and ensure path is included for endpoint uniqueness + let endpoint: string; + try { + const parsedUrl = new URL(href); + if (parsedUrl.protocol !== "opc.tcp:") { + throw new Error(`Unsupported protocol: ${parsedUrl.protocol}`); } + // We use the full href as the canonical endpoint identifier, without the query and fragment + parsedUrl.hash = ""; + parsedUrl.search = ""; + endpoint = parsedUrl.href; + } catch (err) { + debug(`Invalid OPCUA endpoint href: ${href}. Error: ${(err as Error).message}`); + throw new Error(`Invalid OPCUA endpoint: ${href}`); } - if (c.pending) { - await new Promise((resolve) => { - c?.pending?.push(resolve); + + let connectionPromise = this._connections.get(endpoint); + + if (!connectionPromise) { + debug(`_withConnection: no cached connection for ${endpoint}. Creating a new one.`); + connectionPromise = this._createConnection(endpoint); + this._connections.set(endpoint, connectionPromise); + + // If the connection fails, remove the rejected promise from the cache + // to allow future retries. + connectionPromise.catch((err) => { + debug(`_withConnection: connection to ${endpoint} failed. Evicting from cache. Error: ${err.message}`); + if (this._connections.get(endpoint) === connectionPromise) { + this._connections.delete(endpoint); + } }); + } else { + debug(`_withConnection: using cached connection promise for ${endpoint}`); } - return next(c); + + const connection = await connectionPromise; + return next(connection); } private async _withSession(form: OPCUAForm, next: (session: ClientSession) => Promise): Promise { @@ -475,11 +504,18 @@ export class OPCUAProtocolClient implements ProtocolClient { async stop(): Promise { debug("stop"); - for (const connection of this._connections.values()) { - await connection.subscription.terminate(); - await connection.session.close(); - await connection.client.disconnect(); + // Wait for all connection promises to resolve before trying to close them. + const connections = await Promise.all(this._connections.values()); + for (const connection of connections) { + try { + await connection.subscription.terminate(); + await connection.session.close(); + await connection.client.disconnect(); + } catch (err) { + debug(`Error while stopping a connection: ${(err as Error).message}`); + } } + this._connections.clear(); await CertificateManagerSingleton.releaseCertificateManager(); } diff --git a/packages/binding-opcua/test/opcua-protocol-client-test.ts b/packages/binding-opcua/test/opcua-protocol-client-test.ts new file mode 100644 index 000000000..95f0ac94a --- /dev/null +++ b/packages/binding-opcua/test/opcua-protocol-client-test.ts @@ -0,0 +1,67 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import debugFactory from "debug"; +import { expect } from "chai"; +import { OPCUAServer } from "node-opcua-server"; + +import { startServer } from "./fixture/basic-opcua-server"; +import { OPCUAForm, OPCUAProtocolClient } from "../src/opcua-protocol-client"; + +const debug = debugFactory("binding-opcua:test"); + +describe("OPCUA Protocol Client", function () { + this.timeout(20000); + + let opcuaServer: OPCUAServer; + let endpoint: string; + before(async () => { + opcuaServer = await startServer(); + endpoint = opcuaServer.getEndpointUrl(); + debug(`endpoint = ${endpoint}`); + }); + after(async () => { + await opcuaServer.shutdown(); + }); + + it("should read temperature and pressure property without recreating a connection", async () => { + const client = new OPCUAProtocolClient(); + + try { + const form1: OPCUAForm = { + href: `${endpoint}?id=ns=1;s=Temperature`, + contentType: "application/json", + }; + + await client.readResource(form1); + expect(client.connectionCount).equals(1); + + await client.readResource(form1); + expect(client.connectionCount).equals(1); + + const form2: OPCUAForm = { + href: `${endpoint}?id=ns=1;s=Pressure`, + contentType: "application/json", + }; + + await client.readResource(form2); + + // connection should be reused, eventhough hrefs differ + expect(client.connectionCount).equals(1); + } finally { + await client.stop(); + } + }); +}); From 1b26f4846a5929180aab9be936dc5e569de765f2 Mon Sep 17 00:00:00 2001 From: Sterfive's NodeWoT team Date: Mon, 27 Oct 2025 09:26:07 +0100 Subject: [PATCH 3/6] chore(binding-opcua): refactor codec into multiple files --- packages/binding-opcua/src/codec.ts | 279 +----------------- .../src/codecs/opcua-binary-codec.ts | 53 ++++ .../src/codecs/opcua-data-schemas.ts | 128 ++++++++ .../src/codecs/opcua-json-codec.ts | 155 ++++++++++ .../src/opcua-protocol-client.ts | 2 +- .../test/schema-validation-test.ts | 2 +- 6 files changed, 341 insertions(+), 278 deletions(-) create mode 100644 packages/binding-opcua/src/codecs/opcua-binary-codec.ts create mode 100644 packages/binding-opcua/src/codecs/opcua-data-schemas.ts create mode 100644 packages/binding-opcua/src/codecs/opcua-json-codec.ts diff --git a/packages/binding-opcua/src/codec.ts b/packages/binding-opcua/src/codec.ts index 77add05b2..3177b55f9 100644 --- a/packages/binding-opcua/src/codec.ts +++ b/packages/binding-opcua/src/codec.ts @@ -12,279 +12,6 @@ * * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 ********************************************************************************/ - -import { ContentCodec, DataSchema, createLoggers } from "@node-wot/core"; -import { DataValue } from "node-opcua-data-value"; -import { DataType, Variant } from "node-opcua-variant"; -import Ajv from "ajv"; -import addFormats from "ajv-formats"; - -// see https://www.w3.org/Protocols/rfc1341/4_Content-Type.html -import { - opcuaJsonEncodeDataValue, - opcuaJsonDecodeDataValue, - opcuaJsonDecodeVariant, - DataValueJSON, - opcuaJsonEncodeVariant, -} from "node-opcua-json"; -import { BinaryStream } from "node-opcua-binary-stream"; -import { DataSchemaValue } from "wot-typescript-definitions"; - -const { debug } = createLoggers("binding-opcua", "codec"); - -// Strict mode has a lot of other checks and it prevents runtime unexpected problems -// TODO: in the future we should use the strict mode -const ajv = new Ajv({ strict: false }); -addFormats(ajv); -/** - * this schema, describe the node-opcua JSON format for a DataValue object - * - * const pojo = (new DataValue({})).toString(); - * - */ -export const schemaDataValue = { - type: ["object"], // "number", "integer", "string", "boolean", "array", "null"], - properties: { - serverPicoseconds: { type: "integer" }, - sourcePicoseconds: { type: "integer" }, - serverTimestamp: { type: "string", /* format: "date", */ nullable: true }, - sourceTimestamp: { type: "string", /* format: "date", */ nullable: true }, - statusCode: { - type: ["object"], - properties: { - value: { - type: "number", - }, - }, - }, - value: { - type: ["object"], - properties: { - dataType: { - type: ["string", "integer"], - }, - arrayType: { - type: ["string"], - }, - value: { - type: ["number", "integer", "string", "boolean", "array", "null", "object"], - }, - dimension: { - type: ["array"], - items: { type: "integer" }, - }, - additionalProperties: false, - }, - }, - }, - additionalProperties: true, -}; - -export const schemaVariantJSONNull = { - type: "null", - nullable: true, -}; - -export const schemaVariantJSON = { - type: "object", - properties: { - Type: { - type: ["number"], - }, - Body: { - type: ["number", "integer", "string", "boolean", "array", "null", "object"], - nullable: true, - }, - Dimensions: { - type: ["array"], - items: { type: "integer" }, - }, - }, - additionalProperties: false, - required: ["Type", "Body"], -}; - -export const schemaDataValueJSON1 = { - type: ["object"], // "number", "integer", "string", "boolean", "array", "null"], - properties: { - ServerPicoseconds: { type: "integer" }, - SourcePicoseconds: { type: "integer" }, - ServerTimestamp: { - type: "string" /*, format: "date" */, - }, - SourceTimestamp: { - type: "string" /*, format: "date" */, - }, - StatusCode: { - type: "integer", - minimum: 0, - }, - - Value: schemaVariantJSON, - Value1: { type: "number", nullable: true }, - - Value2: { - oneOf: [schemaVariantJSON, schemaVariantJSONNull], - }, - }, - - additionalProperties: false, - required: ["Value"], -}; -export const schemaDataValueJSON2 = { - properties: { - Value: { type: "null" }, - }, -}; -export const schemaDataValueJSON = { - oneOf: [schemaDataValueJSON2, schemaDataValueJSON1], -}; -export const schemaDataValueJSONValidate = ajv.compile(schemaDataValueJSON); -export const schemaDataValueValidate = ajv.compile(schemaDataValue); - -export function formatForNodeWoT(dataValue: DataValueJSON): DataValueJSON { - // remove unwanted/unneeded properties - delete dataValue.SourcePicoseconds; - delete dataValue.ServerPicoseconds; - delete dataValue.ServerTimestamp; - return dataValue; -} - -// application/json => is equivalent to application/opcua+json;type=Value - -export class OpcuaJSONCodec implements ContentCodec { - getMediaType(): string { - return "application/opcua+json"; - } - - bytesToValue(bytes: Buffer, schema: DataSchema, parameters?: { [key: string]: string }): DataSchemaValue { - const type = parameters?.type ?? "DataValue"; - let parsed = JSON.parse(bytes.toString()); - - const wantDataValue = parameters?.to === "DataValue" || false; - - switch (type) { - case "DataValue": { - const isValid = schemaDataValueJSONValidate(parsed); - if (!isValid) { - debug(`bytesToValue: parsed = ${parsed}`); - debug(`bytesToValue: ${schemaDataValueJSONValidate.errors}`); - throw new Error("Invalid JSON dataValue : " + JSON.stringify(parsed, null, " ")); - } - if (wantDataValue) { - return opcuaJsonDecodeDataValue(parsed); - } - return formatForNodeWoT(opcuaJsonEncodeDataValue(opcuaJsonDecodeDataValue(parsed), true)); - // return parsed; - } - case "Variant": { - if (wantDataValue) { - const dataValue = new DataValue({ value: opcuaJsonDecodeVariant(parsed) }); - return dataValue; - } - const v = opcuaJsonEncodeVariant(opcuaJsonDecodeVariant(parsed), true); - debug(`${v}`); - return v; - } - case "Value": { - if (wantDataValue) { - if (!parameters || !parameters.dataType) { - throw new Error("[OpcuaJSONCodec|bytesToValue]: unknown dataType for Value encoding" + type); - } - if (parameters.dataType === DataType[DataType.DateTime]) { - parsed = new Date(parsed); - } - const value = { - dataType: DataType[parameters.dataType as keyof typeof DataType], - value: parsed, - }; - return new DataValue({ value }); - } else { - if (parameters?.dataType === DataType[DataType.DateTime]) { - parsed = new Date(parsed); - } - return parsed; - } - } - default: - throw new Error("[OpcuaJSONCodec|bytesToValue]: Invalid type " + type); - } - } - - valueToBytes(value: unknown, _schema: DataSchema, parameters?: { [key: string]: string }): Buffer { - const type = parameters?.type ?? "DataValue"; - switch (type) { - case "DataValue": { - let dataValueJSON: DataValueJSON; - if (value instanceof DataValue) { - dataValueJSON = opcuaJsonEncodeDataValue(value, true); - } else if (value instanceof Variant) { - dataValueJSON = opcuaJsonEncodeDataValue(new DataValue({ value }), true); - } else if (typeof value === "string") { - dataValueJSON = JSON.parse(value) as DataValueJSON; - } else { - dataValueJSON = opcuaJsonEncodeDataValue(opcuaJsonDecodeDataValue(value as DataValueJSON), true); - } - dataValueJSON = formatForNodeWoT(dataValueJSON); - return Buffer.from(JSON.stringify(dataValueJSON), "ascii"); - } - case "Variant": { - if (value instanceof DataValue) { - value = opcuaJsonEncodeVariant(value.value, true); - } else if (value instanceof Variant) { - value = opcuaJsonEncodeVariant(value, true); - } else if (typeof value === "string") { - value = JSON.parse(value); - } - return Buffer.from(JSON.stringify(value), "ascii"); - } - case "Value": { - if (value === undefined) { - return Buffer.alloc(0); - } - if (value instanceof DataValue) { - value = opcuaJsonEncodeVariant(value.value, false); - } else if (value instanceof Variant) { - value = opcuaJsonEncodeVariant(value, false); - } - return Buffer.from(JSON.stringify(value), "ascii"); - } - default: - throw new Error("[OpcuaJSONCodec|valueToBytes]: Invalid type : " + type); - } - } -} -export const theOpcuaJSONCodec = new OpcuaJSONCodec(); - -export class OpcuaBinaryCodec implements ContentCodec { - getMediaType(): string { - return "application/opcua+octet-stream"; // see Ege - } - - bytesToValue(bytes: Buffer, schema: DataSchema, parameters?: { [key: string]: string }): DataValueJSON { - const binaryStream = new BinaryStream(bytes); - const dataValue = new DataValue(); - dataValue.decode(binaryStream); - return opcuaJsonEncodeDataValue(dataValue, true); - } - - valueToBytes( - dataValue: DataValueJSON | DataValue, - schema: DataSchema, - parameters?: { [key: string]: string } - ): Buffer { - dataValue = dataValue instanceof DataValue ? dataValue : opcuaJsonDecodeDataValue(dataValue); - - // remove unwanted properties - dataValue.serverPicoseconds = 0; - dataValue.sourcePicoseconds = 0; - dataValue.serverTimestamp = null; - - const size = dataValue.binaryStoreSize(); - const stream = new BinaryStream(size); - dataValue.encode(stream); - const body = stream.buffer; - return body; - } -} -export const theOpcuaBinaryCodec = new OpcuaBinaryCodec(); +export * from "./codecs/opcua-binary-codec"; +export * from "./codecs/opcua-json-codec"; +export * from "./codecs/opcua-data-schemas"; diff --git a/packages/binding-opcua/src/codecs/opcua-binary-codec.ts b/packages/binding-opcua/src/codecs/opcua-binary-codec.ts new file mode 100644 index 000000000..9c914919c --- /dev/null +++ b/packages/binding-opcua/src/codecs/opcua-binary-codec.ts @@ -0,0 +1,53 @@ +/******************************************************************************** + * Copyright (c) 2022 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { ContentCodec, DataSchema } from "@node-wot/core"; +import { BinaryStream } from "node-opcua-binary-stream"; +import { DataValue } from "node-opcua-data-value"; + +import { opcuaJsonEncodeDataValue, opcuaJsonDecodeDataValue, DataValueJSON } from "node-opcua-json"; + +export class OpcuaBinaryCodec implements ContentCodec { + getMediaType(): string { + return "application/opcua+octet-stream"; // see Ege + } + + bytesToValue(bytes: Buffer, schema: DataSchema, parameters?: { [key: string]: string }): DataValueJSON { + const binaryStream = new BinaryStream(bytes); + const dataValue = new DataValue(); + dataValue.decode(binaryStream); + return opcuaJsonEncodeDataValue(dataValue, true); + } + + valueToBytes( + dataValue: DataValueJSON | DataValue, + schema: DataSchema, + parameters?: { [key: string]: string } + ): Buffer { + dataValue = dataValue instanceof DataValue ? dataValue : opcuaJsonDecodeDataValue(dataValue); + + // remove unwanted properties + dataValue.serverPicoseconds = 0; + dataValue.sourcePicoseconds = 0; + dataValue.serverTimestamp = null; + + const size = dataValue.binaryStoreSize(); + const stream = new BinaryStream(size); + dataValue.encode(stream); + const body = stream.buffer; + return body; + } +} +export const theOpcuaBinaryCodec = new OpcuaBinaryCodec(); diff --git a/packages/binding-opcua/src/codecs/opcua-data-schemas.ts b/packages/binding-opcua/src/codecs/opcua-data-schemas.ts new file mode 100644 index 000000000..dfbb98881 --- /dev/null +++ b/packages/binding-opcua/src/codecs/opcua-data-schemas.ts @@ -0,0 +1,128 @@ +/******************************************************************************** + * Copyright (c) 2022 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import Ajv from "ajv"; +import addFormats from "ajv-formats"; + +// Strict mode has a lot of other checks and it prevents runtime unexpected problems +// TODO: in the future we should use the strict mode +const ajv = new Ajv({ strict: false }); +addFormats(ajv); +/** + * this schema, describe the node-opcua JSON format for a DataValue object + * + * const pojo = (new DataValue({})).toString(); + * + */ +export const schemaDataValue = { + type: ["object"], // "number", "integer", "string", "boolean", "array", "null"], + properties: { + serverPicoseconds: { type: "integer" }, + sourcePicoseconds: { type: "integer" }, + serverTimestamp: { type: "string", /* format: "date", */ nullable: true }, + sourceTimestamp: { type: "string", /* format: "date", */ nullable: true }, + statusCode: { + type: ["object"], + properties: { + value: { + type: "number", + }, + }, + }, + value: { + type: ["object"], + properties: { + dataType: { + type: ["string", "integer"], + }, + arrayType: { + type: ["string"], + }, + value: { + type: ["number", "integer", "string", "boolean", "array", "null", "object"], + }, + dimension: { + type: ["array"], + items: { type: "integer" }, + }, + additionalProperties: false, + }, + }, + }, + additionalProperties: true, +}; + +export const schemaVariantJSONNull = { + type: "null", + nullable: true, +}; + +export const schemaVariantJSON = { + type: "object", + properties: { + Type: { + type: "number", + description: "The OPCUA DataType of the Variant, must be 'number'", + }, + Body: { + type: ["number", "integer", "string", "boolean", "array", "null", "object"], + nullable: true, + }, + Dimensions: { + type: "array", + items: { type: "integer" }, + }, + }, + additionalProperties: false, + required: ["Type", "Body"], +}; + +export const schemaDataValueJSON1 = { + type: ["object"], // "number", "integer", "string", "boolean", "array", "null"], + properties: { + ServerPicoseconds: { type: "integer" }, + SourcePicoseconds: { type: "integer" }, + ServerTimestamp: { + type: "string" /*, format: "date" */, + }, + SourceTimestamp: { + type: "string" /*, format: "date" */, + }, + StatusCode: { + type: "integer", + minimum: 0, + }, + + Value: schemaVariantJSON, + Value1: { type: "number", nullable: true }, + + Value2: { + oneOf: [schemaVariantJSON, schemaVariantJSONNull], + }, + }, + + additionalProperties: false, + required: ["Value"], +}; +export const schemaDataValueJSON2 = { + properties: { + Value: { type: "null" }, + }, +}; +export const schemaDataValueJSON = { + oneOf: [schemaDataValueJSON2, schemaDataValueJSON1], +}; +export const schemaDataValueJSONValidate = ajv.compile(schemaDataValueJSON); +export const schemaDataValueValidate = ajv.compile(schemaDataValue); diff --git a/packages/binding-opcua/src/codecs/opcua-json-codec.ts b/packages/binding-opcua/src/codecs/opcua-json-codec.ts new file mode 100644 index 000000000..75673b876 --- /dev/null +++ b/packages/binding-opcua/src/codecs/opcua-json-codec.ts @@ -0,0 +1,155 @@ +/******************************************************************************** + * Copyright (c) 2022 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * Document License (2015-05-13) which is available at + * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ + +import { ContentCodec, DataSchema, createLoggers } from "@node-wot/core"; +import { DataValue } from "node-opcua-data-value"; +import { DataType, Variant } from "node-opcua-variant"; + +// see https://www.w3.org/Protocols/rfc1341/4_Content-Type.html +import { + opcuaJsonEncodeDataValue, + opcuaJsonDecodeDataValue, + opcuaJsonDecodeVariant, + DataValueJSON, + opcuaJsonEncodeVariant, +} from "node-opcua-json"; +import { DataSchemaValue } from "wot-typescript-definitions"; +import { schemaDataValueJSONValidate } from "./opcua-data-schemas"; + +const { debug } = createLoggers("binding-opcua", "codec"); + +// Strict mode has a lot of other checks and it prevents runtime unexpected problems +// TODO: in the future we should use the strict mode + +/** + * this schema, describe the node-opcua JSON format for a DataValue object + * + * const pojo = (new DataValue({})).toString(); + * + */ + +export function formatForNodeWoT(dataValue: DataValueJSON): DataValueJSON { + // remove unwanted/unneeded properties + delete dataValue.SourcePicoseconds; + delete dataValue.ServerPicoseconds; + delete dataValue.ServerTimestamp; + return dataValue; +} + +// application/json => is equivalent to application/opcua+json;type=Value + +export class OpcuaJSONCodec implements ContentCodec { + getMediaType(): string { + return "application/opcua+json"; + } + + bytesToValue(bytes: Buffer, schema: DataSchema, parameters?: { [key: string]: string }): DataSchemaValue { + const type = parameters?.type ?? "DataValue"; + let parsed = JSON.parse(bytes.toString()); + + const wantDataValue = parameters?.to === "DataValue" || false; + + switch (type) { + case "DataValue": { + const isValid = schemaDataValueJSONValidate(parsed); + if (!isValid) { + debug(`bytesToValue: parsed = ${parsed}`); + debug(`bytesToValue: ${schemaDataValueJSONValidate.errors}`); + throw new Error("Invalid JSON dataValue : " + JSON.stringify(parsed, null, " ")); + } + if (wantDataValue) { + return opcuaJsonDecodeDataValue(parsed); + } + return formatForNodeWoT(opcuaJsonEncodeDataValue(opcuaJsonDecodeDataValue(parsed), true)); + // return parsed; + } + case "Variant": { + if (wantDataValue) { + const dataValue = new DataValue({ value: opcuaJsonDecodeVariant(parsed) }); + return dataValue; + } + const v = opcuaJsonEncodeVariant(opcuaJsonDecodeVariant(parsed), true); + debug(`${v}`); + return v; + } + case "Value": { + if (wantDataValue) { + if (!parameters || !parameters.dataType) { + throw new Error("[OpcuaJSONCodec|bytesToValue]: unknown dataType for Value encoding" + type); + } + if (parameters.dataType === DataType[DataType.DateTime]) { + parsed = new Date(parsed); + } + const value = { + dataType: DataType[parameters.dataType as keyof typeof DataType], + value: parsed, + }; + return new DataValue({ value }); + } else { + if (parameters?.dataType === DataType[DataType.DateTime]) { + parsed = new Date(parsed); + } + return parsed; + } + } + default: + throw new Error("[OpcuaJSONCodec|bytesToValue]: Invalid type " + type); + } + } + + valueToBytes(value: unknown, _schema: DataSchema, parameters?: { [key: string]: string }): Buffer { + const type = parameters?.type ?? "DataValue"; + switch (type) { + case "DataValue": { + let dataValueJSON: DataValueJSON; + if (value instanceof DataValue) { + dataValueJSON = opcuaJsonEncodeDataValue(value, true); + } else if (value instanceof Variant) { + dataValueJSON = opcuaJsonEncodeDataValue(new DataValue({ value }), true); + } else if (typeof value === "string") { + dataValueJSON = JSON.parse(value) as DataValueJSON; + } else { + dataValueJSON = opcuaJsonEncodeDataValue(opcuaJsonDecodeDataValue(value as DataValueJSON), true); + } + dataValueJSON = formatForNodeWoT(dataValueJSON); + return Buffer.from(JSON.stringify(dataValueJSON), "ascii"); + } + case "Variant": { + if (value instanceof DataValue) { + value = opcuaJsonEncodeVariant(value.value, true); + } else if (value instanceof Variant) { + value = opcuaJsonEncodeVariant(value, true); + } else if (typeof value === "string") { + value = JSON.parse(value); + } + return Buffer.from(JSON.stringify(value), "ascii"); + } + case "Value": { + if (value === undefined) { + return Buffer.alloc(0); + } + if (value instanceof DataValue) { + value = opcuaJsonEncodeVariant(value.value, false); + } else if (value instanceof Variant) { + value = opcuaJsonEncodeVariant(value, false); + } + return Buffer.from(JSON.stringify(value), "ascii"); + } + default: + throw new Error("[OpcuaJSONCodec|valueToBytes]: Invalid type : " + type); + } + } +} +export const theOpcuaJSONCodec = new OpcuaJSONCodec(); diff --git a/packages/binding-opcua/src/opcua-protocol-client.ts b/packages/binding-opcua/src/opcua-protocol-client.ts index c5d48c7fc..cb6afa05d 100644 --- a/packages/binding-opcua/src/opcua-protocol-client.ts +++ b/packages/binding-opcua/src/opcua-protocol-client.ts @@ -62,7 +62,7 @@ import { opcuaJsonEncodeVariant } from "node-opcua-json"; import { Argument, MessageSecurityMode, UserTokenType } from "node-opcua-types"; import { isGoodish2 } from "node-opcua"; -import { schemaDataValue } from "./codec"; +import { schemaDataValue } from "./codecs/opcua-data-schemas"; import { OPCUACAuthenticationScheme, OPCUAChannelSecurityScheme } from "./security-scheme"; import { CertificateManagerSingleton } from "./certificate-manager-singleton"; import { resolveChannelSecurity, resolvedUserIdentity } from "./opcua-security-resolver"; diff --git a/packages/binding-opcua/test/schema-validation-test.ts b/packages/binding-opcua/test/schema-validation-test.ts index 1d5e1496c..ad01533d5 100644 --- a/packages/binding-opcua/test/schema-validation-test.ts +++ b/packages/binding-opcua/test/schema-validation-test.ts @@ -19,7 +19,7 @@ import { expect } from "chai"; import { DataType, DataValue, StatusCodes, VariantArrayType } from "node-opcua-client"; import { opcuaJsonEncodeDataValue } from "node-opcua-json"; -import { schemaDataValueValidate, schemaDataValueJSONValidate } from "../src/codec"; +import { schemaDataValueValidate, schemaDataValueJSONValidate } from "../src/codecs/opcua-data-schemas"; const { debug } = createLoggers("binding-opcua", "schema-validation-test"); From e11d66870f1ba678e9a11b514c287f3bf40f8f02 Mon Sep 17 00:00:00 2001 From: Etienne Date: Mon, 3 Nov 2025 17:59:35 +0100 Subject: [PATCH 4/6] Update packages/binding-opcua/src/codecs/opcua-binary-codec.ts Co-authored-by: danielpeintner --- packages/binding-opcua/src/codecs/opcua-binary-codec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/binding-opcua/src/codecs/opcua-binary-codec.ts b/packages/binding-opcua/src/codecs/opcua-binary-codec.ts index 9c914919c..f36ac8c77 100644 --- a/packages/binding-opcua/src/codecs/opcua-binary-codec.ts +++ b/packages/binding-opcua/src/codecs/opcua-binary-codec.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022 Contributors to the Eclipse Foundation + * Copyright (c) 2025 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. From 764881613c9a730cde8feb5dae747a95a5a9d25d Mon Sep 17 00:00:00 2001 From: Etienne Date: Mon, 3 Nov 2025 17:59:44 +0100 Subject: [PATCH 5/6] Update packages/binding-opcua/src/codecs/opcua-data-schemas.ts Co-authored-by: danielpeintner --- packages/binding-opcua/src/codecs/opcua-data-schemas.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/binding-opcua/src/codecs/opcua-data-schemas.ts b/packages/binding-opcua/src/codecs/opcua-data-schemas.ts index dfbb98881..886f25a0b 100644 --- a/packages/binding-opcua/src/codecs/opcua-data-schemas.ts +++ b/packages/binding-opcua/src/codecs/opcua-data-schemas.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022 Contributors to the Eclipse Foundation + * Copyright (c) 2025 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. From 5a38d23ffbb9f241c4567dd92c41f94eaf99e7a0 Mon Sep 17 00:00:00 2001 From: Etienne Date: Mon, 3 Nov 2025 17:59:53 +0100 Subject: [PATCH 6/6] Update packages/binding-opcua/src/codecs/opcua-json-codec.ts Co-authored-by: danielpeintner --- packages/binding-opcua/src/codecs/opcua-json-codec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/binding-opcua/src/codecs/opcua-json-codec.ts b/packages/binding-opcua/src/codecs/opcua-json-codec.ts index 75673b876..c860d9da0 100644 --- a/packages/binding-opcua/src/codecs/opcua-json-codec.ts +++ b/packages/binding-opcua/src/codecs/opcua-json-codec.ts @@ -1,5 +1,5 @@ /******************************************************************************** - * Copyright (c) 2022 Contributors to the Eclipse Foundation + * Copyright (c) 2025 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership.