From ba787d3bcb0776705ceb73b0095c6ff566d0849d Mon Sep 17 00:00:00 2001 From: Dominic Betts <1454644+dominicbetts@users.noreply.github.com> Date: Wed, 27 Aug 2025 11:30:11 +0100 Subject: [PATCH 1/4] Revert change --- samples/quickstarts/quickstart.bicep | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/quickstarts/quickstart.bicep b/samples/quickstarts/quickstart.bicep index 75c0dbe8..d841ee36 100644 --- a/samples/quickstarts/quickstart.bicep +++ b/samples/quickstarts/quickstart.bicep @@ -70,7 +70,7 @@ resource device 'Microsoft.DeviceRegistry/namespaces/devices@2025-07-01-preview' assigned: {} } inbound: { - [opcUaEndpointName]: { + opcUaEndpointName: { endpointType: 'Microsoft.OpcUa' address: 'opc.tcp://opcplc-000000:50000' authentication: { From 497614478a0da77f25fb6e6d4afa78c19b3553a2 Mon Sep 17 00:00:00 2001 From: Dominic Betts <1454644+dominicbetts@users.noreply.github.com> Date: Mon, 29 Sep 2025 10:59:13 +0100 Subject: [PATCH 2/4] Add process control sample --- samples/process-control/README.md | 181 +++++++++++++ .../process-control/boiler-simulation.bicep | 246 ++++++++++++++++++ .../opcua-datasetwrite.v2.dtdl.json | 30 +++ .../process-control-demo.slnx | 6 + .../mrpc.client.generated.v3/CustomPayload.cs | 17 ++ .../mrpc.client.generated.v3/DecimalString.cs | 94 +++++++ .../ExternalSerializer.cs | 47 ++++ .../mrpc.client.generated.v3/Write/Write.g.cs | 100 +++++++ .../Write/WriteDatasetCommandInvoker.g.cs | 38 +++ .../mrpc.client.generated.v3.csproj | 14 + .../src/write.dataset.client/Program.cs | 200 ++++++++++++++ .../WriteDatasetClient.cs | 50 ++++ .../src/write.dataset.client/appsettings.json | 19 ++ .../write.dataset.client.csproj | 33 +++ 14 files changed, 1075 insertions(+) create mode 100644 samples/process-control/README.md create mode 100644 samples/process-control/boiler-simulation.bicep create mode 100644 samples/process-control/process-control-demo/opcua-datasetwrite.v2.dtdl.json create mode 100644 samples/process-control/process-control-demo/process-control-demo.slnx create mode 100644 samples/process-control/process-control-demo/src/mrpc.client.generated.v3/CustomPayload.cs create mode 100644 samples/process-control/process-control-demo/src/mrpc.client.generated.v3/DecimalString.cs create mode 100644 samples/process-control/process-control-demo/src/mrpc.client.generated.v3/ExternalSerializer.cs create mode 100644 samples/process-control/process-control-demo/src/mrpc.client.generated.v3/Write/Write.g.cs create mode 100644 samples/process-control/process-control-demo/src/mrpc.client.generated.v3/Write/WriteDatasetCommandInvoker.g.cs create mode 100644 samples/process-control/process-control-demo/src/mrpc.client.generated.v3/mrpc.client.generated.v3.csproj create mode 100644 samples/process-control/process-control-demo/src/write.dataset.client/Program.cs create mode 100644 samples/process-control/process-control-demo/src/write.dataset.client/WriteDatasetClient.cs create mode 100644 samples/process-control/process-control-demo/src/write.dataset.client/appsettings.json create mode 100644 samples/process-control/process-control-demo/src/write.dataset.client/write.dataset.client.csproj diff --git a/samples/process-control/README.md b/samples/process-control/README.md new file mode 100644 index 00000000..9dfd7a7d --- /dev/null +++ b/samples/process-control/README.md @@ -0,0 +1,181 @@ +# OPC UA process control + + + +In Azure IoT Operations `aio-opc-ua-commander` lets you send changes to an OPC UA server from the edge or from the cloud. The current preview includes support for writing data points from an asset dataset with simple and complex data-types as well as dumping the address space of an OPC UA server. + +The OPC-UA commander: + +- Uses the [RPC](https://github.com/Azure/iot-operations-sdks/blob/main/doc/reference/rpc-protocol.md) and MQTT protocol/broker as the underlying messaging plane. + MQTT messages include some system and user properties to define [metadata](https://github.com/Azure/iot-operations-sdks/blob/main/doc/reference/message-metadata.md) values that help with flow control. +- Subscribes to MQTT topic `{AioNamespace}/asset-operations/{AssetId}/{DatasetName}/` for data-set write operations. +- Subscribes to MQTT topic `{AioNamespace}/asset-operations/{AssetId}/{ManagementGroupName}/` for call operations and explicit write. +- Subscribes to MQTT topic `{AioNamespace}/endpoint-operations/{InboundEndpointProfileName}/{ActionName}/` for endpoint operations. +- On MQTT request/response create ad-hoc session based on the device associated with the namespace asset. +- Validates write requests against the generated request schema. +- Validates that the write request only contains data points that exist within the dataset. +- Use write service calls to set all data-points at once. +- Sends response to response topic property defined in MQTT message. + +To learn more about how `aio-opc-ua-commander` works, see [How to control OPC UA assets](https://learn.microsoft.com/azure/iot-operations/discover-manage-assets/howto-control-opc-ua). + +This sample illustrates some of these capabilities using the OPC PLC simulator boiler. + +## Prerequisites + +To run the sample application, you need: + +- A preview instance of Azure IoT Operations deployed. If you don't already have an instance, see [Create an Azure IoT Operations instance](https://learn.microsoft.com/azure/iot-operations/get-started-end-to-end-sample/quickstart-deploy). +- Access to the internal MQTT broker in the Azure IoT Operations cluster. To configure access the broker, see [Test connectivity to MQTT broker with MQTT clients](https://learn.microsoft.com/azure/iot-operations/manage-mqtt-broker/howto-test-connection). +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) version 2.67.0 or higher. + +## Deploy the simulator + +The sample application uses the boiler in the OPC PLC simulator. + +To deploy the OPC PLC simulator, run the following command: + +```bash +kubectl apply -f https://raw.githubusercontent.com/Azure-Samples/explore-iot-operations/main/samples/quickstarts/opc-plc-deployment.yaml +``` + +> [!CAUTION] +> This configuration uses a self-signed application instance certificate. Don't use this configuration in a production environment. To learn more, see [Configure OPC UA certificates infrastructure for the connector for OPC UA](https://learn.microsoft.com/azure/iot-operations/discover-manage-assets/howto-configure-opc-ua-certificates-infrastructure). + + +## Configure the device and namespace assets + +To add the required device and namespace asset to your instance, run the following commands: + +```bash +wget https://raw.githubusercontent.com/Azure-Samples/explore-iot-operations/main/samples/process-control/boiler-simulation.bicep -O boiler-simulation.bicep + +AIO_NAMESPACE_NAME= +RESOURCE_GROUP= +SUBSCRIPTION_ID=$(az account show --query id -o tsv) +CUSTOM_LOCATION_NAME=$(az iot ops list -g $RESOURCE_GROUP --query "[0].extendedLocation.name" -o tsv | awk -F'/' '{print $NF}') + +az deployment group create --subscription $SUBSCRIPTION_ID --resource-group $RESOURCE_GROUP --template-file boiler-simulation.bicep --parameters customLocationName=$CUSTOM_LOCATION_NAME aioNamespaceName=$AIO_NAMESPACE_NAME +``` + +### Usage + +For example, to set the `TargetTemperature` and other values on the boiler asset, the sample application publishes the following MQTT message to the topic `azure-iot-operations/asset-operations//`: + +```json +{ + "BaseTemperature": 42, + "MaintenanceInterval": 360, + "OverheatInterval": 45, + "OverheatedThresholdTemperature": 199, + "TargetTemperature": 176, + "TemperatureChangeSpeed": 6 +} +``` + +The OPC UA commander service in the cluster subscribes to this topic, receives the message, and writes the values to the OPC UA server. The commander service then publishes the result of the write operation to the topic `responseTopic`. If the operation succeeds, the message in the response topic looks like `{}`. + +The sample in the [explore-iot-operations/samples/process-control](https://github.com/Azure-Samples/explore-iot-operations/tree/main/samples/process-control) folder shows how you can write values to the boiler in the OPC PLC simulator. + +#### How data in a dataset are Written to the asset + +Once the asset is installed, you can use the OPC-UA Commander to write data to the OPC-UA asset. +The OPC-UA Commander uses [RPC Protocol](https://github.com/Azure/iot-operations-sdks/blob/main/doc/reference/rpc-protocol.md). +The [Message Metadata](https://github.com/Azure/iot-operations-sdks/blob/main/doc/reference/message-metadata.md) specify system and user properties that should be included in the MQTT message. + +The OPC-UA Commander Subscribe to MQTT topic `{AioNamespace}/asset-operations/{AssetId}/{DatasetName}/`. + +On MQTT request/response create ad-hoc session based on AssetEndpointProfile (AEP) of asset. It validate that the Write request only contain data-points that exist within the data-set and then set the data. + +A sample for simple datatype request payload is: + +```json +{ + "TargetTemperature": 95 +} +``` + +A sample for complex datatype request payload is: +```json +{ + "BoilerStatus": { + "Temperature": { + "Top": 123, + "Bottom": 456 + }, + "Pressure": 789, + "HeaterState": "Off_0" + } +} +``` + +## Asset Endpoint Operations + +Endpoint operations are process control calls that work on the AssetEndpointProfile only and don't need an Asset. + +### Browse + +To dump the address space of an OPC UA server if is possible to send an mRPC message to the topic `{AioNamespace}/endpoint-operations/{EndpointName}/browse`. +Either using an empty JSON object (mean browse from `root` node with infinite depth) or with an JSON object like: + +`Request:` +```json +{ + "root_data_point": "", + "depth": 128 +} +``` + +* [Optional] `root_data_point` defines the starting point of the browse operation. +* [Optional] `depth` defines the max level of nested structure that should be browsed. + +The response is currently and array of array of nodes. An node contains common attributes, references and _NodeClass_ specific attributes. +Once the AIO SDK supports streaming, the outer array is removed and separate streaming response will be send via MQTT to the response topic. + +`Response:` +```json +[ + [ + { + "id": "nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=15070", + "class": "object", + "displayName": "Boiler #1", + "browseName": "4:Boiler #1", + "description": "A simple boiler.", + "attributes": { + "EventNotifier": "subscribeToEvents" + }, + "rolePermissions": [], + "userRolePermissions": [], + "writeMask": 0, + "userWriteMask": 0, + "accessRestrictions": "none", + "references": [ + { + "referenceTypeId": "i=47", + "name": "4:BoilerStatus", + "targetId": "ns=4;i=15013", + "isForward": true + }, + { + "referenceTypeId": "i=35", + "name": "4:Boilers", + "targetId": "ns=4;i=5", + "isForward": false + }, + { + "referenceTypeId": "i=40", + "name": "4:Boiler1Type", + "targetId": "ns=4;i=3", + "isForward": true + } + ] + } + ] +] +``` + + +Sample application + +Protocol compiler from SDK. \ No newline at end of file diff --git a/samples/process-control/boiler-simulation.bicep b/samples/process-control/boiler-simulation.bicep new file mode 100644 index 00000000..6197c87e --- /dev/null +++ b/samples/process-control/boiler-simulation.bicep @@ -0,0 +1,246 @@ +metadata description = 'This template deploys CRs that map to the boiler in the OPC simulator.' + +/*****************************************************************************/ +/* Deployment Parameters */ +/*****************************************************************************/ + +param customLocationName string +param aioNamespaceName string + +/*****************************************************************************/ +/* Existing AIO instance */ +/*****************************************************************************/ + +resource customLocation 'Microsoft.ExtendedLocation/customLocations@2021-08-31-preview' existing = { + name: customLocationName +} + +resource namespace 'Microsoft.DeviceRegistry/namespaces@2025-07-01-preview' existing = { + name: aioNamespaceName +} + +/*****************************************************************************/ +/* Asset */ +/*****************************************************************************/ + +var assetName = 'boiler' +var opcUaEndpointName = 'opc-ua-commander-0' + +resource device 'Microsoft.DeviceRegistry/namespaces/devices@2025-07-01-preview' = { + name: 'opc-ua-commander' + parent: namespace + location: resourceGroup().location + extendedLocation: { + type: 'CustomLocation' + name: customLocation.id + } + properties: { + endpoints: { + outbound: { + assigned: {} + } + inbound: { + opcUaEndpointName: { + endpointType: 'Microsoft.OpcUa' + address: 'opc.tcp://opcplc-000000:50000' + authentication: { + method: 'Anonymous' + } + } + } + } + } +} + +resource asset 'Microsoft.DeviceRegistry/namespaces/assets@2025-07-01-preview' = { + name: assetName + parent: namespace + location: resourceGroup().location + extendedLocation: { + type: 'CustomLocation' + name: customLocation.id + } + properties: { + displayName: assetName + deviceRef: { + deviceName: device.name + endpointName: opcUaEndpointName + } + description: 'Multi-function boiler simulation.' + + enabled: true + attributes: { + manufacturer: 'Contoso' + manufacturerUri: 'http://www.contoso.com/boilers' + model: 'Oven-003' + productCode: '12345C' + hardwareRevision: '2.3' + softwareRevision: '14.1' + serialNumber: '12345' + documentationUri: 'http://docs.contoso.com/boilers/manual' + } + + datasets: [ + { + name: 'boiler-simple-write' + dataPoints: [ + { + name: 'Boiler #2' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=5017' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'AssetId' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6195' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'DeviceHealth' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6198' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'Manufacturer' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6202' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'ManufacturerUri' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6203' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'BaseTemperature' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6210' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'CurrentTemperature' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6211' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'HeaterState' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6212' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'MaintenanceInterval' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6213' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'OverheatInterval' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6350' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'Overheated' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6214' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'OverheatedThresholdTemperature' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6215' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'TargetTemperature' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6217' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'TemperatureChangeSpeed' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6218' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'ProductCode' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6205' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'ProductInstanceUri' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6206' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'RevisionCounter' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6207' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'SerialNumber' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6208' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + { + name: 'SoftwareRevision' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6209' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + ] + destinations: [ + { + target: 'Mqtt' + configuration: { + topic: 'azure-iot-operations/data/oven-simple-write' + retain: 'Never' + qos: 'Qos1' + } + } + ] + } + { + name: 'boiler-complex-write' + dataPoints: [ + { + name: 'BoilerStatus' + dataSource: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=15013' + dataPointConfiguration: '{"samplingInterval":500,"queueSize":1}' + } + ] + destinations: [ + { + target: 'Mqtt' + configuration: { + topic: 'azure-iot-operations/data/oven-complex-write' + retain: 'Never' + qos: 'Qos1' + } + } + ] + } + + ] + + managementGroups: [ + { + name: 'boiler-call' + actions: [ + { + name: 'Switch' + targetUri: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=5017' + actionType: 'Call' + typeRef: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=5019' + } + ] + } + { + name: 'boiler-explicit-write' + actions: [ + { + name: 'simple-write' + targetUri: 'nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=6217' + actionType: 'Write' + } + ] + } + ] + + defaultDatasetsConfiguration: '{"publishingInterval":1000,"samplingInterval":500,"queueSize":1}' + defaultEventsConfiguration: '{"publishingInterval":1000,"samplingInterval":500,"queueSize":1}' + } +} + + diff --git a/samples/process-control/process-control-demo/opcua-datasetwrite.v2.dtdl.json b/samples/process-control/process-control-demo/opcua-datasetwrite.v2.dtdl.json new file mode 100644 index 00000000..b6417091 --- /dev/null +++ b/samples/process-control/process-control-demo/opcua-datasetwrite.v2.dtdl.json @@ -0,0 +1,30 @@ +{ + "@context": [ + "dtmi:dtdl:context;4", + "dtmi:dtdl:extension:mqtt;2", + "dtmi:dtdl:extension:requirement;1" + ], + "@id": "dtmi:opcua:write;1", + "@type": [ + "Interface", + "Mqtt" + ], + "payloadFormat": "custom/0", + "commandTopic": "{ex:namespace}/asset-operations/{ex:asset}/{ex:dataset}", + "schemas": [ + ], + "contents": [ + { + "@type": "Command", + "name": "WriteDataset", + "request": { + "name": "WriteDatasetRequest", + "schema": "bytes" + }, + "response": { + "name": "WriteDatasetResponse", + "schema": "bytes" + } + } + ] +} \ No newline at end of file diff --git a/samples/process-control/process-control-demo/process-control-demo.slnx b/samples/process-control/process-control-demo/process-control-demo.slnx new file mode 100644 index 00000000..6c6fed0c --- /dev/null +++ b/samples/process-control/process-control-demo/process-control-demo.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/CustomPayload.cs b/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/CustomPayload.cs new file mode 100644 index 00000000..6da1d095 --- /dev/null +++ b/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/CustomPayload.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Buffers; +using Azure.Iot.Operations.Protocol; +using Azure.Iot.Operations.Protocol.Models; + +namespace mrpc.client.generated.v3 +{ + public class CustomPayload : SerializedPayloadContext + { + public CustomPayload(ReadOnlySequence serializedPayload, string? contentType = "", MqttPayloadFormatIndicator payloadFormatIndicator = MqttPayloadFormatIndicator.Unspecified) + : base(serializedPayload, contentType, payloadFormatIndicator) + { + } + } +} diff --git a/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/DecimalString.cs b/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/DecimalString.cs new file mode 100644 index 00000000..17ccf3d1 --- /dev/null +++ b/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/DecimalString.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/* Code generated by Azure.Iot.Operations.ProtocolCompiler v0.10.0.0; DO NOT EDIT. */ + +namespace mrpc.client.generated.v3 +{ + using System; + using System.Globalization; + using System.Text.RegularExpressions; + + public class DecimalString + { + private static readonly Regex validationRegex = new Regex("^(?:\\+|-)?(?:[1-9][0-9]*|0)(?:\\.[0-9]*)?$", RegexOptions.Compiled); + + private readonly string value; + + public static bool TryParse(string value, out DecimalString? decimalString) + { + if (validationRegex.IsMatch(value)) + { + decimalString = new DecimalString(value, skipValidation: true); + return true; + } + else + { + decimalString = null; + return false; + } + } + + public DecimalString() + : this("0", skipValidation: false) + { + } + + public DecimalString(string value) + : this(value, skipValidation: false) + { + } + + public static implicit operator string(DecimalString decimalString) => decimalString.value; + public static explicit operator DecimalString(string stringVal) => new DecimalString(stringVal); + + public static implicit operator double(DecimalString decimalString) => double.TryParse(decimalString.value, out double doubleVal) ? doubleVal : double.NaN; + public static explicit operator DecimalString(double doubleVal) => new DecimalString(doubleVal.ToString("F", CultureInfo.InvariantCulture)); + + public static bool operator !=(DecimalString? x, DecimalString? y) + { + if (ReferenceEquals(null, x)) + { + return !ReferenceEquals(null, y); + } + + return !x.Equals(y); + } + + public static bool operator ==(DecimalString? x, DecimalString? y) + { + if (ReferenceEquals(null, x)) + { + return ReferenceEquals(null, y); + } + + return x.Equals(y); + } + + public virtual bool Equals(DecimalString? other) + { + return other?.value == this?.value; + } + + public override bool Equals(object? obj) + { + return obj is DecimalString other && Equals(other); + } + public override int GetHashCode() + { + return value.GetHashCode(); + } + + private DecimalString(string value, bool skipValidation) + { + if (!skipValidation && !validationRegex.IsMatch(value)) + { + throw new ArgumentException($"string {value} is not a valid decimal value"); + } + + this.value = value; + } + + public override string ToString() => value; + } +} diff --git a/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/ExternalSerializer.cs b/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/ExternalSerializer.cs new file mode 100644 index 00000000..80840f56 --- /dev/null +++ b/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/ExternalSerializer.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/* Code generated by Azure.Iot.Operations.ProtocolCompiler v0.10.0.0; DO NOT EDIT. */ + +namespace mrpc.client.generated.v3 +{ + using System; + using System.Buffers; + using Azure.Iot.Operations.Protocol; + using Azure.Iot.Operations.Protocol.Models; + + public class ExternalSerializer : IPayloadSerializer + { + public static readonly CustomPayload EmptyValue = new(ReadOnlySequence.Empty); + + public T FromBytes(ReadOnlySequence payload, string? contentType, MqttPayloadFormatIndicator payloadFormatIndicator) + where T : class + { + if (payload.IsEmpty) + { + return (Array.Empty() as T)!; + } + else if (typeof(T) == typeof(CustomPayload)) + { + return (new CustomPayload(payload, contentType, payloadFormatIndicator) as T)!; + } + else + { + return default!; + } + } + + public SerializedPayloadContext ToBytes(T? payload) + where T : class + { + if (payload is CustomPayload payload1) + { + return payload1; + } + else + { + return EmptyValue; + } + } + } +} diff --git a/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/Write/Write.g.cs b/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/Write/Write.g.cs new file mode 100644 index 00000000..a8bd389d --- /dev/null +++ b/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/Write/Write.g.cs @@ -0,0 +1,100 @@ +/* Code generated by Azure.Iot.Operations.ProtocolCompiler v0.10.0.0; DO NOT EDIT. */ + +#nullable enable + +namespace mrpc.client.generated.v3.Write +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using Azure.Iot.Operations.Protocol.Models; + using Azure.Iot.Operations.Protocol; + using Azure.Iot.Operations.Protocol.RPC; + using Azure.Iot.Operations.Protocol.Telemetry; + using mrpc.client.generated.v3; + + [CommandTopic("{ex:namespace}/asset-operations/{ex:asset}/{ex:dataset}")] + [System.CodeDom.Compiler.GeneratedCode("Azure.Iot.Operations.ProtocolCompiler", "0.10.0.0")] + public static partial class Write + { + public abstract partial class Client : IAsyncDisposable + { + private ApplicationContext applicationContext; + private IMqttPubSubClient mqttClient; + private readonly WriteDatasetCommandInvoker writeDatasetCommandInvoker; + + /// + /// Construct a new instance of this client. + /// + /// The shared context for your application. + /// The MQTT client to use. + /// + /// The topic token replacement map to use for all operations by default. Generally, this will include the token values + /// for topic tokens such as "modelId" which should be the same for the duration of this client's lifetime. Note that + /// additional topic tokens can be specified when starting the client with . + /// + public Client(ApplicationContext applicationContext, IMqttPubSubClient mqttClient, Dictionary? topicTokenMap = null) + { + this.applicationContext = applicationContext; + this.mqttClient = mqttClient; + + this.writeDatasetCommandInvoker = new WriteDatasetCommandInvoker(applicationContext, mqttClient); + if (topicTokenMap != null) + { + foreach (string topicTokenKey in topicTokenMap.Keys) + { + this.writeDatasetCommandInvoker.TopicTokenMap.TryAdd("ex:" + topicTokenKey, topicTokenMap[topicTokenKey]); + } + } + } + + public WriteDatasetCommandInvoker WriteDatasetCommandInvoker { get => this.writeDatasetCommandInvoker; } + + + /// + /// Invoke a command. + /// + /// The metadata for this command request. + /// + /// The topic token replacement map to use in addition to the topic tokens specified in the constructor. If this map + /// contains any keys that the topic tokens specified in the constructor also has, then values specified in this map will take precedence. + /// + /// How long the command will be available on the broker for an executor to receive. + /// Cancellation token. + /// The command response. + public RpcCallAsync WriteDatasetAsync(CustomPayload request, CommandRequestMetadata? requestMetadata = null, Dictionary? additionalTopicTokenMap = null, TimeSpan? commandTimeout = default, CancellationToken cancellationToken = default) + { + string? clientId = this.mqttClient.ClientId; + if (string.IsNullOrEmpty(clientId)) + { + throw new InvalidOperationException("No MQTT client Id configured. Must connect to MQTT broker before invoking command."); + } + + CommandRequestMetadata metadata = requestMetadata ?? new CommandRequestMetadata(); + additionalTopicTokenMap ??= new(); + + Dictionary prefixedAdditionalTopicTokenMap = new(); + foreach (string key in additionalTopicTokenMap.Keys) + { + prefixedAdditionalTopicTokenMap["ex:" + key] = additionalTopicTokenMap[key]; + } + + prefixedAdditionalTopicTokenMap["invokerClientId"] = clientId; + + return new RpcCallAsync(this.writeDatasetCommandInvoker.InvokeCommandAsync(request, metadata, prefixedAdditionalTopicTokenMap, commandTimeout, cancellationToken), metadata.CorrelationId); + } + + public async ValueTask DisposeAsync() + { + await this.writeDatasetCommandInvoker.DisposeAsync().ConfigureAwait(false); + } + + public async ValueTask DisposeAsync(bool disposing) + { + await this.writeDatasetCommandInvoker.DisposeAsync(disposing).ConfigureAwait(false); + } + } + } +} diff --git a/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/Write/WriteDatasetCommandInvoker.g.cs b/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/Write/WriteDatasetCommandInvoker.g.cs new file mode 100644 index 00000000..a1ee2140 --- /dev/null +++ b/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/Write/WriteDatasetCommandInvoker.g.cs @@ -0,0 +1,38 @@ +/* Code generated by Azure.Iot.Operations.ProtocolCompiler v0.10.0.0; DO NOT EDIT. */ + +#nullable enable + +namespace mrpc.client.generated.v3.Write +{ + using System; + using System.Collections.Generic; + using Azure.Iot.Operations.Protocol; + using Azure.Iot.Operations.Protocol.RPC; + using Azure.Iot.Operations.Protocol.Models; + using mrpc.client.generated.v3; + + public static partial class Write + { + /// + /// Specializes the CommandInvoker class for Command 'WriteDataset'. + /// + public class WriteDatasetCommandInvoker : CommandInvoker + { + /// + /// Initializes a new instance of the class. + /// + public WriteDatasetCommandInvoker(ApplicationContext applicationContext, IMqttPubSubClient mqttClient) + : base(applicationContext, mqttClient, "WriteDataset", new ExternalSerializer()) + { + this.ResponseTopicPrefix = "clients/{invokerClientId}"; // default value, can be overwritten by user code + + TopicTokenMap["modelId"] = "dtmi:opcua:write;1"; + if (mqttClient.ClientId != null) + { + TopicTokenMap["invokerClientId"] = mqttClient.ClientId; + } + TopicTokenMap["commandName"] = "WriteDataset"; + } + } + } +} diff --git a/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/mrpc.client.generated.v3.csproj b/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/mrpc.client.generated.v3.csproj new file mode 100644 index 00000000..cddf54ac --- /dev/null +++ b/samples/process-control/process-control-demo/src/mrpc.client.generated.v3/mrpc.client.generated.v3.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + latest-recommended + enable + CS1591;SA1600;SA1300;SA1210;SA1636;SA1620;SA1201;SA1516;SA1202;CA1014;CS1711;SA1408;SA1311;SA1513;SA1413;SYSLIB1045;SA1200;CS1574;CS1573 + + + + + + + diff --git a/samples/process-control/process-control-demo/src/write.dataset.client/Program.cs b/samples/process-control/process-control-demo/src/write.dataset.client/Program.cs new file mode 100644 index 00000000..a8776013 --- /dev/null +++ b/samples/process-control/process-control-demo/src/write.dataset.client/Program.cs @@ -0,0 +1,200 @@ +namespace Aio.Connectors.OpcUa.Demo; + +using Azure.Iot.Operations.Mqtt.Session; +using Azure.Iot.Operations.Protocol; +using Azure.Iot.Operations.Protocol.Connection; +using Azure.Iot.Operations.Protocol.Models; +using Azure.Iot.Operations.Protocol.RPC; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using mrpc.client.generated.v3; +using System; +using System.Buffers; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; +using Terminal.Gui; + +/// +/// Entry point. +/// +public class Program +{ + /// + /// Main program. + /// + /// A representing the result of the asynchronous operation. + public static async Task Main(string[] args) + { + // setup logging + using var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + var logger = loggerFactory.CreateLogger("ProcessControlDemo"); + + // load configuration + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .AddCommandLine(args) + .Build(); + + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, _) => { + logger.LogInformation("SIGINT detected - closing application"); + cts.Cancel(); + }; + + var sessionClientOptions = new MqttSessionClientOptions + { + EnableMqttLogging = true, + }; + + var connectionString = configuration.GetConnectionString("Default"); + if (string.IsNullOrWhiteSpace(connectionString)) + { + logger.LogError("Please provide connection string via appsettings.json, environment variable or command line argument"); + Environment.FailFast(null); + } + + var mqttConnectionSettings = MqttConnectionSettings.FromConnectionString(connectionString); + + // Read AIO settings from configuration + var aioNamespace = configuration["AioSettings:Namespace"]; + var assetName = configuration["AioSettings:AssetName"]; + var datasetName = configuration["AioSettings:DatasetName"]; + + if (string.IsNullOrWhiteSpace(aioNamespace) || string.IsNullOrWhiteSpace(assetName) || string.IsNullOrWhiteSpace(datasetName)) + { + logger.LogError("Please provide AioSettings (Namespace, AssetName, DatasetName) via appsettings.json, environment variables or command line arguments"); + Environment.FailFast(null); + } + + MqttSessionClient? mqttClient = default; + ApplicationContext? applicationContext = default; + WriteDatasetClient? mrpcWriteDatasetClient = default; + try + { + mqttClient = new MqttSessionClient(new MqttSessionClientOptions { EnableMqttLogging = true }); + var connectionResult = await mqttClient.ConnectAsync(mqttConnectionSettings, cts.Token).ConfigureAwait(true); + if (connectionResult.ResultCode != MqttClientConnectResultCode.Success) + { + logger.LogError($"Error while connecting to MQTT Broker {connectionResult.ReasonString} {connectionResult.ResultCode}"); + Environment.FailFast(null); + } + + logger.LogInformation("Successful connected to MQTT Broker"); + + applicationContext = new ApplicationContext(); + mrpcWriteDatasetClient = new WriteDatasetClient( + applicationContext, + mqttClient, + aioNamespace, + assetName, + datasetName); + + Application.Init(); + var top = Application.Top; + var win = new Window("Process Control Demo") + { + X = 0, + Y = 1, + Width = Dim.Fill(), + Height = Dim.Fill() - 1, + }; + top.Add(win); + + var baseTempLabel = new Label("Base Temperature:") { X = 3, Y = 2 }; + var baseTempText = new TextField("42") { X = 40, Y = 2, Width = 40 }; + baseTempText.Enter += (_) => baseTempText.CursorPosition = baseTempText.Text.Length; + var targetTempLabel = new Label("Target Temperature:") { X = 3, Y = 4 }; + var targetTempText = new TextField("176") { X = 40, Y = 4, Width = 40 }; + targetTempText.Enter += (_) => targetTempText.CursorPosition = targetTempText.Text.Length; + var tempChangeSpeedLabel = new Label("Temperature Change Speed:") { X = 3, Y = 6 }; + var tempChangeSpeedText = new TextField("6") { X = 40, Y = 6, Width = 40 }; + tempChangeSpeedText.Enter += (_) => tempChangeSpeedText.CursorPosition = tempChangeSpeedText.Text.Length; + var overheatedThresholdTempLabel = new Label("Overheated Threshold Temperature:") { X = 3, Y = 8 }; + var overheatedThresholdTempText = new TextField("199") { X = 40, Y = 8, Width = 40 }; + overheatedThresholdTempText.Enter += (_) => overheatedThresholdTempText.CursorPosition = overheatedThresholdTempText.Text.Length; + var maintenanceIntervalLabel = new Label("Maintenance Interval:") { X = 3, Y = 10 }; + var maintenanceIntervalText = new TextField("360") { X = 40, Y = 10, Width = 40 }; + maintenanceIntervalText.Enter += (_) => maintenanceIntervalText.CursorPosition = maintenanceIntervalText.Text.Length; + var overheatIntervalLabel = new Label("Overheat Interval:") { X = 3, Y = 12 }; + var overheatIntervalText = new TextField("45") { X = 40, Y = 12, Width = 40 }; + overheatIntervalText.Enter += (_) => overheatIntervalText.CursorPosition = overheatIntervalText.Text.Length; + + var sendButton = new Button("Send") { X = 3, Y = 14 }; + var closeButton = new Button("Close") { X = 25, Y = 14 }; + + sendButton.Clicked += async () => + { + var request = $@" + {{ + ""BaseTemperature"" : {baseTempText.Text}, + ""TargetTemperature"" : {targetTempText.Text}, + ""TemperatureChangeSpeed"" : {tempChangeSpeedText.Text}, + ""OverheatedThresholdTemperature"" : {overheatedThresholdTempText.Text}, + ""MaintenanceInterval"" : {maintenanceIntervalText.Text}, + ""OverheatInterval"": {overheatIntervalText.Text} + }}"; + + ReadOnlySequence buffer = new ReadOnlySequence(Encoding.UTF8.GetBytes(request)); + var reqPayload = new CustomPayload(buffer, "application/json", MqttPayloadFormatIndicator.CharacterData); + + try + { + var commandRequestMetadata = new CommandRequestMetadata(); + commandRequestMetadata.UserData.Add("mqtt-property", "value"); + + mrpcWriteDatasetClient.WriteDatasetCommandInvoker.ResponseTopicPattern = "responseTopic/" + Guid.NewGuid().ToString(); + var rpcCall = await mrpcWriteDatasetClient.WriteDatasetAsync( + request: reqPayload, + requestMetadata: commandRequestMetadata, + commandTimeout: TimeSpan.FromMinutes(1), + cancellationToken: cts.Token) + .WithMetadata(); + MessageBox.Query("Success", "Request sent successfully!", "Ok"); + var responseMetadata = rpcCall.ResponseMetadata; + } + catch (Exception e) + { + MessageBox.ErrorQuery("Error", $"Failed to send request: {e.Message}", "Ok"); + } + }; + + closeButton.Clicked += () => + { + cts.Cancel(); + Application.Shutdown(); + }; + + win.Add( + baseTempLabel, + baseTempText, + targetTempLabel, + targetTempText, + tempChangeSpeedLabel, + tempChangeSpeedText, + overheatedThresholdTempLabel, + overheatedThresholdTempText, + maintenanceIntervalLabel, + maintenanceIntervalText, + overheatIntervalLabel, + overheatIntervalText, + sendButton, + closeButton); + + Application.Run(); + } + catch (Exception e) + { + logger.LogError(e.ToString()); + } + finally + { + await (mrpcWriteDatasetClient?.DisposeAsync() ?? ValueTask.CompletedTask).ConfigureAwait(false); + await (applicationContext?.DisposeAsync() ?? ValueTask.CompletedTask).ConfigureAwait(false); + await (mqttClient?.DisposeAsync() ?? ValueTask.CompletedTask).ConfigureAwait(false); + } + + logger.LogInformation("OPC UA Process Control Demo - Stopped!"); + } +} \ No newline at end of file diff --git a/samples/process-control/process-control-demo/src/write.dataset.client/WriteDatasetClient.cs b/samples/process-control/process-control-demo/src/write.dataset.client/WriteDatasetClient.cs new file mode 100644 index 00000000..52279713 --- /dev/null +++ b/samples/process-control/process-control-demo/src/write.dataset.client/WriteDatasetClient.cs @@ -0,0 +1,50 @@ +namespace Aio.Connectors.OpcUa.Demo; + +using Azure.Iot.Operations.Protocol; +using static mrpc.client.generated.v3.Write.Write; + +/// +/// Client to write datasets. +/// +public class WriteDatasetClient : Client +{ + /// + /// Initializes a new instance of the class. + /// + public WriteDatasetClient( + ApplicationContext applicationContext, + IMqttPubSubClient mqttClient, + string aioNamespace, + string assetName, + string datasetName) + : base( + applicationContext, + mqttClient, + new Dictionary + { + { "namespace", aioNamespace }, + { "asset", assetName }, + { "dataset", datasetName }, + }) + { + // SDK don't listens to dynamic response topic ==> https://github.com/Azure/iot-operations-sdks/issues/638 + WriteDatasetCommandInvoker.ResponseTopicPattern = "responseTopic/" + Guid.NewGuid().ToString(); + + // WriteDatasetCommandInvoker.GetResponseTopic = (string requestTopic) => { + // return "responseTopic/" + Guid.NewGuid().ToString(); + // }; + //// WriteDatasetCommandInvoker.TopicTokenMap["ex:namespace"] = aioNamespace; + //// WriteDatasetCommandInvoker.TopicTokenMap["ex:asset"] = assetName; + //// WriteDatasetCommandInvoker.TopicTokenMap["ex:dataset"] = datasetName; + //// WriteDatasetCommandInvoker.TopicTokenMap["namespace"] = aioNamespace; + //// WriteDatasetCommandInvoker.TopicTokenMap["asset"] = assetName; + //// WriteDatasetCommandInvoker.TopicTokenMap["dataset"] = datasetName; + + //// CustomTopicTokenMap["ex:namespace"] = aioNamespace; + //// CustomTopicTokenMap["ex:asset"] = assetName; + //// CustomTopicTokenMap["ex:dataset"] = datasetName; + //// CustomTopicTokenMap["namespace"] = aioNamespace; + //// CustomTopicTokenMap["asset"] = assetName; + //// CustomTopicTokenMap["dataset"] = datasetName; + } +} \ No newline at end of file diff --git a/samples/process-control/process-control-demo/src/write.dataset.client/appsettings.json b/samples/process-control/process-control-demo/src/write.dataset.client/appsettings.json new file mode 100644 index 00000000..327c9812 --- /dev/null +++ b/samples/process-control/process-control-demo/src/write.dataset.client/appsettings.json @@ -0,0 +1,19 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug" + }, + "Console": { + "FormatterName": "simple", + "singleLine": "true" + } + }, + "ConnectionStrings": { + "Default": "HostName=localhost;TcpPort=31884;UseTls=false;ClientId=ProcessControlDemo;CleanStart=true" + }, + "AioSettings": { + "Namespace": "azure-iot-operations", + "AssetName": "boiler", + "DatasetName": "default" + } + } \ No newline at end of file diff --git a/samples/process-control/process-control-demo/src/write.dataset.client/write.dataset.client.csproj b/samples/process-control/process-control-demo/src/write.dataset.client/write.dataset.client.csproj new file mode 100644 index 00000000..a8aaf90e --- /dev/null +++ b/samples/process-control/process-control-demo/src/write.dataset.client/write.dataset.client.csproj @@ -0,0 +1,33 @@ + + + + Exe + net9.0 + process_control_demo + enable + enable + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + From cfb8025e70e6e452ce420becaf15c72068e23e8e Mon Sep 17 00:00:00 2001 From: Dominic Betts <1454644+dominicbetts@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:26:48 +0100 Subject: [PATCH 3/4] Update process-control readme --- samples/process-control/README.md | 134 ++++++------------------------ 1 file changed, 26 insertions(+), 108 deletions(-) diff --git a/samples/process-control/README.md b/samples/process-control/README.md index 9dfd7a7d..04c4a19f 100644 --- a/samples/process-control/README.md +++ b/samples/process-control/README.md @@ -1,7 +1,5 @@ # OPC UA process control - - In Azure IoT Operations `aio-opc-ua-commander` lets you send changes to an OPC UA server from the edge or from the cloud. The current preview includes support for writing data points from an asset dataset with simple and complex data-types as well as dumping the address space of an OPC UA server. The OPC-UA commander: @@ -28,6 +26,7 @@ To run the sample application, you need: - A preview instance of Azure IoT Operations deployed. If you don't already have an instance, see [Create an Azure IoT Operations instance](https://learn.microsoft.com/azure/iot-operations/get-started-end-to-end-sample/quickstart-deploy). - Access to the internal MQTT broker in the Azure IoT Operations cluster. To configure access the broker, see [Test connectivity to MQTT broker with MQTT clients](https://learn.microsoft.com/azure/iot-operations/manage-mqtt-broker/howto-test-connection). - [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) version 2.67.0 or higher. +- [NET 9 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) ## Deploy the simulator @@ -45,7 +44,7 @@ kubectl apply -f https://raw.githubusercontent.com/Azure-Samples/explore-iot-ope ## Configure the device and namespace assets -To add the required device and namespace asset to your instance, run the following commands: +To add the required device (`opc-ua-commander`), inbound endpoint (`opc-ua-commander-0`), and namespace asset (`boiler`) to your instance, run the following commands: ```bash wget https://raw.githubusercontent.com/Azure-Samples/explore-iot-operations/main/samples/process-control/boiler-simulation.bicep -O boiler-simulation.bicep @@ -58,124 +57,43 @@ CUSTOM_LOCATION_NAME=$(az iot ops list -g $RESOURCE_GROUP --query "[0].extendedL az deployment group create --subscription $SUBSCRIPTION_ID --resource-group $RESOURCE_GROUP --template-file boiler-simulation.bicep --parameters customLocationName=$CUSTOM_LOCATION_NAME aioNamespaceName=$AIO_NAMESPACE_NAME ``` -### Usage - -For example, to set the `TargetTemperature` and other values on the boiler asset, the sample application publishes the following MQTT message to the topic `azure-iot-operations/asset-operations//`: +You can review these resources in the Operations experience web UI or by running the following commands: -```json -{ - "BaseTemperature": 42, - "MaintenanceInterval": 360, - "OverheatInterval": 45, - "OverheatedThresholdTemperature": 199, - "TargetTemperature": 176, - "TemperatureChangeSpeed": 6 -} +```bash +# View device details +az iot ops ns device query --instance -g +# View namespace asset details +az iot ops ns asset query --instance -g ``` -The OPC UA commander service in the cluster subscribes to this topic, receives the message, and writes the values to the OPC UA server. The commander service then publishes the result of the write operation to the topic `responseTopic`. If the operation succeeds, the message in the response topic looks like `{}`. - -The sample in the [explore-iot-operations/samples/process-control](https://github.com/Azure-Samples/explore-iot-operations/tree/main/samples/process-control) folder shows how you can write values to the boiler in the OPC PLC simulator. - -#### How data in a dataset are Written to the asset - -Once the asset is installed, you can use the OPC-UA Commander to write data to the OPC-UA asset. -The OPC-UA Commander uses [RPC Protocol](https://github.com/Azure/iot-operations-sdks/blob/main/doc/reference/rpc-protocol.md). -The [Message Metadata](https://github.com/Azure/iot-operations-sdks/blob/main/doc/reference/message-metadata.md) specify system and user properties that should be included in the MQTT message. +## Configure and run application -The OPC-UA Commander Subscribe to MQTT topic `{AioNamespace}/asset-operations/{AssetId}/{DatasetName}/`. +Make sure the `process-control-demo/src/write.dataset.client/appsettings.json` file contains the correct values for: -On MQTT request/response create ad-hoc session based on AssetEndpointProfile (AEP) of asset. It validate that the Write request only contain data-points that exist within the data-set and then set the data. +- `HostName` and `Port` in the `ConnectionStrings` setting. +- `Namespace`: typically `azure-iot-operations`. +- `AssetName`: `boiler` if you used the `boiler-simulation.bicep` file to add the device and namespace asset. +- `DatasetName`: `default` if you used the `boiler-simulation.bicep` file to add the device and namespace asset. -A sample for simple datatype request payload is: - -```json -{ - "TargetTemperature": 95 -} -``` +To run the application: -A sample for complex datatype request payload is: -```json -{ - "BoilerStatus": { - "Temperature": { - "Top": 123, - "Bottom": 456 - }, - "Pressure": 789, - "HeaterState": "Off_0" - } -} +```bash +dotnet run --project process-control-demo/src/write.dataset.client/write.dataset.client.csproj ``` -## Asset Endpoint Operations - -Endpoint operations are process control calls that work on the AssetEndpointProfile only and don't need an Asset. - -### Browse +### Usage -To dump the address space of an OPC UA server if is possible to send an mRPC message to the topic `{AioNamespace}/endpoint-operations/{EndpointName}/browse`. -Either using an empty JSON object (mean browse from `root` node with infinite depth) or with an JSON object like: +For example, to set the `TargetTemperature` and other values on the boiler asset, the sample application publishes the following MQTT message to the topic `azure-iot-operations/asset-operations//`: -`Request:` ```json { - "root_data_point": "", - "depth": 128 + "BaseTemperature": 42, + "MaintenanceInterval": 360, + "OverheatInterval": 45, + "OverheatedThresholdTemperature": 199, + "TargetTemperature": 176, + "TemperatureChangeSpeed": 6 } ``` -* [Optional] `root_data_point` defines the starting point of the browse operation. -* [Optional] `depth` defines the max level of nested structure that should be browsed. - -The response is currently and array of array of nodes. An node contains common attributes, references and _NodeClass_ specific attributes. -Once the AIO SDK supports streaming, the outer array is removed and separate streaming response will be send via MQTT to the response topic. - -`Response:` -```json -[ - [ - { - "id": "nsu=http://microsoft.com/Opc/OpcPlc/Boiler;i=15070", - "class": "object", - "displayName": "Boiler #1", - "browseName": "4:Boiler #1", - "description": "A simple boiler.", - "attributes": { - "EventNotifier": "subscribeToEvents" - }, - "rolePermissions": [], - "userRolePermissions": [], - "writeMask": 0, - "userWriteMask": 0, - "accessRestrictions": "none", - "references": [ - { - "referenceTypeId": "i=47", - "name": "4:BoilerStatus", - "targetId": "ns=4;i=15013", - "isForward": true - }, - { - "referenceTypeId": "i=35", - "name": "4:Boilers", - "targetId": "ns=4;i=5", - "isForward": false - }, - { - "referenceTypeId": "i=40", - "name": "4:Boiler1Type", - "targetId": "ns=4;i=3", - "isForward": true - } - ] - } - ] -] -``` - - -Sample application - -Protocol compiler from SDK. \ No newline at end of file +The OPC UA commander service in the cluster subscribes to this topic, receives the message, and writes the values to the OPC UA server. The commander service then publishes the result of the write operation to the topic `responseTopic`. If the operation succeeds, the message in the response topic looks like `{}`. From 36b507b974a839d43e0bd26173d169fab27e29c3 Mon Sep 17 00:00:00 2001 From: Dominic Betts <1454644+dominicbetts@users.noreply.github.com> Date: Mon, 29 Sep 2025 12:04:53 +0000 Subject: [PATCH 4/4] Fix endpoint --- samples/process-control/README.md | 4 ++-- samples/process-control/boiler-simulation.bicep | 2 +- .../src/write.dataset.client/appsettings.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/samples/process-control/README.md b/samples/process-control/README.md index 04c4a19f..a2796da1 100644 --- a/samples/process-control/README.md +++ b/samples/process-control/README.md @@ -71,9 +71,9 @@ az iot ops ns asset query --instance -g