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",
},
],
},