diff --git a/package-lock.json b/package-lock.json index ffd5501c3..91a341d04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11336,7 +11336,6 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12338,7 +12337,6 @@ "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.0.6" @@ -13269,7 +13267,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -13289,7 +13286,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -13306,7 +13302,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -13325,7 +13320,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -14843,6 +14837,19 @@ "resolved": "https://registry.npmjs.org/uritemplate/-/uritemplate-0.3.4.tgz", "integrity": "sha512-enADBvHfhjrwxFMTVWeIIYz51SZ91uC6o2MR/NQTVljJB6HTZ8eQL3Q7JBj3RxNISA14MOwJaU3vpf5R6dyxHA==" }, + "node_modules/url": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", + "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -14859,6 +14866,12 @@ "integrity": "sha512-UaZ2+50am4HwrV2crR/JAf63Q4VvPYphe63WGeoJxeu8gmOm0qxPt+KsukfakPNrX9aymGNEkkaoICwn+OuvBw==", "license": "Apache-2.0" }, + "node_modules/url/node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "license": "MIT" + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -15719,7 +15732,8 @@ "node-opcua-status-code": "2.139.0", "node-opcua-types": "2.143.0", "node-opcua-variant": "2.142.0", - "rxjs": "5.5.11" + "rxjs": "5.5.11", + "url": "^0.11.4" }, "devDependencies": { "should": "^13.2.3" diff --git a/packages/binding-opcua/README.md b/packages/binding-opcua/README.md index e87152a91..9968b9121 100644 --- a/packages/binding-opcua/README.md +++ b/packages/binding-opcua/README.md @@ -28,9 +28,8 @@ const thingDescription = { type: "number", forms: [ { - href: "opc.tcp://opcuademo.sterfive.com:26543", // endpoint, + href: "opc.tcp://opcuademo.sterfive.com:26543?id=ns=1;s=PumpSpeed", op: ["readproperty", "observeproperty"], - "opcua:nodeId": "ns=1;s=PumpSpeed", }, ], }, @@ -64,60 +63,23 @@ import { thingDescription } from "./demo-opcua-thing-description"; ### Run the Example App -The `examples/src/opcua` folder contains a set of typescript demo that shows you +The `examples/src/bindings/opcua` folder contains a set of typescript demo that shows you how to define a thing description containing OPCUA Variables and methods. - `demo-opcua1.ts` shows how to define and read an OPC-UA variable in WoT. - `demo-opcua2.ts` shows how to subscribe to an OPC-UA variable in WoT. - `opcua-coffee-machine-demo.ts` demonstrates how to define and invoke OPCUA methods as WoT actions. -### Form extensions +### Format for href -#### href - -the `href` property must contains a OPCUA endpoint url in the form `opc.tcp://MACHINE:PORT/Application` +The `href` must contain an OPCUA endpoint url in the form of `opc.tcp://
:/?id=` such as for instance: -`opc.tcp://opcuademo.sterfive.com:26543` or `opc.tcp://localhost:48010` - -#### opcua:nodeId - -The form must contain an `opcua:nodeId` property that describes the nodeId of the OPCUA Variable to read/write/subscribe or the nodeId of the OPCUA Object related to the action. - -The `opcua:nodeId` can have 2 forms: - -- a **NodeId** as a string, such as `"ns=1;i=1234"` , for instance: - -```javascript -"opcua:nodeId": "ns=1;s=\"Machine\".\"Component\"" -``` - -- or **browsePath**: The browse path will be converted into the corresponding nodeId at runtime when first encountered. - -``` -"opcua:nodeId": { root: "i=84", path: "/Objects/2:DeviceSet/1:CoffeeMachine" }, -``` +`opc.tcp://opcuademo.sterfive.com:26543?id=ns=1;s=PumpSpeed` -### opcua:method +`` has the following expectations: -for example: - -```typescript -const thingDescription = { - // ... - actions: { - brewCoffee: { - forms: [ - { - href: "opc.tcp://opcuademo.sterfive.com:26543", - op: ["invokeaction"], - "opcua:nodeId": { root: "i=84", path: "/Objects/2:DeviceSet/1:CoffeeMachine" }, - "opcua:method": { root: "i=84", path: "/Objects/2:DeviceSet/1:CoffeeMachine/2:MethodSet/9:Start" }, - }, - ], - }, - }, -}; -``` +- any hash character (`#`) must be URL encoded (`%23`) +- any ampersand character (`&`) must be URL encoded (`%26`) ### defining a property @@ -133,9 +95,8 @@ const thingDescription = { type: "number", forms: [ { - href: "opc.tcp://opcuademo.sterfive.com:26543", + href: "opc.tcp://opcuademo.sterfive.com:26543?id=ns=1;s=Temperature", op: ["readproperty", "observeproperty"], - "opcua:nodeId": "ns=1;s=Temperature", }, ], }, diff --git a/packages/binding-opcua/package.json b/packages/binding-opcua/package.json index 50598e19c..eb82a63b4 100644 --- a/packages/binding-opcua/package.json +++ b/packages/binding-opcua/package.json @@ -43,7 +43,8 @@ "node-opcua-status-code": "2.139.0", "node-opcua-types": "2.143.0", "node-opcua-variant": "2.142.0", - "rxjs": "5.5.11" + "rxjs": "5.5.11", + "url": "^0.11.4" }, "scripts": { "build": "tsc -b", diff --git a/packages/binding-opcua/src/opcua-protocol-client.ts b/packages/binding-opcua/src/opcua-protocol-client.ts index 6ecf42e0a..37b94e5ee 100644 --- a/packages/binding-opcua/src/opcua-protocol-client.ts +++ b/packages/binding-opcua/src/opcua-protocol-client.ts @@ -16,6 +16,7 @@ import { Subscription } from "rxjs/Subscription"; import { promisify } from "util"; import { Readable } from "stream"; +import { URL } from "url"; import { ProtocolClient, Content, ContentSerdes, Form, SecurityScheme, createLoggers } from "@node-wot/core"; @@ -44,7 +45,6 @@ import { makeBrowsePath } from "node-opcua-service-translate-browse-path"; import { StatusCodes } from "node-opcua-status-code"; import { schemaDataValue } from "./codec"; -import { FormElementProperty } from "wot-thing-description-types"; import { opcuaJsonEncodeVariant } from "node-opcua-json"; import { Argument, BrowseDescription, BrowseResult } from "node-opcua-types"; import { isGoodish2, ReferenceTypeIds } from "node-opcua"; @@ -63,20 +63,12 @@ export type NodeIdLike2 = NodeIdLike & { }; export interface FormPartialNodeDescription { - "opcua:nodeId": NodeIdLike | NodeByBrowsePath; + /** @deprecated use href instead */ + "opcua:nodeId"?: NodeIdLike | NodeByBrowsePath; } export interface OPCUAForm extends Form, FormPartialNodeDescription {} -export interface OPCUAFormElement extends FormElementProperty, FormPartialNodeDescription {} - -export interface OPCUAFormInvoke extends OPCUAForm { - "opcua:method": NodeIdLike | NodeByBrowsePath; -} -export interface OPCUAFormSubscribe extends OPCUAForm { - "opcua:samplingInterval"?: number; -} - interface OPCUAConnection { session: ClientSession; client: OPCUAClient; @@ -260,23 +252,37 @@ export class OPCUAProtocolClient implements ProtocolClient { } } - private async _resolveNodeId(form: OPCUAForm): Promise { - const fNodeId = form["opcua:nodeId"]; + static getNodeId(form: OPCUAForm): NodeIdLike | NodeByBrowsePath { + let fNodeId; + if (form.href != null && form.href !== "" && form.href !== "/") { + // parse node id from href + // Note: href needs to be absolute + const url = new URL(form.href); + if (url != null && url.search != null && url.search.startsWith("?")) { + const searchParams = new URLSearchParams(url.search.substring(1)); + fNodeId = searchParams.get("id"); + } + } if (fNodeId == null) { - debug(`resolveNodeId: form = ${form}`); - throw new Error("form must expose a 'opcua:nodeId'"); + // fallback to *old* way + fNodeId = form["opcua:nodeId"]; + if (fNodeId == null) { + debug(`resolveNodeId: form = ${form}`); + throw new Error("form must expose nodeId via href or 'opcua:nodeId'"); + } } + return fNodeId; + } + + private async _resolveNodeId(form: OPCUAForm): Promise { + const fNodeId = OPCUAProtocolClient.getNodeId(form); return this._resolveNodeId2(form, fNodeId); } /** extract the dataType of a variable */ private async _predictDataType(form: OPCUAForm): Promise { - const fNodeId = form["opcua:nodeId"]; - if (fNodeId == null) { - debug(`resolveNodeId: form = ${form}`); - throw new Error("form must expose a 'opcua:nodeId'"); - } - const nodeId = await this._resolveNodeId2(form, fNodeId); + const nodeId = await this._resolveNodeId(form); + return await this._withSession(form, async (session) => { const dataTypeOrNull = await getBuiltInDataType(session, nodeId); if (dataTypeOrNull !== undefined && dataTypeOrNull !== DataType.Null) { @@ -286,14 +292,14 @@ export class OPCUAProtocolClient implements ProtocolClient { }); } - private async _resolveMethodNodeId(form: OPCUAFormInvoke): Promise { + private async _resolveMethodNodeId(form: OPCUAForm): Promise { // const objectNode = this._resolveNodeId(form); const fNodeId = form["opcua:method"]; if (fNodeId == null) { debug(`resolveNodeId: form = ${form}`); - throw new Error("form must expose a 'opcua:nodeId'"); + throw new Error("form must expose a 'opcua:method'"); } - return this._resolveNodeId2(form, fNodeId); + return this._resolveNodeId2(form, fNodeId as NodeIdLike | NodeByBrowsePath); } public async readResource(form: OPCUAForm): Promise { @@ -328,7 +334,7 @@ export class OPCUAProtocolClient implements ProtocolClient { } } - public async invokeResource(form: OPCUAFormInvoke, content: Content): Promise { + public async invokeResource(form: OPCUAForm, content: Content): Promise { return await this._withSession(form, async (session) => { const objectId = await this._resolveNodeId(form); const methodId = await this._resolveMethodNodeId(form); @@ -362,7 +368,7 @@ export class OPCUAProtocolClient implements ProtocolClient { error?: (error: Error) => void, complete?: () => void ): Promise { - debug(`subscribeResource: form ${form["opcua:nodeId"]}`); + debug(`subscribeResource: form ${OPCUAProtocolClient.getNodeId(form)}`); return this._withSubscription(form, async (session, subscription) => { const nodeId = await this._resolveNodeId(form); @@ -437,7 +443,7 @@ export class OPCUAProtocolClient implements ProtocolClient { } async unlinkResource(form: OPCUAForm): Promise { - debug(`unlinkResource: form ${form["opcua:nodeId"]}`); + debug(`unlinkResource: form ${OPCUAProtocolClient.getNodeId(form)}`); this._withSubscription(form, async (session, subscription) => { const nodeId = await this._resolveNodeId(form); await this._unmonitor(nodeId); @@ -575,7 +581,7 @@ export class OPCUAProtocolClient implements ProtocolClient { private async _resolveInputArguments( session: IBasicSession, - form: OPCUAFormInvoke, + form: OPCUAForm, content: Content | undefined | null, argumentDefinition: ArgumentDefinition ): Promise { @@ -623,7 +629,7 @@ export class OPCUAProtocolClient implements ProtocolClient { private async _resolveOutputArguments( session: IBasicSession, - form: OPCUAFormInvoke, + form: OPCUAForm, argumentDefinition: ArgumentDefinition, outputVariants: Variant[] ): Promise { diff --git a/packages/binding-opcua/test/client-test.ts b/packages/binding-opcua/test/client-test.ts index 34f3e81ec..d776b5259 100644 --- a/packages/binding-opcua/test/client-test.ts +++ b/packages/binding-opcua/test/client-test.ts @@ -19,7 +19,7 @@ import { ContentSerdes, createLoggers } from "@node-wot/core"; import { VariableIds, OPCUAServer } from "node-opcua"; -import { OPCUAProtocolClient, OPCUAForm, OPCUAFormInvoke } from "../src/opcua-protocol-client"; +import { OPCUAProtocolClient, OPCUAForm } from "../src/opcua-protocol-client"; import { OpcuaJSONCodec, schemaDataValue } from "../src/codec"; import { startServer } from "./fixture/basic-opcua-server"; @@ -92,7 +92,7 @@ describe("OPCUA Client", function () { expected: new Date("2022-01-31T10:45:00.000Z"), }, ].forEach(({ contentType, expected }, index) => { - it(`Y1-${index} should read a topic with contentType= ${contentType}`, async () => { + it(`Y1a-${index} should read a topic with contentType= ${contentType}`, async () => { const readForm: OPCUAForm = { href: endpoint, "opcua:nodeId": "ns=1;s=ManufacturingDate", @@ -107,6 +107,28 @@ describe("OPCUA Client", function () { const codecSerDes = ContentSerdes.get(); const dataValue = codecSerDes.contentToValue(content2, schemaDataValue) as Record; + // (deal with always changing date ) + if (dataValue.SourceTimestamp != null) { + expect(dataValue.SourceTimestamp).to.be.instanceOf(Date); + dataValue.SourceTimestamp = "*"; + } + debug(`${dataValue}`); + expect(dataValue).to.eql(expected); + }); + it(`Y1b-${index} should read a topic with contentType= ${contentType}`, async () => { + const readForm: OPCUAForm = { + href: endpoint + "?id=ns=1;s=ManufacturingDate", + contentType, + }; + + const content = await client.readResource(readForm); + const content2 = { ...content, body: await content.toBuffer() }; + + debug(`readResource returned: ${content2.body.toString("ascii")}`); + + const codecSerDes = ContentSerdes.get(); + const dataValue = codecSerDes.contentToValue(content2, schemaDataValue) as Record; + // (deal with always changing date ) if (dataValue.SourceTimestamp != null) { expect(dataValue.SourceTimestamp).to.be.instanceOf(Date); @@ -164,7 +186,7 @@ describe("OPCUA Client", function () { required: ["TargetTemperature"], }; - const form: OPCUAFormInvoke = { + const form: OPCUAForm = { href: endpoint, "opcua:nodeId": { root: "i=84", path: "/Objects/1:MySensor" }, "opcua:method": { root: "i=84", path: "/Objects/1:MySensor/2:MethodSet/1:SetTemperatureSetPoint" }, diff --git a/packages/binding-opcua/test/opcua-href-test.ts b/packages/binding-opcua/test/opcua-href-test.ts new file mode 100644 index 000000000..4138306f9 --- /dev/null +++ b/packages/binding-opcua/test/opcua-href-test.ts @@ -0,0 +1,45 @@ +/******************************************************************************** + * 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 { expect } from "chai"; + +import { OPCUAProtocolClient } from "../src/opcua-protocol-client"; + +describe("OPCUA Hrefs", () => { + it("Example-1", () => { + const form = { href: "opc.tcp://192.168.120.237:4840/?id=ns=10;i=12345" }; + const nodeId = OPCUAProtocolClient.getNodeId(form); + expect(nodeId).equal("ns=10;i=12345"); + }); + it("Example-2", () => { + const form = { href: "opc.tcp://192.168.120.237:4840/?id=nsu=http://widgets.com/schemas/hello;s=水 World" }; + const nodeId = OPCUAProtocolClient.getNodeId(form); + expect(nodeId).equal("nsu=http://widgets.com/schemas/hello;s=水 World"); + }); + it("Example-3 #", () => { + const form = { href: "opc.tcp://192.168.120.237:4840/?id=nsu=http://example.com/hello%23;s=temperature" }; + const nodeId = OPCUAProtocolClient.getNodeId(form); + expect(nodeId).equal("nsu=http://example.com/hello#;s=temperature"); // %23 -> # + }); + it("Example-4 &", () => { + const form = { href: "opc.tcp://192.168.120.237:4840/?id=nsu=http://example.com/a%26b" }; + const nodeId = OPCUAProtocolClient.getNodeId(form); + expect(nodeId).equal("nsu=http://example.com/a&b"); // %26 -> & + }); + it("Example-5 relative", () => { + const form = { href: "/?id=ns=10;i=12345" }; + expect(() => OPCUAProtocolClient.getNodeId(form)).to.throw(Error, "Invalid URL"); + }); +}); diff --git a/packages/examples/src/bindings/opcua/demo-opcua-thing-description.ts b/packages/examples/src/bindings/opcua/demo-opcua-thing-description.ts index d015cef1d..bec245db5 100644 --- a/packages/examples/src/bindings/opcua/demo-opcua-thing-description.ts +++ b/packages/examples/src/bindings/opcua/demo-opcua-thing-description.ts @@ -34,9 +34,8 @@ export const thingDescription: WoT.ThingDescription = { type: "number", forms: [ { - href: endpointUrl, + href: endpointUrl + "?id=ns=1;s=PumpSpeed", op: ["readproperty", "observeproperty"], - "opcua:nodeId": "ns=1;s=PumpSpeed", }, ], }, @@ -48,9 +47,8 @@ export const thingDescription: WoT.ThingDescription = { type: "number", forms: [ { - href: endpointUrl, + href: endpointUrl + "?id=ns=1;s=Temperature", op: ["readproperty", "observeproperty"], - "opcua:nodeId": "ns=1;s=Temperature", }, ], },