Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 9 additions & 48 deletions packages/binding-opcua/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
],
},
Expand Down Expand Up @@ -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://<address>:<port>/?id=<nodeId>`
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
`<nodeId>` 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

Expand All @@ -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",
},
],
},
Expand Down
3 changes: 2 additions & 1 deletion packages/binding-opcua/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
64 changes: 35 additions & 29 deletions packages/binding-opcua/src/opcua-protocol-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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";
Expand All @@ -63,20 +63,12 @@ export type NodeIdLike2 = NodeIdLike & {
};

export interface FormPartialNodeDescription {
"opcua:nodeId": NodeIdLike | NodeByBrowsePath;
/** @deprecated use href instead */
"opcua:nodeId"?: NodeIdLike | NodeByBrowsePath;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General question/comment:
Why do we have interface FormPartialNodeDescription and OPCUAFormInvoke.
It is somewhat similar without extending (OPCUA)Form.

Moreover, OPCUAForm extends FormPartialNodeDescription again !?
Some dependencies I don't understand, and I wonder whether we can simplify it.

@erossignon

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I simplified it, and we do not see any problems when testing via unit tests nor when testing with real devices

}

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;
Expand Down Expand Up @@ -260,23 +252,37 @@ export class OPCUAProtocolClient implements ProtocolClient {
}
}

private async _resolveNodeId(form: OPCUAForm): Promise<NodeId> {
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<NodeId> {
const fNodeId = OPCUAProtocolClient.getNodeId(form);
return this._resolveNodeId2(form, fNodeId);
}

/** extract the dataType of a variable */
private async _predictDataType(form: OPCUAForm): Promise<DataType> {
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<DataType>(form, async (session) => {
const dataTypeOrNull = await getBuiltInDataType(session, nodeId);
if (dataTypeOrNull !== undefined && dataTypeOrNull !== DataType.Null) {
Expand All @@ -286,14 +292,14 @@ export class OPCUAProtocolClient implements ProtocolClient {
});
}

private async _resolveMethodNodeId(form: OPCUAFormInvoke): Promise<NodeId> {
private async _resolveMethodNodeId(form: OPCUAForm): Promise<NodeId> {
// 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<Content> {
Expand Down Expand Up @@ -328,7 +334,7 @@ export class OPCUAProtocolClient implements ProtocolClient {
}
}

public async invokeResource(form: OPCUAFormInvoke, content: Content): Promise<Content> {
public async invokeResource(form: OPCUAForm, content: Content): Promise<Content> {
return await this._withSession(form, async (session) => {
const objectId = await this._resolveNodeId(form);
const methodId = await this._resolveMethodNodeId(form);
Expand Down Expand Up @@ -362,7 +368,7 @@ export class OPCUAProtocolClient implements ProtocolClient {
error?: (error: Error) => void,
complete?: () => void
): Promise<Subscription> {
debug(`subscribeResource: form ${form["opcua:nodeId"]}`);
debug(`subscribeResource: form ${OPCUAProtocolClient.getNodeId(form)}`);

return this._withSubscription<Subscription>(form, async (session, subscription) => {
const nodeId = await this._resolveNodeId(form);
Expand Down Expand Up @@ -437,7 +443,7 @@ export class OPCUAProtocolClient implements ProtocolClient {
}

async unlinkResource(form: OPCUAForm): Promise<void> {
debug(`unlinkResource: form ${form["opcua:nodeId"]}`);
debug(`unlinkResource: form ${OPCUAProtocolClient.getNodeId(form)}`);
this._withSubscription<void>(form, async (session, subscription) => {
const nodeId = await this._resolveNodeId(form);
await this._unmonitor(nodeId);
Expand Down Expand Up @@ -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<VariantOptions[]> {
Expand Down Expand Up @@ -623,7 +629,7 @@ export class OPCUAProtocolClient implements ProtocolClient {

private async _resolveOutputArguments(
session: IBasicSession,
form: OPCUAFormInvoke,
form: OPCUAForm,
argumentDefinition: ArgumentDefinition,
outputVariants: Variant[]
): Promise<Content> {
Expand Down
28 changes: 25 additions & 3 deletions packages/binding-opcua/test/client-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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",
Expand All @@ -107,6 +107,28 @@ describe("OPCUA Client", function () {
const codecSerDes = ContentSerdes.get();
const dataValue = codecSerDes.contentToValue(content2, schemaDataValue) as Record<string, unknown>;

// (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<string, unknown>;

// (deal with always changing date )
if (dataValue.SourceTimestamp != null) {
expect(dataValue.SourceTimestamp).to.be.instanceOf(Date);
Expand Down Expand Up @@ -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" },
Expand Down
Loading