Skip to content

Commit 05173e7

Browse files
codepitbullmarregui
authored andcommitted
Add an otpion to allow opcua metadata to be read
1 parent 24c2a8a commit 05173e7

15 files changed

+190
-38
lines changed

modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapter.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@ public void createTagSchema(
573573
return;
574574
}
575575
conn.client()
576-
.ifPresentOrElse(client -> new JsonSchemaGenerator(client).createMqttPayloadJsonSchema(tag)
576+
.ifPresentOrElse(client -> new JsonSchemaGenerator(client, config.isIncludeMetadata()).createMqttPayloadJsonSchema(tag)
577577
.whenComplete((result, throwable) -> {
578578
if (throwable == null) {
579579
result.ifPresentOrElse(schema -> {

modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/config/BidirectionalOpcUaSpecificAdapterConfig.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ public BidirectionalOpcUaSpecificAdapterConfig(
3232
@JsonProperty("tls") final @Nullable Tls tls,
3333
@JsonProperty(value = "opcuaToMqtt") final @Nullable OpcUaToMqttConfig opcuaToMqttConfig,
3434
@JsonProperty("security") final @Nullable Security security,
35-
@JsonProperty("connectionOptions") final @Nullable ConnectionOptions connectionOptions) {
36-
super(uri, overrideUri, applicationUri, auth, tls, opcuaToMqttConfig, security, connectionOptions);
35+
@JsonProperty("connectionOptions") final @Nullable ConnectionOptions connectionOptions,
36+
@JsonProperty("includeMetadata") final @Nullable Boolean includeMetadata) {
37+
super(uri, overrideUri, applicationUri, auth, tls, opcuaToMqttConfig, security, connectionOptions, includeMetadata);
3738
}
3839
}

modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/config/OpcUaSpecificAdapterConfig.java

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ public class OpcUaSpecificAdapterConfig implements ProtocolSpecificAdapterConfig
8989
description = "Controls how heartbeats and reconnects are handled")
9090
private final @NotNull ConnectionOptions connectionOptions;
9191

92+
@JsonProperty("includeMetadata")
93+
@ModuleConfigField(title = "Include Metadata",
94+
description = "Include OPC UA metadata (timestamps, status code) in JSON output and schema",
95+
defaultValue = "false")
96+
private final boolean includeMetadata;
97+
9298
@JsonCreator
9399
public OpcUaSpecificAdapterConfig(
94100
@JsonProperty(value = "uri", required = true) final @NotNull String uri,
@@ -98,7 +104,8 @@ public OpcUaSpecificAdapterConfig(
98104
@JsonProperty("tls") final @Nullable Tls tls,
99105
@JsonProperty("opcuaToMqtt") final @Nullable OpcUaToMqttConfig opcuaToMqttConfig,
100106
@JsonProperty("security") final @Nullable Security security,
101-
@JsonProperty("connectionOptions") final @Nullable ConnectionOptions connectionOptions) {
107+
@JsonProperty("connectionOptions") final @Nullable ConnectionOptions connectionOptions,
108+
@JsonProperty("includeMetadata") final @Nullable Boolean includeMetadata) {
102109
this.uri = uri;
103110
this.overrideUri = requireNonNullElse(overrideUri, false);
104111
this.applicationUri = (applicationUri != null && !applicationUri.isBlank()) ? applicationUri : "";
@@ -107,6 +114,7 @@ public OpcUaSpecificAdapterConfig(
107114
this.opcuaToMqttConfig = requireNonNullElseGet(opcuaToMqttConfig, OpcUaToMqttConfig::defaultOpcUaToMqttConfig);
108115
this.security = requireNonNullElse(security, new Security(Constants.DEFAULT_SECURITY_POLICY));
109116
this.connectionOptions = requireNonNullElseGet(connectionOptions, ConnectionOptions::defaultConnectionOptions);
117+
this.includeMetadata = requireNonNullElse(includeMetadata, false);
110118
}
111119

112120

@@ -142,11 +150,16 @@ public OpcUaSpecificAdapterConfig(
142150
return connectionOptions;
143151
}
144152

153+
public boolean isIncludeMetadata() {
154+
return includeMetadata;
155+
}
156+
145157
@Override
146158
public boolean equals(final Object o) {
147159
if (o == null || getClass() != o.getClass()) return false;
148160
final OpcUaSpecificAdapterConfig that = (OpcUaSpecificAdapterConfig) o;
149161
return getOverrideUri().equals(that.getOverrideUri()) &&
162+
includeMetadata == that.includeMetadata &&
150163
Objects.equals(id, that.id) &&
151164
Objects.equals(getUri(), that.getUri()) &&
152165
Objects.equals(getApplicationUri(), that.getApplicationUri()) &&
@@ -167,7 +180,8 @@ public int hashCode() {
167180
getTls(),
168181
getSecurity(),
169182
getOpcuaToMqttConfig(),
170-
connectionOptions);
183+
connectionOptions,
184+
includeMetadata);
171185
}
172186

173187
@Override
@@ -194,6 +208,8 @@ public String toString() {
194208
opcuaToMqttConfig +
195209
", connectionOptions=" +
196210
connectionOptions +
211+
", includeMetadata=" +
212+
includeMetadata +
197213
'}';
198214
}
199215
}

modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/listeners/OpcUaSubscriptionLifecycleHandler.java

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -122,18 +122,6 @@ public OpcUaSubscriptionLifecycleHandler(
122122
return Optional.empty();
123123
}
124124

125-
private static @NotNull String extractPayload(final @NotNull OpcUaClient client, final @NotNull DataValue value)
126-
throws UaException {
127-
if (value.getValue().getValue() == null) {
128-
return "";
129-
}
130-
131-
final ByteBuffer byteBuffer = OpcUaToJsonConverter.convertPayload(client.getDynamicEncodingContext(), value);
132-
final byte[] buffer = new byte[byteBuffer.remaining()];
133-
byteBuffer.get(buffer);
134-
return new String(buffer, StandardCharsets.UTF_8);
135-
}
136-
137125
/**
138126
* Subscribes to the OPC UA client.
139127
* If a subscription ID is provided, it attempts to transfer the subscription.
@@ -322,4 +310,18 @@ public void onDataReceived(
322310
}
323311
}
324312
}
313+
314+
private @NotNull String extractPayload(final @NotNull OpcUaClient client, final @NotNull DataValue value)
315+
throws UaException {
316+
if (value.getValue().getValue() == null) {
317+
return "";
318+
}
319+
320+
final ByteBuffer byteBuffer = OpcUaToJsonConverter.convertPayload(client.getDynamicEncodingContext(),
321+
value,
322+
config.isIncludeMetadata());
323+
final byte[] buffer = new byte[byteBuffer.remaining()];
324+
byteBuffer.get(buffer);
325+
return new String(buffer, StandardCharsets.UTF_8);
326+
}
325327
}

modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/northbound/OpcUaToJsonConverter.java

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,30 +67,40 @@ public class OpcUaToJsonConverter {
6767
public static @NotNull ByteBuffer convertPayload(
6868
final @NotNull EncodingContext serializationContext,
6969
final @NotNull DataValue dataValue) {
70+
return convertPayload(serializationContext, dataValue, false);
71+
}
72+
73+
public static @NotNull ByteBuffer convertPayload(
74+
final @NotNull EncodingContext serializationContext,
75+
final @NotNull DataValue dataValue,
76+
final boolean includeMetadata) {
7077
final Object value = dataValue.getValue().getValue();
7178
if (value == null) {
7279
return ByteBuffer.wrap(EMPTY_BYTES);
7380
}
7481
final JsonObject jsonObject = new JsonObject();
75-
if (value instanceof final DataValue v) {
76-
if (v.getStatusCode().getValue() > 0) {
77-
jsonObject.add("statusCode", convertStatusCode(v.getStatusCode()));
82+
83+
// Extract metadata from the outer DataValue when includeMetadata is enabled
84+
if (includeMetadata) {
85+
if (dataValue.getStatusCode() != null && dataValue.getStatusCode().getValue() > 0) {
86+
jsonObject.add("statusCode", convertStatusCode(dataValue.getStatusCode()));
7887
}
79-
if (v.getSourceTime() != null) {
88+
if (dataValue.getSourceTime() != null) {
8089
jsonObject.add("sourceTimestamp",
81-
new JsonPrimitive(DateTimeFormatter.ISO_INSTANT.format(v.getSourceTime().getJavaInstant())));
90+
new JsonPrimitive(DateTimeFormatter.ISO_INSTANT.format(dataValue.getSourceTime().getJavaInstant())));
8291
}
83-
if (v.getSourcePicoseconds() != null) {
84-
jsonObject.add("sourcePicoseconds", new JsonPrimitive(v.getSourcePicoseconds().intValue()));
92+
if (dataValue.getSourcePicoseconds() != null) {
93+
jsonObject.add("sourcePicoseconds", new JsonPrimitive(dataValue.getSourcePicoseconds().intValue()));
8594
}
86-
if (v.getServerTime() != null) {
95+
if (dataValue.getServerTime() != null) {
8796
jsonObject.add("serverTimestamp",
88-
new JsonPrimitive(DateTimeFormatter.ISO_INSTANT.format(v.getServerTime().getJavaInstant())));
97+
new JsonPrimitive(DateTimeFormatter.ISO_INSTANT.format(dataValue.getServerTime().getJavaInstant())));
8998
}
90-
if (v.getServerPicoseconds() != null) {
91-
jsonObject.add("serverPicoseconds", new JsonPrimitive(v.getServerPicoseconds().intValue()));
99+
if (dataValue.getServerPicoseconds() != null) {
100+
jsonObject.add("serverPicoseconds", new JsonPrimitive(dataValue.getServerPicoseconds().intValue()));
92101
}
93102
}
103+
94104
jsonObject.add("value", convertValue(value, serializationContext));
95105
return ByteBuffer.wrap(GSON.toJson(jsonObject).getBytes(StandardCharsets.UTF_8));
96106
}

modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/southbound/BuiltinJsonSchema.java

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,58 @@ public class BuiltinJsonSchema {
109109
}
110110
}
111111

112+
/**
113+
* Adds readonly metadata properties to the schema for OPC UA DataValue metadata.
114+
* These properties are marked as readOnly and are not required.
115+
*/
116+
static void addReadOnlyMetadataProperties(
117+
final @NotNull ObjectNode propertiesNode,
118+
final @NotNull ObjectMapper objectMapper) {
119+
// sourceTimestamp - DateTime as ISO 8601 string
120+
final ObjectNode sourceTimestamp = objectMapper.createObjectNode();
121+
sourceTimestamp.put(TYPE, STRING_DATA_TYPE);
122+
sourceTimestamp.put("format", DATETIME_DATA_TYPE);
123+
sourceTimestamp.put("readOnly", true);
124+
propertiesNode.set("sourceTimestamp", sourceTimestamp);
125+
126+
// serverTimestamp - DateTime as ISO 8601 string
127+
final ObjectNode serverTimestamp = objectMapper.createObjectNode();
128+
serverTimestamp.put(TYPE, STRING_DATA_TYPE);
129+
serverTimestamp.put("format", DATETIME_DATA_TYPE);
130+
serverTimestamp.put("readOnly", true);
131+
propertiesNode.set("serverTimestamp", serverTimestamp);
132+
133+
// sourcePicoseconds - UShort (0-65535)
134+
final ObjectNode sourcePicoseconds = objectMapper.createObjectNode();
135+
sourcePicoseconds.put(TYPE, Constants.INTEGER_DATA_TYPE);
136+
sourcePicoseconds.put(Constants.MINIMUM_KEY_WORD, 0);
137+
sourcePicoseconds.put(Constants.MAXIMUM_KEY_WORD, 65535);
138+
sourcePicoseconds.put("readOnly", true);
139+
propertiesNode.set("sourcePicoseconds", sourcePicoseconds);
140+
141+
// serverPicoseconds - UShort (0-65535)
142+
final ObjectNode serverPicoseconds = objectMapper.createObjectNode();
143+
serverPicoseconds.put(TYPE, Constants.INTEGER_DATA_TYPE);
144+
serverPicoseconds.put(Constants.MINIMUM_KEY_WORD, 0);
145+
serverPicoseconds.put(Constants.MAXIMUM_KEY_WORD, 65535);
146+
serverPicoseconds.put("readOnly", true);
147+
propertiesNode.set("serverPicoseconds", serverPicoseconds);
148+
149+
// statusCode - object with code and symbol
150+
final ObjectNode statusCode = objectMapper.createObjectNode();
151+
statusCode.put(TYPE, OBJECT_DATA_TYPE);
152+
statusCode.put("readOnly", true);
153+
final ObjectNode statusCodeProps = objectMapper.createObjectNode();
154+
final ObjectNode codeNode = objectMapper.createObjectNode();
155+
codeNode.put(TYPE, Constants.INTEGER_DATA_TYPE);
156+
statusCodeProps.set("code", codeNode);
157+
final ObjectNode symbolNode = objectMapper.createObjectNode();
158+
symbolNode.put(TYPE, STRING_DATA_TYPE);
159+
statusCodeProps.set("symbol", symbolNode);
160+
statusCode.set("properties", statusCodeProps);
161+
propertiesNode.set("statusCode", statusCode);
162+
}
163+
112164
static void populatePropertiesForArray(
113165
final @NotNull ObjectNode propertiesNode,
114166
final @NotNull OpcUaDataType builtinDataType,
@@ -263,6 +315,13 @@ static void populatePropertiesForBuiltinType(
263315
static @NotNull JsonNode createJsonSchemaForArrayType(
264316
final @NotNull OpcUaDataType builtinDataType,
265317
final @NotNull UInteger @NotNull [] dimensions) {
318+
return createJsonSchemaForArrayType(builtinDataType, dimensions, false);
319+
}
320+
321+
static @NotNull JsonNode createJsonSchemaForArrayType(
322+
final @NotNull OpcUaDataType builtinDataType,
323+
final @NotNull UInteger @NotNull [] dimensions,
324+
final boolean includeMetadata) {
266325
final ObjectNode rootNode = MAPPER.createObjectNode();
267326
final ObjectNode propertiesNode = MAPPER.createObjectNode();
268327
final ObjectNode valueNode = MAPPER.createObjectNode();
@@ -274,13 +333,43 @@ static void populatePropertiesForBuiltinType(
274333

275334
populatePropertiesForArray(valueNode, builtinDataType, MAPPER, dimensions);
276335

336+
if (includeMetadata) {
337+
addReadOnlyMetadataProperties(propertiesNode, MAPPER);
338+
}
339+
277340
final ArrayNode requiredAttributes = MAPPER.createArrayNode();
278341
requiredAttributes.add("value");
279342
rootNode.set("required", requiredAttributes);
280343
return rootNode;
281344
}
282345

283346
static @Nullable JsonNode createJsonSchemaForBuiltInType(final @NotNull OpcUaDataType builtinDataType) {
284-
return BUILT_IN_TYPES.get(builtinDataType);
347+
return createJsonSchemaForBuiltInType(builtinDataType, false);
348+
}
349+
350+
static @Nullable JsonNode createJsonSchemaForBuiltInType(
351+
final @NotNull OpcUaDataType builtinDataType,
352+
final boolean includeMetadata) {
353+
if (!includeMetadata) {
354+
return BUILT_IN_TYPES.get(builtinDataType);
355+
}
356+
// Generate dynamically with metadata
357+
final String title = builtinDataType.name() + " JsonSchema";
358+
final ObjectNode rootNode = MAPPER.createObjectNode();
359+
final ObjectNode propertiesNode = MAPPER.createObjectNode();
360+
final ObjectNode valueNode = MAPPER.createObjectNode();
361+
rootNode.set("$schema", new TextNode(SCHEMA_URI));
362+
rootNode.set("title", new TextNode(title));
363+
rootNode.set(TYPE, new TextNode(OBJECT_DATA_TYPE));
364+
rootNode.set("properties", propertiesNode);
365+
propertiesNode.set("value", valueNode);
366+
367+
populatePropertiesForBuiltinType(valueNode, builtinDataType, MAPPER);
368+
addReadOnlyMetadataProperties(propertiesNode, MAPPER);
369+
370+
final ArrayNode requiredAttributes = MAPPER.createArrayNode();
371+
requiredAttributes.add("value");
372+
rootNode.set("required", requiredAttributes);
373+
return rootNode;
285374
}
286375
}

modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/southbound/JsonSchemaGenerator.java

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,15 @@ public class JsonSchemaGenerator {
5252

5353
private final @NotNull OpcUaClient client;
5454
private final @NotNull DataTypeTree tree;
55+
private final boolean includeMetadata;
5556

5657
public JsonSchemaGenerator(final @NotNull OpcUaClient client) {
58+
this(client, false);
59+
}
60+
61+
public JsonSchemaGenerator(final @NotNull OpcUaClient client, final boolean includeMetadata) {
5762
this.client = client;
63+
this.includeMetadata = includeMetadata;
5864
try {
5965
this.tree = client.getDataTypeTree();
6066
} catch (final UaException e) {
@@ -64,13 +70,13 @@ public JsonSchemaGenerator(final @NotNull OpcUaClient client) {
6470

6571
public @NotNull CompletableFuture<Optional<JsonNode>> createMqttPayloadJsonSchema(final @NotNull OpcuaTag tag) {
6672
final String nodeId = tag.getDefinition().getNode();
67-
final var jsonSchemaGenerator = new JsonSchemaGenerator(client);
73+
final var jsonSchemaGenerator = new JsonSchemaGenerator(client, includeMetadata);
6874
final var parsed = NodeId.parse(nodeId);
6975
return jsonSchemaGenerator.collectTypeInfo(parsed).thenApply(info -> {
7076
if (info.arrayDimensions() != null && info.arrayDimensions().length > 0) {
71-
return createJsonSchemaForArrayType(info.dataType(), info.arrayDimensions);
77+
return createJsonSchemaForArrayType(info.dataType(), info.arrayDimensions, includeMetadata);
7278
} else if (info.nestedFields() == null || info.nestedFields().isEmpty()) {
73-
return createJsonSchemaForBuiltInType(info.dataType());
79+
return createJsonSchemaForBuiltInType(info.dataType(), includeMetadata);
7480
} else {
7581
return jsonSchemaGenerator.jsonSchemaFromNodeId(info);
7682
}
@@ -251,8 +257,12 @@ private void verifyDataTypeForField(final @NotNull FieldInformation fieldType) {
251257
fieldInformation.customDataType().getNodeId().toParseableString())));
252258
rootNode.set(TYPE, new TextNode(OBJECT_DATA_TYPE));
253259

260+
// Create the root properties node that contains "value" and metadata
261+
final ObjectNode rootPropertiesNode = MAPPER.createObjectNode();
262+
rootNode.set("properties", rootPropertiesNode);
263+
254264
final ObjectNode valueNode = MAPPER.createObjectNode();
255-
rootNode.set("value", valueNode);
265+
rootPropertiesNode.set("value", valueNode);
256266
valueNode.set(TYPE, new TextNode(OBJECT_DATA_TYPE));
257267

258268
final ObjectNode propertiesNode = MAPPER.createObjectNode();
@@ -269,6 +279,11 @@ private void verifyDataTypeForField(final @NotNull FieldInformation fieldType) {
269279

270280
valueNode.set("required", requiredAttributesArray);
271281

282+
// Add metadata properties if enabled
283+
if (includeMetadata) {
284+
BuiltinJsonSchema.addReadOnlyMetadataProperties(rootPropertiesNode, MAPPER);
285+
}
286+
272287
final ArrayNode requiredProperties = MAPPER.createArrayNode();
273288
requiredProperties.add("value");
274289
rootNode.set("required", requiredProperties);

modules/hivemq-edge-module-opcua/src/test/java/com/hivemq/edge/adapters/opcua/OpcUaClientConnectionTest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ void whenSubscriptionIsActive_thenKeepAliveMessagesAreReceived() throws Exceptio
8989
new OpcUaToMqttConfig(1, 1000),
9090
// 1 second publishing interval
9191
null,
92+
null,
9293
null);
9394

9495
// Create a tag that maps to a node in the test server
@@ -174,6 +175,7 @@ void whenMultipleTagsSubscribed_thenKeepAliveMessagesAreReceived() throws Except
174175
new OpcUaToMqttConfig(1, 2000),
175176
// 2 second publishing interval
176177
null,
178+
null,
177179
null);
178180

179181
// Create multiple tags
@@ -254,6 +256,7 @@ void whenNoSubscriptionCreated_thenIsHealthyReturnsFalse() {
254256
null,
255257
new OpcUaToMqttConfig(1, 1000),
256258
null,
259+
null,
257260
null);
258261

259262
final DataPointFactory dataPointFactory = new DataPointFactory() {
@@ -305,6 +308,7 @@ void whenConnectionStopped_thenIsHealthyReturnsFalse() throws Exception {
305308
null,
306309
new OpcUaToMqttConfig(1, 1000),
307310
null,
311+
null,
308312
null);
309313

310314
final OpcuaTag tag = new OpcuaTag("testTag",

0 commit comments

Comments
 (0)