diff --git a/packages/binding-opcua/src/opcua-data-schemas.ts b/packages/binding-opcua/src/opcua-data-schemas.ts new file mode 100644 index 000000000..619477802 --- /dev/null +++ b/packages/binding-opcua/src/opcua-data-schemas.ts @@ -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 = { + 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, +}; diff --git a/packages/binding-opcua/src/opcua-protocol-client.ts b/packages/binding-opcua/src/opcua-protocol-client.ts index 08bb99278..f3261eae8 100644 --- a/packages/binding-opcua/src/opcua-protocol-client.ts +++ b/packages/binding-opcua/src/opcua-protocol-client.ts @@ -16,7 +16,6 @@ import { Subscription } from "rxjs/Subscription"; import { promisify } from "util"; import { Readable } from "stream"; -import { URL } from "url"; import { ProtocolClient, Content, @@ -157,6 +156,20 @@ function _variantToJSON(variant: Variant, contentType: string) { } } +const dataTypeToSchema = new Map([ + [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 = new Map(); @@ -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") { + 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; diff --git a/packages/binding-opcua/test/full-opcua-thing-test.ts b/packages/binding-opcua/test/full-opcua-thing-test.ts index ce5e6a34d..e5c480243 100644 --- a/packages/binding-opcua/test/full-opcua-thing-test.ts +++ b/packages/binding-opcua/test/full-opcua-thing-test.ts @@ -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", @@ -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: [ @@ -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, @@ -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 @@ -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; @@ -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(); + } + }); + }); }); diff --git a/packages/core/src/codecs/octetstream-codec.ts b/packages/core/src/codecs/octetstream-codec.ts index 5aec25931..12b20017a 100644 --- a/packages/core/src/codecs/octetstream-codec.ts +++ b/packages/core/src/codecs/octetstream-codec.ts @@ -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"); }