Skip to content

Commit 92fcbd8

Browse files
committed
chore(binding-opcua) fix application/octet-stream encoding/decoding #1400
1 parent 8dc6ed8 commit 92fcbd8

File tree

4 files changed

+256
-4
lines changed

4 files changed

+256
-4
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/********************************************************************************
2+
* Copyright (c) 2022 Contributors to the Eclipse Foundation
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information regarding copyright ownership.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License v. 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and
10+
* Document License (2015-05-13) which is available at
11+
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document.
12+
*
13+
* SPDX-License-Identifier: EPL-2.0 OR W3C-20150513
14+
********************************************************************************/
15+
16+
import { DataSchema } from "wot-typescript-definitions";
17+
18+
export const variantDataSchema: DataSchema = {
19+
description: "A JSON structure representing a OPCUA Variant encoded in JSON format using 1.04 specification",
20+
type: "object",
21+
properties: {
22+
Type: {
23+
type: "number",
24+
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],
25+
description: "The OPCUA DataType of the Variant, must be 'number'",
26+
},
27+
Body: {
28+
description: "The body can be any JSON value",
29+
// "type": ["string", "number", "object", "array", "boolean", "null"]
30+
},
31+
},
32+
required: ["Type", "Body"],
33+
additionalProperties: false,
34+
};
35+
36+
export const opcuaVariableSchemaType: Record<string, DataSchema> = {
37+
number: {
38+
type: "number",
39+
description: "A simple number",
40+
},
41+
dataValue: {
42+
description: "A JSON structure representing a OPCUA DataValue encoded in JSON format using 1.04 specification",
43+
type: "object",
44+
properties: {
45+
SourceTimestamp: {
46+
// type: "date",
47+
description: "The sourceTimestamp of the DataValue",
48+
},
49+
Value: variantDataSchema,
50+
},
51+
required: ["Value"],
52+
additionalProperties: false,
53+
},
54+
variant: variantDataSchema,
55+
};

packages/binding-opcua/src/opcua-protocol-client.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
import { Subscription } from "rxjs/Subscription";
1717
import { promisify } from "util";
1818
import { Readable } from "stream";
19-
import { URL } from "url";
2019
import {
2120
ProtocolClient,
2221
Content,
@@ -157,6 +156,20 @@ function _variantToJSON(variant: Variant, contentType: string) {
157156
}
158157
}
159158

