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
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ public void createTagSchema(
return;
}
conn.client()
.ifPresentOrElse(client -> new JsonSchemaGenerator(client).createMqttPayloadJsonSchema(tag)
.ifPresentOrElse(client -> new JsonSchemaGenerator(client, config.isIncludeMetadata()).createMqttPayloadJsonSchema(tag)
.whenComplete((result, throwable) -> {
if (throwable == null) {
result.ifPresentOrElse(schema -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ public BidirectionalOpcUaSpecificAdapterConfig(
@JsonProperty("tls") final @Nullable Tls tls,
@JsonProperty(value = "opcuaToMqtt") final @Nullable OpcUaToMqttConfig opcuaToMqttConfig,
@JsonProperty("security") final @Nullable Security security,
@JsonProperty("connectionOptions") final @Nullable ConnectionOptions connectionOptions) {
super(uri, overrideUri, applicationUri, auth, tls, opcuaToMqttConfig, security, connectionOptions);
@JsonProperty("connectionOptions") final @Nullable ConnectionOptions connectionOptions,
@JsonProperty("includeMetadata") final @Nullable Boolean includeMetadata) {
super(uri, overrideUri, applicationUri, auth, tls, opcuaToMqttConfig, security, connectionOptions, includeMetadata);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ public class OpcUaSpecificAdapterConfig implements ProtocolSpecificAdapterConfig
description = "Controls how heartbeats and reconnects are handled")
private final @NotNull ConnectionOptions connectionOptions;

@JsonProperty("includeMetadata")
@ModuleConfigField(title = "Include Metadata",
description = "Include OPC UA metadata (timestamps, status code) in JSON output and schema",
defaultValue = "false")
private final boolean includeMetadata;

@JsonCreator
public OpcUaSpecificAdapterConfig(
@JsonProperty(value = "uri", required = true) final @NotNull String uri,
Expand All @@ -98,7 +104,8 @@ public OpcUaSpecificAdapterConfig(
@JsonProperty("tls") final @Nullable Tls tls,
@JsonProperty("opcuaToMqtt") final @Nullable OpcUaToMqttConfig opcuaToMqttConfig,
@JsonProperty("security") final @Nullable Security security,
@JsonProperty("connectionOptions") final @Nullable ConnectionOptions connectionOptions) {
@JsonProperty("connectionOptions") final @Nullable ConnectionOptions connectionOptions,
@JsonProperty("includeMetadata") final @Nullable Boolean includeMetadata) {
this.uri = uri;
this.overrideUri = requireNonNullElse(overrideUri, false);
this.applicationUri = (applicationUri != null && !applicationUri.isBlank()) ? applicationUri : "";
Expand All @@ -107,6 +114,7 @@ public OpcUaSpecificAdapterConfig(
this.opcuaToMqttConfig = requireNonNullElseGet(opcuaToMqttConfig, OpcUaToMqttConfig::defaultOpcUaToMqttConfig);
this.security = requireNonNullElse(security, new Security(Constants.DEFAULT_SECURITY_POLICY));
this.connectionOptions = requireNonNullElseGet(connectionOptions, ConnectionOptions::defaultConnectionOptions);
this.includeMetadata = requireNonNullElse(includeMetadata, false);
}


Expand Down Expand Up @@ -142,11 +150,16 @@ public OpcUaSpecificAdapterConfig(
return connectionOptions;
}

public boolean isIncludeMetadata() {
return includeMetadata;
}

@Override
public boolean equals(final Object o) {
if (o == null || getClass() != o.getClass()) return false;
final OpcUaSpecificAdapterConfig that = (OpcUaSpecificAdapterConfig) o;
return getOverrideUri().equals(that.getOverrideUri()) &&
includeMetadata == that.includeMetadata &&
Objects.equals(id, that.id) &&
Objects.equals(getUri(), that.getUri()) &&
Objects.equals(getApplicationUri(), that.getApplicationUri()) &&
Expand All @@ -167,7 +180,8 @@ public int hashCode() {
getTls(),
getSecurity(),
getOpcuaToMqttConfig(),
connectionOptions);
connectionOptions,
includeMetadata);
}

@Override
Expand All @@ -194,6 +208,8 @@ public String toString() {
opcuaToMqttConfig +
", connectionOptions=" +
connectionOptions +
", includeMetadata=" +
includeMetadata +
'}';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -293,13 +293,14 @@ public void onDataReceived(
}
}

private static @NotNull String extractPayload(final @NotNull OpcUaClient client, final @NotNull DataValue value)
private @NotNull String extractPayload(final @NotNull OpcUaClient client, final @NotNull DataValue value)
throws UaException {
if (value.getValue().getValue() == null) {
return "";
}

final ByteBuffer byteBuffer = OpcUaToJsonConverter.convertPayload(client.getDynamicEncodingContext(), value);
final ByteBuffer byteBuffer = OpcUaToJsonConverter.convertPayload(
client.getDynamicEncodingContext(), value, config.isIncludeMetadata());
final byte[] buffer = new byte[byteBuffer.remaining()];
byteBuffer.get(buffer);
return new String(buffer, StandardCharsets.UTF_8);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,30 +67,40 @@ public class OpcUaToJsonConverter {
public static @NotNull ByteBuffer convertPayload(
final @NotNull EncodingContext serializationContext,
final @NotNull DataValue dataValue) {
return convertPayload(serializationContext, dataValue, false);
}

public static @NotNull ByteBuffer convertPayload(
final @NotNull EncodingContext serializationContext,
final @NotNull DataValue dataValue,
final boolean includeMetadata) {
final Object value = dataValue.getValue().getValue();
if (value == null) {
return ByteBuffer.wrap(EMPTY_BYTES);
}
final JsonObject jsonObject = new JsonObject();
if (value instanceof final DataValue v) {
if (v.getStatusCode().getValue() > 0) {
jsonObject.add("statusCode", convertStatusCode(v.getStatusCode()));

// Extract metadata from the outer DataValue when includeMetadata is enabled
if (includeMetadata) {
if (dataValue.getStatusCode() != null && dataValue.getStatusCode().getValue() > 0) {
jsonObject.add("statusCode", convertStatusCode(dataValue.getStatusCode()));
}
if (v.getSourceTime() != null) {
if (dataValue.getSourceTime() != null) {
jsonObject.add("sourceTimestamp",
new JsonPrimitive(DateTimeFormatter.ISO_INSTANT.format(v.getSourceTime().getJavaInstant())));
new JsonPrimitive(DateTimeFormatter.ISO_INSTANT.format(dataValue.getSourceTime().getJavaInstant())));
}
if (v.getSourcePicoseconds() != null) {
jsonObject.add("sourcePicoseconds", new JsonPrimitive(v.getSourcePicoseconds().intValue()));
if (dataValue.getSourcePicoseconds() != null) {
jsonObject.add("sourcePicoseconds", new JsonPrimitive(dataValue.getSourcePicoseconds().intValue()));
}
if (v.getServerTime() != null) {
if (dataValue.getServerTime() != null) {
jsonObject.add("serverTimestamp",
new JsonPrimitive(DateTimeFormatter.ISO_INSTANT.format(v.getServerTime().getJavaInstant())));
new JsonPrimitive(DateTimeFormatter.ISO_INSTANT.format(dataValue.getServerTime().getJavaInstant())));
}
if (v.getServerPicoseconds() != null) {
jsonObject.add("serverPicoseconds", new JsonPrimitive(v.getServerPicoseconds().intValue()));
if (dataValue.getServerPicoseconds() != null) {
jsonObject.add("serverPicoseconds", new JsonPrimitive(dataValue.getServerPicoseconds().intValue()));
}
}

jsonObject.add("value", convertValue(value, serializationContext));
return ByteBuffer.wrap(GSON.toJson(jsonObject).getBytes(StandardCharsets.UTF_8));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,58 @@ public class BuiltinJsonSchema {
}
}

/**
* Adds readonly metadata properties to the schema for OPC UA DataValue metadata.
* These properties are marked as readOnly and are not required.
*/
static void addReadOnlyMetadataProperties(
final @NotNull ObjectNode propertiesNode,
final @NotNull ObjectMapper objectMapper) {
// sourceTimestamp - DateTime as ISO 8601 string
final ObjectNode sourceTimestamp = objectMapper.createObjectNode();
sourceTimestamp.put(TYPE, STRING_DATA_TYPE);
sourceTimestamp.put("format", DATETIME_DATA_TYPE);
sourceTimestamp.put("readOnly", true);
propertiesNode.set("sourceTimestamp", sourceTimestamp);

// serverTimestamp - DateTime as ISO 8601 string
final ObjectNode serverTimestamp = objectMapper.createObjectNode();
serverTimestamp.put(TYPE, STRING_DATA_TYPE);
serverTimestamp.put("format", DATETIME_DATA_TYPE);
serverTimestamp.put("readOnly", true);
propertiesNode.set("serverTimestamp", serverTimestamp);

// sourcePicoseconds - UShort (0-65535)
final ObjectNode sourcePicoseconds = objectMapper.createObjectNode();
sourcePicoseconds.put(TYPE, Constants.INTEGER_DATA_TYPE);
sourcePicoseconds.put(Constants.MINIMUM_KEY_WORD, 0);
sourcePicoseconds.put(Constants.MAXIMUM_KEY_WORD, 65535);
sourcePicoseconds.put("readOnly", true);
propertiesNode.set("sourcePicoseconds", sourcePicoseconds);

// serverPicoseconds - UShort (0-65535)
final ObjectNode serverPicoseconds = objectMapper.createObjectNode();
serverPicoseconds.put(TYPE, Constants.INTEGER_DATA_TYPE);
serverPicoseconds.put(Constants.MINIMUM_KEY_WORD, 0);
serverPicoseconds.put(Constants.MAXIMUM_KEY_WORD, 65535);
serverPicoseconds.put("readOnly", true);
propertiesNode.set("serverPicoseconds", serverPicoseconds);

// statusCode - object with code and symbol
final ObjectNode statusCode = objectMapper.createObjectNode();
statusCode.put(TYPE, OBJECT_DATA_TYPE);
statusCode.put("readOnly", true);
final ObjectNode statusCodeProps = objectMapper.createObjectNode();
final ObjectNode codeNode = objectMapper.createObjectNode();
codeNode.put(TYPE, Constants.INTEGER_DATA_TYPE);
statusCodeProps.set("code", codeNode);
final ObjectNode symbolNode = objectMapper.createObjectNode();
symbolNode.put(TYPE, STRING_DATA_TYPE);
statusCodeProps.set("symbol", symbolNode);
statusCode.set("properties", statusCodeProps);
propertiesNode.set("statusCode", statusCode);
}

static void populatePropertiesForArray(
final @NotNull ObjectNode propertiesNode,
final @NotNull OpcUaDataType builtinDataType,
Expand Down Expand Up @@ -263,6 +315,13 @@ static void populatePropertiesForBuiltinType(
static @NotNull JsonNode createJsonSchemaForArrayType(
final @NotNull OpcUaDataType builtinDataType,
final @NotNull UInteger @NotNull [] dimensions) {
return createJsonSchemaForArrayType(builtinDataType, dimensions, false);
}

static @NotNull JsonNode createJsonSchemaForArrayType(
final @NotNull OpcUaDataType builtinDataType,
final @NotNull UInteger @NotNull [] dimensions,
final boolean includeMetadata) {
final ObjectNode rootNode = MAPPER.createObjectNode();
final ObjectNode propertiesNode = MAPPER.createObjectNode();
final ObjectNode valueNode = MAPPER.createObjectNode();
Expand All @@ -274,13 +333,43 @@ static void populatePropertiesForBuiltinType(

populatePropertiesForArray(valueNode, builtinDataType, MAPPER, dimensions);

if (includeMetadata) {
addReadOnlyMetadataProperties(propertiesNode, MAPPER);
}

final ArrayNode requiredAttributes = MAPPER.createArrayNode();
requiredAttributes.add("value");
rootNode.set("required", requiredAttributes);
return rootNode;
}

static @Nullable JsonNode createJsonSchemaForBuiltInType(final @NotNull OpcUaDataType builtinDataType) {
return BUILT_IN_TYPES.get(builtinDataType);
return createJsonSchemaForBuiltInType(builtinDataType, false);
}

static @Nullable JsonNode createJsonSchemaForBuiltInType(
final @NotNull OpcUaDataType builtinDataType,
final boolean includeMetadata) {
if (!includeMetadata) {
return BUILT_IN_TYPES.get(builtinDataType);
}
// Generate dynamically with metadata
final String title = builtinDataType.name() + " JsonSchema";
final ObjectNode rootNode = MAPPER.createObjectNode();
final ObjectNode propertiesNode = MAPPER.createObjectNode();
final ObjectNode valueNode = MAPPER.createObjectNode();
rootNode.set("$schema", new TextNode(SCHEMA_URI));
rootNode.set("title", new TextNode(title));
rootNode.set(TYPE, new TextNode(OBJECT_DATA_TYPE));
rootNode.set("properties", propertiesNode);
propertiesNode.set("value", valueNode);

populatePropertiesForBuiltinType(valueNode, builtinDataType, MAPPER);
addReadOnlyMetadataProperties(propertiesNode, MAPPER);

final ArrayNode requiredAttributes = MAPPER.createArrayNode();
requiredAttributes.add("value");
rootNode.set("required", requiredAttributes);
return rootNode;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,15 @@ public class JsonSchemaGenerator {

private final @NotNull OpcUaClient client;
private final @NotNull DataTypeTree tree;
private final boolean includeMetadata;

public JsonSchemaGenerator(final @NotNull OpcUaClient client) {
this(client, false);
}

public JsonSchemaGenerator(final @NotNull OpcUaClient client, final boolean includeMetadata) {
this.client = client;
this.includeMetadata = includeMetadata;
try {
this.tree = client.getDataTypeTree();
} catch (final UaException e) {
Expand All @@ -64,13 +70,13 @@ public JsonSchemaGenerator(final @NotNull OpcUaClient client) {

public @NotNull CompletableFuture<Optional<JsonNode>> createMqttPayloadJsonSchema(final @NotNull OpcuaTag tag) {
final String nodeId = tag.getDefinition().getNode();
final var jsonSchemaGenerator = new JsonSchemaGenerator(client);
final var jsonSchemaGenerator = new JsonSchemaGenerator(client, includeMetadata);
final var parsed = NodeId.parse(nodeId);
return jsonSchemaGenerator.collectTypeInfo(parsed).thenApply(info -> {
if (info.arrayDimensions() != null && info.arrayDimensions().length > 0) {
return createJsonSchemaForArrayType(info.dataType(), info.arrayDimensions);
return createJsonSchemaForArrayType(info.dataType(), info.arrayDimensions, includeMetadata);
} else if (info.nestedFields() == null || info.nestedFields().isEmpty()) {
return createJsonSchemaForBuiltInType(info.dataType());
return createJsonSchemaForBuiltInType(info.dataType(), includeMetadata);
} else {
return jsonSchemaGenerator.jsonSchemaFromNodeId(info);
}
Expand Down Expand Up @@ -251,8 +257,12 @@ private void verifyDataTypeForField(final @NotNull FieldInformation fieldType) {
fieldInformation.customDataType().getNodeId().toParseableString())));
rootNode.set(TYPE, new TextNode(OBJECT_DATA_TYPE));

// Create the root properties node that contains "value" and metadata
final ObjectNode rootPropertiesNode = MAPPER.createObjectNode();
rootNode.set("properties", rootPropertiesNode);

final ObjectNode valueNode = MAPPER.createObjectNode();
rootNode.set("value", valueNode);
rootPropertiesNode.set("value", valueNode);
valueNode.set(TYPE, new TextNode(OBJECT_DATA_TYPE));

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

valueNode.set("required", requiredAttributesArray);

// Add metadata properties if enabled
if (includeMetadata) {
BuiltinJsonSchema.addReadOnlyMetadataProperties(rootPropertiesNode, MAPPER);
}

final ArrayNode requiredProperties = MAPPER.createArrayNode();
requiredProperties.add("value");
rootNode.set("required", requiredProperties);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ void whenSubscriptionIsActive_thenKeepAliveMessagesAreReceived() throws Exceptio
new OpcUaToMqttConfig(1, 1000),
// 1 second publishing interval
null,
null,
null);

// Create a tag that maps to a node in the test server
Expand Down Expand Up @@ -174,6 +175,7 @@ void whenMultipleTagsSubscribed_thenKeepAliveMessagesAreReceived() throws Except
new OpcUaToMqttConfig(1, 2000),
// 2 second publishing interval
null,
null,
null);

// Create multiple tags
Expand Down Expand Up @@ -254,6 +256,7 @@ void whenNoSubscriptionCreated_thenIsHealthyReturnsFalse() {
null,
new OpcUaToMqttConfig(1, 1000),
null,
null,
null);

final DataPointFactory dataPointFactory = new DataPointFactory() {
Expand Down Expand Up @@ -305,6 +308,7 @@ void whenConnectionStopped_thenIsHealthyReturnsFalse() throws Exception {
null,
new OpcUaToMqttConfig(1, 1000),
null,
null,
null);

final OpcuaTag tag = new OpcuaTag("testTag",
Expand Down
Loading
Loading