Skip to content
Open
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
55 changes: 55 additions & 0 deletions packages/binding-opcua/src/opcua-data-schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/********************************************************************************
* 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 { DataSchema } from "wot-typescript-definitions";

export const variantDataSchema: DataSchema = {
description: "A JSON structure representing a OPCUA Variant encoded in JSON format using 1.04 specification",
type: "object",
properties: {
Type: {
type: "number",
enum: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25],
description: "The OPCUA DataType of the Variant, must be 'number'",
},
Body: {
description: "The body can be any JSON value",
// "type": ["string", "number", "object", "array", "boolean", "null"]
},
},
required: ["Type", "Body"],
additionalProperties: false,
};

export const opcuaVariableSchemaType: Record<string, DataSchema> = {
number: {
type: "number",
description: "A simple number",
},
dataValue: {
description: "A JSON structure representing a OPCUA DataValue encoded in JSON format using 1.04 specification",
type: "object",
properties: {
SourceTimestamp: {
// type: "date",
description: "The sourceTimestamp of the DataValue",
},
Value: variantDataSchema,
},
required: ["Value"],
additionalProperties: false,
},
variant: variantDataSchema,
};
72 changes: 71 additions & 1 deletion packages/binding-opcua/src/opcua-protocol-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import { Subscription } from "rxjs/Subscription";
import { promisify } from "util";
import { Readable } from "stream";
import { URL } from "url";
import {
ProtocolClient,
Content,
Expand Down Expand Up @@ -157,6 +156,20 @@ function _variantToJSON(variant: Variant, contentType: string) {
}
}

const dataTypeToSchema = new Map<DataType, string>([
[DataType.Boolean, "boolean"],
[DataType.SByte, "int8"],
[DataType.Byte, "uint8"],
[DataType.Int16, "int16"],
[DataType.UInt16, "uint16"],
[DataType.Int32, "int32"],
[DataType.UInt32, "uint32"],
[DataType.Int64, "int64"],
[DataType.UInt64, "uint64"],
[DataType.Float, "number"],
[DataType.Double, "number"],
[DataType.String, "string"],
]);
export class OPCUAProtocolClient implements ProtocolClient {
private _connections: Map<string, OPCUAConnectionEx> = new Map<string, OPCUAConnectionEx>();

Expand Down Expand Up @@ -600,6 +613,63 @@ export class OPCUAProtocolClient implements ProtocolClient {
const variantInJson = opcuaJsonEncodeVariant(dataValue.value, false);
const content = contentSerDes.valueToContent(variantInJson, schemaDataValue, contentType);
return content;
} else if (contentType === "application/octet-stream") {
Copy link
Member

Choose a reason for hiding this comment

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

I think the general problem with the current code is that it right away decodes the data on the wire. It should first get the data raw and afterwards the ContentSerdes decodes the data.

Copy link
Member

Choose a reason for hiding this comment

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

Take a look at CoAP for example where the readResource call returns the raw data

public async readResource(form: CoapForm): Promise<Content> {
const req = await this.generateRequest(form, "GET");
debug(`CoapClient sending ${req.statusCode} to ${form.href}`);
return new Promise<Content>((resolve, reject) => {
req.on("response", (res: ObserveReadStream) => {
debug(`CoapClient received ${res.code} from ${form.href}`);
debug(`CoapClient received Content-Format: ${res.headers["Content-Format"]}`);
// FIXME does not work with blockwise because of node-coap
const contentType = (res.headers["Content-Format"] as string) ?? form.contentType;
resolve(new Content(contentType, Readable.from(res.payload)));
});
req.on("error", (err: Error) => reject(err));
req.end();
});
}

Afterwards the configured ContentSerdes deals with the actual content and whether the "byte stream" is JSON, CBOR or anything else..

The same should happen with OPC UA....

Copy link
Contributor Author

Choose a reason for hiding this comment

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

the current code is that it right away decodes the data
In fact this code is where the encoding takes palce;( not decoding)

const variant = dataValue.value;
if (variant.arrayType !== VariantArrayType.Scalar) {
// for the time being we only support scalar values (limitation of the octet-stream codec)
throw new Error("application/octet-stream only supports scalar values");
}
switch (form.type) {
case "boolean": {
if (variant.dataType !== DataType.Boolean) {
throw new Error(
`application/octet-stream with type boolean requires a Variant with dataType Boolean - got ${DataType[variant.dataType]}`
);
}
return contentSerDes.valueToContent(variant.value, { type: "boolean" }, contentType);
}
case "integer": {
if (
variant.dataType !== DataType.Int16 &&
variant.dataType !== DataType.Int32 &&
variant.dataType !== DataType.Int64 &&
variant.dataType !== DataType.UInt16 &&
variant.dataType !== DataType.UInt32 &&
variant.dataType !== DataType.UInt64
) {
throw new Error(
`application/octet-stream with type integer requires a Variant with dataType Int16, Int32, Int64, UInt16, UInt32 or UInt64 - got ${DataType[variant.dataType]}`
);
}
const type = dataTypeToSchema.get(variant.dataType);
if (type === undefined) {
throw new Error(
`Internal Error: cannot find schema for dataType ${DataType[variant.dataType]}`
);
}
return contentSerDes.valueToContent(variant.value, { type }, contentType);
}
case "number": {
if (variant.dataType !== DataType.Float && variant.dataType !== DataType.Double) {
throw new Error(
`application/octet-stream with type number requires a Variant with dataType Float or Double - got ${DataType[variant.dataType]}`
);
}
return contentSerDes.valueToContent(variant.value, { type: "number" }, contentType);
}
case "string": {
if (variant.dataType !== DataType.String) {
throw new Error(
`application/octet-stream with type string requires a Variant with dataType String - got ${DataType[variant.dataType]}`
);
}
return contentSerDes.valueToContent(variant.value, { type: "string" }, contentType);
}
default:
throw new Error(
`application/octet-stream only supports primitive types (boolean, integer, number, string) - got ${form.type}`
);
}
}
const content = contentSerDes.valueToContent(dataValue, schemaDataValue, contentType);
return content;
Expand Down
121 changes: 118 additions & 3 deletions packages/binding-opcua/test/full-opcua-thing-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ import { expect } from "chai";
import { Servient, createLoggers } from "@node-wot/core";
import { InteractionOptions } from "wot-typescript-definitions";

import { OPCUAServer } from "node-opcua";
import { DataType, makeBrowsePath, OPCUAServer, StatusCodes, UAVariable } from "node-opcua";

import { OPCUAClientFactory } from "../src";
import { startServer } from "./fixture/basic-opcua-server";
import { opcuaVariableSchemaType } from "../src/opcua-data-schemas";
const endpoint = "opc.tcp://localhost:7890";

const { debug, info } = createLoggers("binding-opcua", "full-opcua-thing-test");
const { debug, info, error } = createLoggers("binding-opcua", "full-opcua-thing-test");

const thingDescription: WoT.ThingDescription = {
"@context": "https://www.w3.org/2019/wot/td/v1",
Expand All @@ -49,7 +50,14 @@ const thingDescription: WoT.ThingDescription = {
observable: true,
readOnly: true,
unit: "°C",
type: "number",
oneOf: [
{
type: "number",
description: "A simple number",
},
opcuaVariableSchemaType.dataValue,
opcuaVariableSchemaType.variant,
],
"opcua:nodeId": { root: "i=84", path: "/Objects/1:MySensor/2:ParameterSet/1:Temperature" },
// Don't specify type here as it could be multi form: type: [ "object", "number" ],
forms: [
Expand All @@ -59,6 +67,7 @@ const thingDescription: WoT.ThingDescription = {
op: ["readproperty", "observeproperty"],
"opcua:nodeId": { root: "i=84", path: "/Objects/1:MySensor/2:ParameterSet/1:Temperature" },
contentType: "application/json",
type: "number",
},
{
href: "/", // endpoint,
Expand All @@ -78,6 +87,13 @@ const thingDescription: WoT.ThingDescription = {
"opcua:nodeId": { root: "i=84", path: "/Objects/1:MySensor/2:ParameterSet/1:Temperature" },
contentType: "application/opcua+json;type=DataValue",
},
{
href: "/", // endpoint,
op: ["readproperty", "observeproperty"],
"opcua:nodeId": { root: "i=84", path: "/Objects/1:MySensor/2:ParameterSet/1:Temperature" },
contentType: "application/octet-stream", // equivalent to Variant
type: "number",
},
],
},
// Enriched value like provided by OPCUA
Expand Down Expand Up @@ -323,11 +339,34 @@ const thingDescription: WoT.ThingDescription = {
describe("Full OPCUA Thing Test", () => {
let opcuaServer: OPCUAServer;
let endpoint: string;

function setTemperature(value: number, sourceTimestamp: Date) {
const browsePath = makeBrowsePath("ObjectsFolder", "/1:MySensor/2:ParameterSet/1:Temperature");
const browsePathResult = opcuaServer.engine.addressSpace?.browsePath(browsePath);
if (!browsePathResult || browsePathResult.statusCode !== StatusCodes.Good) {
error("Cannot find Temperature node");
return;
}
const nodeId = browsePathResult.targets![0].targetId!;
const uaTemperature = opcuaServer.engine.addressSpace?.findNode(nodeId) as UAVariable;
if (uaTemperature) {
uaTemperature.setValueFromSource(
{
dataType: DataType.Double,
value,
},
StatusCodes.Good,
sourceTimestamp
);
}
}

before(async () => {
opcuaServer = await startServer();
endpoint = opcuaServer.getEndpointUrl();
debug(`endpoint = ${endpoint}`);

setTemperature(25, new Date("2022-01-01T12:00:00Z"));
// adjust TD to endpoint
thingDescription.base = endpoint;
(thingDescription.opcua as unknown as { endpoint: string }).endpoint = endpoint;
Expand Down Expand Up @@ -649,4 +688,80 @@ describe("Full OPCUA Thing Test", () => {
await servient.shutdown();
}
});

// Please refer to the description of this codec on how to decode and encode plain register
// values to/from JavaScript objects (See `OctetstreamCodec`).
// **Note** `array` and `object` schema are not supported.
[
// Var
{ property: "temperature", contentType: "application/octet-stream", expectedValue: 25 },
{ property: "temperature", contentType: "application/octet-stream;byteSeq=BIG_ENDIAN", expectedValue: 25 },
{ property: "temperature", contentType: "application/octet-stream;byteSeq=LITTLE_ENDIAN", expectedValue: 25 },
{
property: "temperature",
contentType: "application/octet-stream;byteSeq=BIG_ENDIAN_BYTE_SWAP",
expectedValue: 25,
},
{
property: "temperature",
contentType: "application/octet-stream;byteSeq=LITTLE_ENDIAN_BYTE_SWAP",
expectedValue: 25,
},
{ property: "temperature", contentType: "application/json", expectedValue: 25 },

// DataValue
{
property: "temperature",
contentType: "application/opcua+json;type=DataValue",
expectedValue: {
SourceTimestamp: new Date("2022-01-01T12:00:00Z"),
Value: {
Type: 11,
Body: 25,
},
},
},
{
property: "temperature",
contentType: "application/opcua+json;type=Variant",
expectedValue: {
Type: 11,
Body: 25,
},
},
].map(({ contentType, property, expectedValue }, index) => {
it(`CONTENT-TYPE-${index} - should work with this encoding format- contentType=${contentType}`, async () => {
setTemperature(25, new Date("2022-01-01T12:00:00Z"));

const { thing, servient } = await makeThing();

const propertyForm = thing.getThingDescription().properties?.[property].forms;
if (!propertyForm) {
expect.fail(`no forms for ${property}`);
}

// find exact match of contentType first
let formIndex = propertyForm.findIndex((form) => form.contentType === contentType);
if (formIndex === undefined || formIndex < 0) {
const mainCodec = contentType.split(";")[0];
// fallback to main codec match
formIndex = propertyForm.findIndex((form) => form.contentType === mainCodec);
if (formIndex === undefined || formIndex < 0) {
debug(propertyForm.map((f) => f.contentType).join(","));
expect.fail(`Cannot find form with contentType ${contentType}`);
}
}

debug(`Using form index ${formIndex} with contentType ${contentType}`);

try {
const content = await thing.readProperty(property, { formIndex });
const value = await content.value();
debug(`${property} value is: ${value}`);
expect(value).to.eql(expectedValue);
} finally {
await servient.shutdown();
}
});
});
});
12 changes: 12 additions & 0 deletions packages/core/src/codecs/octetstream-codec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ export default class OctetstreamCodec implements ContentCodec {
const bigEndian = !(parameters.byteSeq?.includes(Endianness.LITTLE_ENDIAN) === true); // default to big endian
let dataType: string = schema?.type;

if (!dataType) {
// try to find a type in oneOf
if (schema?.oneOf !== undefined && Array.isArray(schema.oneOf)) {
for (const sch of schema.oneOf) {
if (typeof sch.type === "string") {
dataType = sch.type;
schema = sch;
break;
}
}
}
}
if (!dataType) {
throw new Error("Missing 'type' property in schema");
}
Expand Down