159+
const dataTypeToSchema = new Map<DataType, string>([
160+
[DataType.Boolean, "boolean"],
161+
[DataType.SByte, "int8"],
162+
[DataType.Byte, "uint8"],
163+
[DataType.Int16, "int16"],
164+
[DataType.UInt16, "uint16"],
165+
[DataType.Int32, "int32"],
166+
[DataType.UInt32, "uint32"],
167+
[DataType.Int64, "int64"],
168+
[DataType.UInt64, "uint64"],
169+
[DataType.Float, "number"],
170+
[DataType.Double, "number"],
171+
[DataType.String, "string"],
172+
]);
160173
export class OPCUAProtocolClient implements ProtocolClient {
161174
private _connections: Map<string, OPCUAConnectionEx> = new Map<string, OPCUAConnectionEx>();
162175

@@ -600,6 +613,63 @@ export class OPCUAProtocolClient implements ProtocolClient {
600613
const variantInJson = opcuaJsonEncodeVariant(dataValue.value, false);
601614
const content = contentSerDes.valueToContent(variantInJson, schemaDataValue, contentType);
602615
return content;
616+
} else if (contentType === "application/octet-stream") {
617+
const variant = dataValue.value;
618+
if (variant.arrayType !== VariantArrayType.Scalar) {
619+
// for the time being we only support scalar values (limitation of the octet-stream codec)
620+
throw new Error("application/octet-stream only supports scalar values");
621+
}
622+
switch (form.type) {
623+
case "boolean": {
624+
if (variant.dataType !== DataType.Boolean) {
625+
throw new Error(
626+
`application/octet-stream with type boolean requires a Variant with dataType Boolean - got ${DataType[variant.dataType]}`
627+
);
628+
}
629+
return contentSerDes.valueToContent(variant.value, { type: "boolean" }, contentType);
630+
}
631+
case "integer": {
632+
if (
633+
variant.dataType !== DataType.Int16 &&
634+
variant.dataType !== DataType.Int32 &&
635+
variant.dataType !== DataType.Int64 &&
636+
variant.dataType !== DataType.UInt16 &&
637+
variant.dataType !== DataType.UInt32 &&
638+
variant.dataType !== DataType.UInt64
639+
) {
640+
throw new Error(
641+
`application/octet-stream with type integer requires a Variant with dataType Int16, Int32, Int64, UInt16, UInt32 or UInt64 - got ${DataType[variant.dataType]}`
642+
);
643+
}
644+
const type = dataTypeToSchema.get(variant.dataType);
645+
if (type === undefined) {
646+
throw new Error(
647+
`Internal Error: cannot find schema for dataType ${DataType[variant.dataType]}`
648+
);
649+
}
650+
return contentSerDes.valueToContent(variant.value, { type }, contentType);
651+
}
652+
case "number": {
653+
if (variant.dataType !== DataType.Float && variant.dataType !== DataType.Double) {
654+
throw new Error(
655+
`application/octet-stream with type number requires a Variant with dataType Float or Double - got ${DataType[variant.dataType]}`
656+
);
657+
}
658+
return contentSerDes.valueToContent(variant.value, { type: "number" }, contentType);
659+
}
660+
case "string": {
661+
if (variant.dataType !== DataType.String) {
662+
throw new Error(
663+
`application/octet-stream with type string requires a Variant with dataType String - got ${DataType[variant.dataType]}`
664+
);
665+
}
666+
return contentSerDes.valueToContent(variant.value, { type: "string" }, contentType);
667+
}
668+
default:
669+
throw new Error(
670+
`application/octet-stream only supports primitive types (boolean, integer, number, string) - got ${form.type}`
671+
);
672+
}
603673
}
604674
const content = contentSerDes.valueToContent(dataValue, schemaDataValue, contentType);
605675
return content;

packages/binding-opcua/test/full-opcua-thing-test.ts

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ import { expect } from "chai";
1919
import { Servient, createLoggers } from "@node-wot/core";
2020
import { InteractionOptions } from "wot-typescript-definitions";
2121

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

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

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

3031
const thingDescription: WoT.ThingDescription = {
3132
"@context": "https://www.w3.org/2019/wot/td/v1",
@@ -49,7 +50,14 @@ const thingDescription: WoT.ThingDescription = {
4950
observable: true,
5051
readOnly: true,
5152
unit: "°C",
52-
type: "number",
53+
oneOf: [
54+
{
55+
type: "number",
56+
description: "A simple number",
57+
},
58+
opcuaVariableSchemaType.dataValue,
59+
opcuaVariableSchemaType.variant,
60+
],
5361
"opcua:nodeId": { root: "i=84", path: "/Objects/1:MySensor/2:ParameterSet/1:Temperature" },
5462
// Don't specify type here as it could be multi form: type: [ "object", "number" ],
5563
forms: [
@@ -59,6 +67,7 @@ const thingDescription: WoT.ThingDescription = {
5967
op: ["readproperty", "observeproperty"],
6068
"opcua:nodeId": { root: "i=84", path: "/Objects/1:MySensor/2:ParameterSet/1:Temperature" },
6169
contentType: "application/json",
70+
type: "number",
6271
},
6372
{
6473
href: "/", // endpoint,
@@ -78,6 +87,13 @@ const thingDescription: WoT.ThingDescription = {
7887
"opcua:nodeId": { root: "i=84", path: "/Objects/1:MySensor/2:ParameterSet/1:Temperature" },
7988
contentType: "application/opcua+json;type=DataValue",
8089
},
90+
{
91+
href: "/", // endpoint,
92+
op: ["readproperty", "observeproperty"],
93+
"opcua:nodeId": { root: "i=84", path: "/Objects/1:MySensor/2:ParameterSet/1:Temperature" },
94+
contentType: "application/octet-stream", // equivalent to Variant
95+
type: "number",
96+
},
8197
],
8298
},
8399
// Enriched value like provided by OPCUA
@@ -323,11 +339,34 @@ const thingDescription: WoT.ThingDescription = {
323339
describe("Full OPCUA Thing Test", () => {
324340
let opcuaServer: OPCUAServer;
325341
let endpoint: string;
342+
343+
function setTemperature(value: number, sourceTimestamp: Date) {
344+
const browsePath = makeBrowsePath("ObjectsFolder", "/1:MySensor/2:ParameterSet/1:Temperature");
345+
const browsePathResult = opcuaServer.engine.addressSpace?.browsePath(browsePath);
346+
if (!browsePathResult || browsePathResult.statusCode !== StatusCodes.Good) {
347+
error("Cannot find Temperature node");
348+
return;
349+
}
350+
const nodeId = browsePathResult.targets![0].targetId!;
351+
const uaTemperature = opcuaServer.engine.addressSpace?.findNode(nodeId) as UAVariable;
352+
if (uaTemperature) {
353+
uaTemperature.setValueFromSource(
354+
{
355+
dataType: DataType.Double,
356+
value,
357+
},
358+
StatusCodes.Good,
359+
sourceTimestamp
360+
);
361+
}
362+
}
363+
326364
before(async () => {
327365
opcuaServer = await startServer();
328366
endpoint = opcuaServer.getEndpointUrl();
329367
debug(`endpoint = ${endpoint}`);
330368

369+
setTemperature(25, new Date("2022-01-01T12:00:00Z"));
331370
// adjust TD to endpoint
332371
thingDescription.base = endpoint;
333372
(thingDescription.opcua as unknown as { endpoint: string }).endpoint = endpoint;
@@ -649,4 +688,80 @@ describe("Full OPCUA Thing Test", () => {
649688
await servient.shutdown();
650689
}
651690
});
691+
692+
// Please refer to the description of this codec on how to decode and encode plain register
693+
// values to/from JavaScript objects (See `OctetstreamCodec`).
694+
// **Note** `array` and `object` schema are not supported.
695+
[
696+
// Var
697+
{ property: "temperature", contentType: "application/octet-stream", expectedValue: 25 },
698+
{ property: "temperature", contentType: "application/octet-stream;byteSeq=BIG_ENDIAN", expectedValue: 25 },
699+
{ property: "temperature", contentType: "application/octet-stream;byteSeq=LITTLE_ENDIAN", expectedValue: 25 },
700+
{
701+
property: "temperature",
702+
contentType: "application/octet-stream;byteSeq=BIG_ENDIAN_BYTE_SWAP",
703+
expectedValue: 25,
704+
},
705+
{
706+
property: "temperature",
707+
contentType: "application/octet-stream;byteSeq=LITTLE_ENDIAN_BYTE_SWAP",
708+
expectedValue: 25,
709+
},
710+
{ property: "temperature", contentType: "application/json", expectedValue: 25 },
711+
712+
// DataValue
713+
{
714+
property: "temperature",
715+
contentType: "application/opcua+json;type=DataValue",
716+
expectedValue: {
717+
SourceTimestamp: new Date("2022-01-01T12:00:00Z"),
718+
Value: {
719+
Type: 11,
720+
Body: 25,
721+
},
722+
},
723+
},
724+
{
725+
property: "temperature",
726+
contentType: "application/opcua+json;type=Variant",
727+
expectedValue: {
728+
Type: 11,
729+
Body: 25,
730+
},
731+
},
732+
].map(({ contentType, property, expectedValue }, index) => {
733+
it(`CONTENT-TYPE-${index} - should work with this encoding format- contentType=${contentType}`, async () => {
734+
setTemperature(25, new Date("2022-01-01T12:00:00Z"));
735+
736+
const { thing, servient } = await makeThing();
737+
738+
const propertyForm = thing.getThingDescription().properties?.[property].forms;
739+
if (!propertyForm) {
740+
expect.fail(`no forms for ${property}`);
741+
}
742+
743+
// find exact match of contentType first
744+
let formIndex = propertyForm.findIndex((form) => form.contentType === contentType);
745+
if (formIndex === undefined || formIndex < 0) {
746+
const mainCodec = contentType.split(";")[0];
747+
// fallback to main codec match
748+
formIndex = propertyForm.findIndex((form) => form.contentType === mainCodec);
749+
if (formIndex === undefined || formIndex < 0) {
750+
debug(propertyForm.map((f) => f.contentType).join(","));
751+
expect.fail(`Cannot find form with contentType ${contentType}`);
752+
}
753+
}
754+
755+
debug(`Using form index ${formIndex} with contentType ${contentType}`);
756+
757+
try {
758+
const content = await thing.readProperty(property, { formIndex });
759+
const value = await content.value();
760+
debug(`${property} value is: ${value}`);
761+
expect(value).to.eql(expectedValue);
762+
} finally {
763+
await servient.shutdown();
764+
}
765+
});
766+
});
652767
});

packages/core/src/codecs/octetstream-codec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,18 @@ export default class OctetstreamCodec implements ContentCodec {
9696
const bigEndian = !(parameters.byteSeq?.includes(Endianness.LITTLE_ENDIAN) === true); // default to big endian
9797
let dataType: string = schema?.type;
9898

99+
if (!dataType) {
100+
// try to find a type in oneOf
101+
if (schema?.oneOf !== undefined && Array.isArray(schema.oneOf)) {
102+
for (const sch of schema.oneOf) {
103+
if (typeof sch.type === "string") {
104+
dataType = sch.type;
105+
schema = sch;
106+
break;
107+
}
108+
}
109+
}
110+
}
99111
if (!dataType) {
100112
throw new Error("Missing 'type' property in schema");
101113
}

0 commit comments

Comments
 (0)