diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index fbe8229573..53f239bf89 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -101,7 +101,7 @@ This is the official OPC UA .NET Standard Stack from the OPC Foundation. It prov
- Follow standard C# naming conventions. Do not use underscores in method names.
- Assembly prefix: `Opc.Ua` (Except applications, or if otherwise requested)
- Package prefix: `OPCFoundation.NetStandard`
-- Always use a line break after and before for all members (except for documentation of fields).
+- Always use a line break after `` and before `` for all members (except for documentation of fields). This applies to **every** XML-doc summary, including in sample/application code — never write a single-line `/// ... `; always put the text on its own line between the opening and closing tags.
### Security Requirements
- **Never hardcode credentials, certificates, or secrets** in source code
diff --git a/.github/workflows/buildandtest.yml b/.github/workflows/buildandtest.yml
index 58d934cb52..033aa409fe 100644
--- a/.github/workflows/buildandtest.yml
+++ b/.github/workflows/buildandtest.yml
@@ -34,7 +34,7 @@ jobs:
outputs:
csprojs: ${{ steps.list.outputs.csprojs }}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Enumerate Tests/Opc.Ua.*.Tests projects
id: list
shell: pwsh
@@ -79,7 +79,7 @@ jobs:
TESTRESULTS: "TestResults-${{matrix.csproj}}-${{matrix.os}}-${{matrix.framework}}-${{matrix.configuration}}"
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -159,7 +159,7 @@ jobs:
AOTPROJECT: './Tests/Opc.Ua.Aot.Tests/Opc.Ua.Aot.Tests.csproj'
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
with:
fetch-depth: 0
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 9275e5efa0..e5bc7f051b 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -38,7 +38,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
index 883e1d41c9..9c70554372 100644
--- a/.github/workflows/docker-image.yml
+++ b/.github/workflows/docker-image.yml
@@ -34,7 +34,7 @@ jobs:
id-token: write
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
with:
fetch-depth: 0
diff --git a/.github/workflows/stability-test.yml b/.github/workflows/stability-test.yml
index 434da581f9..8f4306846a 100644
--- a/.github/workflows/stability-test.yml
+++ b/.github/workflows/stability-test.yml
@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 0
diff --git a/.github/workflows/stress-test.yml b/.github/workflows/stress-test.yml
index 7640ac4494..f55e1ca597 100644
--- a/.github/workflows/stress-test.yml
+++ b/.github/workflows/stress-test.yml
@@ -36,7 +36,7 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 0
diff --git a/Applications/ConsoleReferencePubSubClient/ConsoleLoggingSink.cs b/Applications/ConsoleReferencePubSubClient/ConsoleLoggingSink.cs
new file mode 100644
index 0000000000..cdea6f1702
--- /dev/null
+++ b/Applications/ConsoleReferencePubSubClient/ConsoleLoggingSink.cs
@@ -0,0 +1,85 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Opc.Ua.PubSub.DataSets;
+using Opc.Ua.PubSub.Encoding;
+
+namespace Quickstarts.ConsoleReferencePubSubClient
+{
+ ///
+ /// that prints every received
+ /// DataSetMessage to the console via an
+ /// . Demonstrates the Part 14 §6.2.9 sink
+ /// extension point.
+ ///
+ public sealed class ConsoleLoggingSink : ISubscribedDataSetSink
+ {
+ private readonly ILogger m_logger;
+ private long m_received;
+
+ public ConsoleLoggingSink(ILogger logger)
+ {
+ if (logger is null)
+ {
+ throw new ArgumentNullException(nameof(logger));
+ }
+ m_logger = logger;
+ }
+
+ public ValueTask WriteAsync(
+ IReadOnlyList fields,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ long sequence = Interlocked.Increment(ref m_received);
+ var builder = new StringBuilder();
+ for (int i = 0; i < fields.Count; i++)
+ {
+ DataSetField field = fields[i];
+ if (i > 0)
+ {
+ builder.Append(", ");
+ }
+ builder.Append(string.IsNullOrEmpty(field.Name) ? $"f{i}" : field.Name);
+ builder.Append('=');
+ builder.Append(field.Value.IsNull ? "(null)" : field.Value.ToString());
+ }
+ m_logger.LogInformation(
+ "DataSet #{Sequence} received ({FieldCount} fields): {Fields}",
+ sequence, fields.Count, builder.ToString());
+ return ValueTask.CompletedTask;
+ }
+ }
+}
diff --git a/Applications/ConsoleReferencePubSubClient/ConsoleReferencePubSubClient.csproj b/Applications/ConsoleReferencePubSubClient/ConsoleReferencePubSubClient.csproj
new file mode 100644
index 0000000000..ef292bb712
--- /dev/null
+++ b/Applications/ConsoleReferencePubSubClient/ConsoleReferencePubSubClient.csproj
@@ -0,0 +1,37 @@
+
+
+ net10.0
+ Exe
+ ConsoleReferencePubSubClient
+ ConsoleReferencePubSubClient
+ OPC Foundation
+ Self-contained OPC UA Part 14 PubSub reference sample with three command-line-selectable modes (publisher, subscriber, external-server adapter bridge) built on the fluent + DI Host surface. Native AOT compatible.
+ Copyright © 2004-2026 OPC Foundation, Inc
+ Quickstarts.ConsoleReferencePubSubClient
+ enable
+ false
+ true
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
diff --git a/Applications/ConsoleReferencePubSubClient/ExternalServerPubSubConfiguration.cs b/Applications/ConsoleReferencePubSubClient/ExternalServerPubSubConfiguration.cs
new file mode 100644
index 0000000000..0af1aeb6c5
--- /dev/null
+++ b/Applications/ConsoleReferencePubSubClient/ExternalServerPubSubConfiguration.cs
@@ -0,0 +1,360 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using Opc.Ua;
+using Opc.Ua.PubSub.Configuration;
+
+namespace Quickstarts.ConsoleReferencePubSubClient
+{
+ ///
+ /// Builds the small, self-contained Part 14
+ /// payloads consumed by the
+ /// external-server PubSub adapters. The publisher payload maps three
+ /// PublishedDataSet variables onto well-known Server status nodes that
+ /// exist on every OPC UA server, and the subscriber payload maps the same
+ /// field set onto writable nodes on the target server.
+ ///
+ ///
+ /// The fluent assembles the
+ /// connection / writer-group / reader-group structure and the DataSet
+ /// metadata. The two adapter-specific pieces it does not model directly -
+ /// the PublishedDataSet source
+ /// node ids and the DataSetReader
+ /// write targets - are attached afterwards, because those node ids are
+ /// exactly what the Opc.Ua.PubSub.Adapter reads from and writes to
+ /// on the external server.
+ ///
+ public static class ExternalServerPubSubConfiguration
+ {
+ ///
+ /// Name shared by the PublishedDataSet and the DataSetWriter / reader
+ /// metadata. The adapter matches DataSetWriters to PublishedDataSets and
+ /// registers sinks per DataSetReader by this name.
+ ///
+ public const string DataSetName = "ExternalServerDataSet";
+
+ ///
+ /// DataSetReader name. The subscriber adapter registers one external
+ /// write sink per reader using this name.
+ ///
+ public const string ReaderName = "ExternalServerReader";
+
+ ///
+ /// Default UDP/UADP multicast transport endpoint for the PubSub wire.
+ ///
+ public const string DefaultPubSubEndpoint = "opc.udp://239.0.0.1:4840";
+
+ private const ushort PublisherId = 1;
+ private const ushort WriterGroupId = 100;
+ private const ushort DataSetWriterId = 1;
+ private const int PublishingIntervalMs = 1000;
+
+ ///
+ /// Builds the external bridge configuration for the selected publisher,
+ /// subscriber and responder directions.
+ ///
+ ///
+ /// The external bridge directions to include.
+ ///
+ ///
+ /// The UDP/UADP transport endpoint the bridge uses.
+ ///
+ ///
+ /// A configuration with one PubSubConnection containing the selected
+ /// writer and reader groups.
+ ///
+ public static PubSubConfigurationDataType BuildConfiguration(BridgeMode modes, string pubSubEndpoint)
+ {
+ if (modes == BridgeMode.None)
+ {
+ modes = BridgeMode.Publisher;
+ }
+
+ bool includePublisher = modes.HasFlag(BridgeMode.Publisher);
+ bool includeSubscriber = modes.HasFlag(BridgeMode.Subscriber)
+ || modes.HasFlag(BridgeMode.Responder);
+
+ PubSubConfigurationBuilder builder = PubSubConfigurationBuilder.Create();
+ if (includePublisher)
+ {
+ AddExternalServerDataSet(builder);
+ }
+
+ builder.AddConnection(ConnectionName(modes), connection =>
+ {
+ connection
+ .WithPublisherId(new Variant(PublisherId))
+ .WithTransportProfile(Profiles.PubSubUdpUadpTransport)
+ .WithAddress(pubSubEndpoint);
+
+ if (includePublisher)
+ {
+ AddWriterGroup(connection);
+ }
+ if (includeSubscriber)
+ {
+ AddReaderGroup(connection);
+ }
+ });
+
+ PubSubConfigurationDataType configuration = builder.Build();
+ if (includePublisher)
+ {
+ AttachExternalReadSource(configuration);
+ }
+ if (includeSubscriber)
+ {
+ AttachExternalWriteTargets(configuration);
+ }
+
+ return configuration;
+ }
+
+ ///
+ /// Builds the publisher configuration. The PublishedDataSet fields are
+ /// sourced from the external server's Server status nodes so the
+ /// sample produces meaningful data against any compliant server without
+ /// prior address-space knowledge.
+ ///
+ ///
+ /// The UDP/UADP transport endpoint the publisher emits on.
+ ///
+ ///
+ /// A configuration with one PublishedDataSet, one PubSubConnection, one
+ /// WriterGroup and one DataSetWriter.
+ ///
+ public static PubSubConfigurationDataType BuildPublisherConfiguration(string pubSubEndpoint)
+ {
+ return BuildConfiguration(BridgeMode.Publisher, pubSubEndpoint);
+ }
+
+ ///
+ /// Builds the subscriber configuration. Each received DataSet field is
+ /// written back to the external server through the DataSetReader's
+ /// TargetVariables, mapped positionally to the placeholder writable
+ /// nodes below.
+ ///
+ ///
+ /// The UDP/UADP transport endpoint the subscriber listens on.
+ ///
+ ///
+ /// A configuration with one PubSubConnection, one ReaderGroup and one
+ /// DataSetReader whose SubscribedDataSet is a
+ /// .
+ ///
+ public static PubSubConfigurationDataType BuildSubscriberConfiguration(string pubSubEndpoint)
+ {
+ return BuildConfiguration(BridgeMode.Subscriber, pubSubEndpoint);
+ }
+
+ private static void AddExternalServerDataSet(PubSubConfigurationBuilder builder)
+ {
+ builder.AddPublishedDataSet(DataSetName, dataSet => dataSet
+ .AddField("CurrentTime", (byte)DataTypes.DateTime, DataTypeIds.DateTime)
+ .AddField("State", (byte)DataTypes.Int32, DataTypeIds.Int32)
+ .AddField("ServiceLevel", (byte)DataTypes.Byte, DataTypeIds.Byte));
+ }
+
+ private static void AddWriterGroup(PubSubConnectionBuilder connection)
+ {
+ connection.AddWriterGroup("WriterGroup 1", group =>
+ {
+ group
+ .WithWriterGroupId(WriterGroupId)
+ .WithPublishingInterval(PublishingIntervalMs)
+ .WithMessageSettings(WriterGroupMessageSettings())
+ .WithTransportSettings(new DatagramWriterGroupTransportDataType())
+ .AddDataSetWriter("Writer 1", writer => writer
+ .WithDataSetWriterId(DataSetWriterId)
+ .WithDataSetName(DataSetName)
+ .WithKeyFrameCount(1)
+ .WithFieldContentMask(DataSetFieldContentMask.RawData)
+ .WithMessageSettings(WriterMessageSettings()));
+ });
+ }
+
+ private static void AddReaderGroup(PubSubConnectionBuilder connection)
+ {
+ connection.AddReaderGroup("ReaderGroup 1", group => group
+ .WithMaxNetworkMessageSize(1500)
+ .AddDataSetReader(ReaderName, reader => reader
+ .WithFilter(new Variant(PublisherId), WriterGroupId, DataSetWriterId)
+ .WithFieldContentMask(DataSetFieldContentMask.RawData)
+ .WithMessageReceiveTimeout(5000)
+ .WithMessageSettings(ReaderMessageSettings())
+ .WithDataSetMetaData(DataSetName, metaData => metaData
+ .WithoutFieldIds()
+ .AddField("CurrentTime", (byte)DataTypes.DateTime, DataTypeIds.DateTime)
+ .AddField("State", (byte)DataTypes.Int32, DataTypeIds.Int32)
+ .AddField("ServiceLevel", (byte)DataTypes.Byte, DataTypeIds.Byte))));
+ }
+
+ private static string ConnectionName(BridgeMode modes)
+ {
+ if (modes == BridgeMode.Publisher)
+ {
+ return "External Server Publisher Connection";
+ }
+ if (modes == BridgeMode.Subscriber || modes == BridgeMode.Responder)
+ {
+ return "External Server Subscriber Connection";
+ }
+
+ return "External Server Bridge Connection";
+ }
+
+ private static void AttachExternalReadSource(PubSubConfigurationDataType configuration)
+ {
+ if (configuration.PublishedDataSets.IsNull || configuration.PublishedDataSets.Count == 0)
+ {
+ return;
+ }
+
+ var source = new PublishedDataItemsDataType
+ {
+ PublishedData =
+ [
+ ReadFrom(VariableIds.Server_ServerStatus_CurrentTime),
+ ReadFrom(VariableIds.Server_ServerStatus_State),
+ ReadFrom(VariableIds.Server_ServiceLevel)
+ ]
+ };
+ configuration.PublishedDataSets[0].DataSetSource = new ExtensionObject(source);
+ }
+
+ private static void AttachExternalWriteTargets(PubSubConfigurationDataType configuration)
+ {
+ DataSetReaderDataType? reader = FindFirstReader(configuration);
+ if (reader is null)
+ {
+ return;
+ }
+
+ // Placeholder writable nodes on the external (target) server. Point
+ // these at any writable variables of matching type - for example the
+ // Scalar simulation nodes exposed by the repository's
+ // ConsoleReferenceServer.
+ var targets = new TargetVariablesDataType
+ {
+ TargetVariables =
+ [
+ WriteTo("Demo.External.CurrentTime"),
+ WriteTo("Demo.External.State"),
+ WriteTo("Demo.External.ServiceLevel")
+ ]
+ };
+ reader.SubscribedDataSet = new ExtensionObject(targets);
+ }
+
+ private static PublishedVariableDataType ReadFrom(NodeId variableId)
+ {
+ return new PublishedVariableDataType
+ {
+ PublishedVariable = variableId,
+ AttributeId = Attributes.Value
+ };
+ }
+
+ private static FieldTargetDataType WriteTo(string nodeIdentifier)
+ {
+ return new FieldTargetDataType
+ {
+ TargetNodeId = NodeId.Parse($"ns=2;s={nodeIdentifier}"),
+ AttributeId = Attributes.Value,
+ OverrideValueHandling = OverrideValueHandling.LastUsableValue
+ };
+ }
+
+ private static DataSetReaderDataType? FindFirstReader(PubSubConfigurationDataType configuration)
+ {
+ if (configuration.Connections.IsNull)
+ {
+ return null;
+ }
+ foreach (PubSubConnectionDataType connection in configuration.Connections)
+ {
+ if (connection?.ReaderGroups is null || connection.ReaderGroups.IsNull)
+ {
+ continue;
+ }
+ foreach (ReaderGroupDataType group in connection.ReaderGroups)
+ {
+ if (group is null || group.DataSetReaders.IsNull || group.DataSetReaders.Count == 0)
+ {
+ continue;
+ }
+ return group.DataSetReaders[0];
+ }
+ }
+ return null;
+ }
+
+ private static UadpWriterGroupMessageDataType WriterGroupMessageSettings()
+ {
+ return new UadpWriterGroupMessageDataType
+ {
+ DataSetOrdering = DataSetOrderingType.AscendingWriterId,
+ NetworkMessageContentMask = (uint)(
+ UadpNetworkMessageContentMask.PublisherId
+ | UadpNetworkMessageContentMask.GroupHeader
+ | UadpNetworkMessageContentMask.WriterGroupId
+ | UadpNetworkMessageContentMask.PayloadHeader
+ | UadpNetworkMessageContentMask.NetworkMessageNumber
+ | UadpNetworkMessageContentMask.SequenceNumber)
+ };
+ }
+
+ private static UadpDataSetWriterMessageDataType WriterMessageSettings()
+ {
+ return new UadpDataSetWriterMessageDataType
+ {
+ DataSetMessageContentMask = (uint)(
+ UadpDataSetMessageContentMask.Status
+ | UadpDataSetMessageContentMask.SequenceNumber)
+ };
+ }
+
+ private static UadpDataSetReaderMessageDataType ReaderMessageSettings()
+ {
+ return new UadpDataSetReaderMessageDataType
+ {
+ NetworkMessageContentMask = (uint)(
+ UadpNetworkMessageContentMask.PublisherId
+ | UadpNetworkMessageContentMask.GroupHeader
+ | UadpNetworkMessageContentMask.WriterGroupId
+ | UadpNetworkMessageContentMask.PayloadHeader
+ | UadpNetworkMessageContentMask.NetworkMessageNumber
+ | UadpNetworkMessageContentMask.SequenceNumber),
+ DataSetMessageContentMask = (uint)(
+ UadpDataSetMessageContentMask.Status
+ | UadpDataSetMessageContentMask.SequenceNumber)
+ };
+ }
+ }
+}
diff --git a/Applications/ConsoleReferencePubSubClient/Program.cs b/Applications/ConsoleReferencePubSubClient/Program.cs
new file mode 100644
index 0000000000..9b0bc51767
--- /dev/null
+++ b/Applications/ConsoleReferencePubSubClient/Program.cs
@@ -0,0 +1,901 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.CommandLine;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Opc.Ua;
+using Opc.Ua.PubSub.Adapter;
+using Opc.Ua.PubSub.Adapter.DependencyInjection;
+using Opc.Ua.PubSub.Application;
+using Opc.Ua.PubSub.Configuration;
+
+namespace Quickstarts.ConsoleReferencePubSubClient
+{
+ ///
+ /// Unified OPC UA Part 14 PubSub reference sample built on the fluent
+ /// + DI + .NET Generic Host surface.
+ /// One executable exposes three command-line-selectable modes:
+ ///
+ /// -
+ /// publisher - publishes sample DataSets over UDP/UADP or MQTT (UADP/JSON).
+ ///
+ /// -
+ /// subscriber - receives DataSets and logs each decoded message.
+ ///
+ /// -
+ /// external - bridges an external OPC UA server to PubSub through the
+ /// Opc.Ua.PubSub.Adapter library (publisher / subscriber / responder).
+ ///
+ ///
+ /// The build publishes as a NativeAOT-ready single-file executable.
+ ///
+ internal static class Program
+ {
+ private const string DefaultExternalEndpoint =
+ "opc.tcp://localhost:62541/Quickstarts/ReferenceServer";
+
+ private const string ExternalPublisherOptionsName = "ExternalPublisher";
+ private const string ExternalSubscriberOptionsName = "ExternalSubscriber";
+ private const string ExternalResponderOptionsName = "ExternalResponder";
+
+ public static async Task Main(string[] args)
+ {
+ int exitCode = 0;
+
+ var rootCommand = new RootCommand(
+ "OPC UA Part 14 PubSub Reference sample. " +
+ "Select a mode: publisher | subscriber | external.");
+
+ rootCommand.Subcommands.Add(BuildPublisherCommand(code => exitCode = code));
+ rootCommand.Subcommands.Add(BuildSubscriberCommand(code => exitCode = code));
+ rootCommand.Subcommands.Add(BuildExternalCommand(code => exitCode = code));
+
+ ParseResult parse = rootCommand.Parse(args);
+ await parse.InvokeAsync().ConfigureAwait(false);
+ return exitCode;
+ }
+
+ ///
+ /// Builds the publisher subcommand: a UDP/UADP or MQTT publisher
+ /// that publishes a built-in sample DataSet.
+ ///
+ private static Command BuildPublisherCommand(Action setExitCode)
+ {
+ var profileOption = new Option("--profile")
+ {
+ Description = "Transport profile: udp-uadp | mqtt-uadp | mqtt-json.",
+ DefaultValueFactory = _ => "udp-uadp"
+ };
+ var configFileOption = new Option("--config-file")
+ {
+ Description = "Optional path to a Part 14 XML PubSub configuration."
+ };
+ var publisherIdOption = new Option("--publisher-id")
+ {
+ Description = "PublisherId published in every NetworkMessage header.",
+ DefaultValueFactory = _ => 1
+ };
+ var writerGroupIdOption = new Option("--writer-group-id")
+ {
+ Description = "WriterGroupId for the single sample WriterGroup.",
+ DefaultValueFactory = _ => 100
+ };
+ var dataSetWriterIdOption = new Option("--data-set-writer-id")
+ {
+ Description = "DataSetWriterId for the single sample writer.",
+ DefaultValueFactory = _ => 1
+ };
+ var endpointOption = new Option("--endpoint")
+ {
+ Description =
+ "Transport endpoint URL. Defaults: opc.udp://239.0.0.1:4840 (UDP), " +
+ "mqtt://localhost:1883 (MQTT)."
+ };
+ var intervalOption = new Option("--interval")
+ {
+ Description = "Publishing interval in milliseconds.",
+ DefaultValueFactory = _ => 1000
+ };
+
+ var command = new Command(
+ "publisher",
+ "Publish a sample DataSet over UDP/UADP or MQTT (UADP/JSON).")
+ {
+ profileOption,
+ configFileOption,
+ publisherIdOption,
+ writerGroupIdOption,
+ dataSetWriterIdOption,
+ endpointOption,
+ intervalOption
+ };
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ string? profileArg = parseResult.GetValue(profileOption);
+ if (!TryParsePublisherProfile(profileArg, out PublisherProfile profile))
+ {
+ await Console.Error.WriteLineAsync(
+ $"Unknown --profile value '{profileArg}'. " +
+ "Expected one of: udp-uadp, mqtt-uadp, mqtt-json.")
+ .ConfigureAwait(false);
+ setExitCode(2);
+ return;
+ }
+ setExitCode(await RunPublisherAsync(
+ profile,
+ parseResult.GetValue(configFileOption),
+ parseResult.GetValue(publisherIdOption),
+ parseResult.GetValue(writerGroupIdOption),
+ parseResult.GetValue(dataSetWriterIdOption),
+ parseResult.GetValue(endpointOption),
+ parseResult.GetValue(intervalOption),
+ cancellationToken).ConfigureAwait(false));
+ });
+
+ return command;
+ }
+
+ ///
+ /// Builds the subscriber subcommand: a UDP/UADP or MQTT subscriber
+ /// that logs each decoded DataSetMessage.
+ ///
+ private static Command BuildSubscriberCommand(Action setExitCode)
+ {
+ var profileOption = new Option("--profile")
+ {
+ Description = "Transport profile: udp-uadp | mqtt-uadp | mqtt-json.",
+ DefaultValueFactory = _ => "udp-uadp"
+ };
+ var configFileOption = new Option("--config-file")
+ {
+ Description = "Optional path to a Part 14 XML PubSub configuration."
+ };
+ var publisherFilterOption = new Option("--publisher-id-filter")
+ {
+ Description = "PublisherId filter applied by the reader.",
+ DefaultValueFactory = _ => 1
+ };
+ var writerGroupFilterOption = new Option("--writer-group-id-filter")
+ {
+ Description = "WriterGroupId filter applied by the reader.",
+ DefaultValueFactory = _ => 100
+ };
+ var dataSetWriterFilterOption = new Option("--data-set-writer-id-filter")
+ {
+ Description = "DataSetWriterId filter applied by the reader.",
+ DefaultValueFactory = _ => 1
+ };
+ var endpointOption = new Option("--endpoint")
+ {
+ Description = "Transport endpoint URL. Defaults: opc.udp://239.0.0.1:4840 " +
+ "(UDP), mqtt://localhost:1883 (MQTT)."
+ };
+
+ var command = new Command(
+ "subscriber",
+ "Receive DataSets and log each decoded message.")
+ {
+ profileOption,
+ configFileOption,
+ publisherFilterOption,
+ writerGroupFilterOption,
+ dataSetWriterFilterOption,
+ endpointOption
+ };
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ string? profileArg = parseResult.GetValue(profileOption);
+ if (!TryParseSubscriberProfile(profileArg, out SubscriberProfile profile))
+ {
+ await Console.Error.WriteLineAsync(
+ $"Unknown --profile value '{profileArg}'. " +
+ "Expected one of: udp-uadp, mqtt-uadp, mqtt-json.")
+ .ConfigureAwait(false);
+ setExitCode(2);
+ return;
+ }
+ setExitCode(await RunSubscriberAsync(
+ profile,
+ parseResult.GetValue(configFileOption),
+ parseResult.GetValue(publisherFilterOption),
+ parseResult.GetValue(writerGroupFilterOption),
+ parseResult.GetValue(dataSetWriterFilterOption),
+ parseResult.GetValue(endpointOption),
+ cancellationToken).ConfigureAwait(false));
+ });
+
+ return command;
+ }
+
+ ///
+ /// Builds the external subcommand: bridges an external OPC UA server
+ /// to PubSub via the adapter library, in publisher / subscriber / responder
+ /// direction.
+ ///
+ private static Command BuildExternalCommand(Action setExitCode)
+ {
+ var directionOption = new Option("--mode")
+ {
+ Description =
+ "Adapter directions to run, comma- or plus-separated: publisher | subscriber | responder.",
+ DefaultValueFactory = _ => "publisher"
+ };
+ var readModeOption = new Option("--read-mode")
+ {
+ Description =
+ "Publisher source strategy: cyclic (Read each cycle) | " +
+ "subscription (client Subscription cache).",
+ DefaultValueFactory = _ => "cyclic"
+ };
+ var affinityOption = new Option("--affinity")
+ {
+ Description =
+ "Subscription grouping when --read-mode=subscription: " +
+ "writergroup | datasetwriter.",
+ DefaultValueFactory = _ => "writergroup"
+ };
+ var endpointOption = new Option("--endpoint")
+ {
+ Description =
+ "External OPC UA server endpoint URL. Defaults to " +
+ "OPCUA_EXTERNAL_ENDPOINT or " +
+ DefaultExternalEndpoint +
+ "."
+ };
+ var pubSubEndpointOption = new Option("--pubsub-endpoint")
+ {
+ Description = "UDP/UADP PubSub transport endpoint URL.",
+ DefaultValueFactory = _ => ExternalServerPubSubConfiguration.DefaultPubSubEndpoint
+ };
+ var hotReloadOption = new Option("--hot-reload")
+ {
+ Description =
+ "Enable the external bridge hot-reload demo using appsettings.json and pubsub-config.xml."
+ };
+
+ var command = new Command(
+ "external",
+ "Bridge an external OPC UA server to PubSub (publisher | subscriber | responder).")
+ {
+ directionOption,
+ readModeOption,
+ affinityOption,
+ endpointOption,
+ pubSubEndpointOption,
+ hotReloadOption
+ };
+
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ if (!TryParseBridgeMode(parseResult.GetValue(directionOption), out BridgeMode mode))
+ {
+ await Console.Error.WriteLineAsync(
+ $"Unknown --mode value '{parseResult.GetValue(directionOption)}'. " +
+ "Expected one or more of: publisher, subscriber, responder.")
+ .ConfigureAwait(false);
+ setExitCode(2);
+ return;
+ }
+ if (!TryParseReadMode(parseResult.GetValue(readModeOption), out ReadMode readMode))
+ {
+ await Console.Error.WriteLineAsync(
+ $"Unknown --read-mode value '{parseResult.GetValue(readModeOption)}'. " +
+ "Expected one of: cyclic, subscription.")
+ .ConfigureAwait(false);
+ setExitCode(2);
+ return;
+ }
+ if (!TryParseAffinity(
+ parseResult.GetValue(affinityOption), out SubscriptionAffinity affinity))
+ {
+ await Console.Error.WriteLineAsync(
+ $"Unknown --affinity value '{parseResult.GetValue(affinityOption)}'. " +
+ "Expected one of: writergroup, datasetwriter.")
+ .ConfigureAwait(false);
+ setExitCode(2);
+ return;
+ }
+
+ string externalEndpoint = parseResult.GetValue(endpointOption)
+ ?? Environment.GetEnvironmentVariable("OPCUA_EXTERNAL_ENDPOINT")
+ ?? DefaultExternalEndpoint;
+
+ setExitCode(await RunExternalAsync(
+ mode,
+ readMode,
+ affinity,
+ externalEndpoint,
+ parseResult.GetValue(pubSubEndpointOption)
+ ?? ExternalServerPubSubConfiguration.DefaultPubSubEndpoint,
+ parseResult.GetValue(hotReloadOption),
+ cancellationToken).ConfigureAwait(false));
+ });
+
+ return command;
+ }
+
+ private static async Task RunPublisherAsync(
+ PublisherProfile profile,
+ string? configFile,
+ ushort publisherId,
+ ushort writerGroupId,
+ ushort dataSetWriterId,
+ string? endpoint,
+ int intervalMs,
+ CancellationToken cancellationToken)
+ {
+ HostApplicationBuilder builder = Host.CreateApplicationBuilder();
+ builder.Logging.ClearProviders();
+ builder.Logging.AddConsole();
+
+ string transportEndpoint = endpoint
+ ?? PublisherConfigurationBuilder.DefaultEndpointFor(profile);
+ var sampleSource = new SampleDataSetSource();
+
+ builder.Services.AddOpcUa().AddPubSub(pubsub =>
+ {
+ IPubSubBuilder publisher = pubsub
+ .AddPublisher()
+ .AddUdpTransport()
+ .AddSecurityKeyProvider(SampleSecurity.CreateKeyProvider())
+ .AddDataSetSource(PublisherConfigurationBuilder.DataSetName, sampleSource);
+ if (profile == PublisherProfile.EthUadp)
+ {
+ publisher.AddEthTransport();
+ }
+ else if (profile != PublisherProfile.UdpUadp)
+ {
+ publisher.AddMqttTransport();
+ }
+ publisher.ConfigureApplication(app =>
+ {
+ app.WithApplicationId("urn:opcfoundation:ConsoleReferencePubSubClient:Publisher");
+ if (!string.IsNullOrEmpty(configFile))
+ {
+ app.UseConfigurationFile(configFile);
+ }
+ else
+ {
+ app.UseConfiguration(PublisherConfigurationBuilder.Build(
+ profile,
+ transportEndpoint,
+ publisherId,
+ writerGroupId,
+ dataSetWriterId,
+ intervalMs));
+ }
+ });
+ });
+
+ IHost host = builder.Build();
+ ILogger logger = host.Services
+ .GetRequiredService()
+ .CreateLogger("ConsoleReferencePubSubClient.Publisher");
+ logger.LogInformation(
+ "Publisher starting: profile={Profile} endpoint={Endpoint} " +
+ "interval={Interval}ms publisherId={PublisherId} writerGroup={WriterGroupId}",
+ profile, transportEndpoint, intervalMs, publisherId, writerGroupId);
+ logger.LogInformation("Publisher started. Press Ctrl-C to exit.");
+ await host.RunAsync(cancellationToken).ConfigureAwait(false);
+ return 0;
+ }
+
+ private static async Task RunSubscriberAsync(
+ SubscriberProfile profile,
+ string? configFile,
+ ushort publisherIdFilter,
+ ushort writerGroupIdFilter,
+ ushort dataSetWriterIdFilter,
+ string? endpoint,
+ CancellationToken cancellationToken)
+ {
+ HostApplicationBuilder builder = Host.CreateApplicationBuilder();
+ builder.Logging.ClearProviders();
+ builder.Logging.AddConsole();
+
+ string transportEndpoint = endpoint
+ ?? SubscriberConfigurationBuilder.DefaultEndpointFor(profile);
+
+ builder.Services.AddOpcUa().AddPubSub(pubsub =>
+ {
+ IPubSubBuilder subscriber = pubsub
+ .AddSubscriber()
+ .AddUdpTransport()
+ .AddSecurityKeyProvider(SampleSecurity.CreateKeyProvider())
+ .AddSubscribedDataSetSink(
+ SubscriberConfigurationBuilder.ReaderName,
+ sp => new ConsoleLoggingSink(
+ sp.GetRequiredService()
+ .CreateLogger()));
+ if (profile == SubscriberProfile.EthUadp)
+ {
+ subscriber.AddEthTransport();
+ }
+ else if (profile != SubscriberProfile.UdpUadp)
+ {
+ subscriber.AddMqttTransport();
+ }
+ subscriber.ConfigureApplication(app =>
+ {
+ app.WithApplicationId("urn:opcfoundation:ConsoleReferencePubSubClient:Subscriber");
+ if (!string.IsNullOrEmpty(configFile))
+ {
+ app.UseConfigurationFile(configFile);
+ }
+ else
+ {
+ app.UseConfiguration(SubscriberConfigurationBuilder.Build(
+ profile,
+ transportEndpoint,
+ publisherIdFilter,
+ writerGroupIdFilter,
+ dataSetWriterIdFilter));
+ }
+ });
+ });
+
+ IHost host = builder.Build();
+ ILogger logger = host.Services
+ .GetRequiredService()
+ .CreateLogger("ConsoleReferencePubSubClient.Subscriber");
+ logger.LogInformation(
+ "Subscriber starting: profile={Profile} endpoint={Endpoint} " +
+ "publisherFilter={PublisherFilter} writerGroupFilter={WriterGroupFilter}",
+ profile,
+ transportEndpoint,
+ publisherIdFilter,
+ writerGroupIdFilter);
+ logger.LogInformation("Subscriber started. Press Ctrl-C to exit.");
+ await host.RunAsync(cancellationToken).ConfigureAwait(false);
+ return 0;
+ }
+
+ private static async Task RunExternalAsync(
+ BridgeMode mode,
+ ReadMode readMode,
+ SubscriptionAffinity affinity,
+ string externalEndpoint,
+ string pubSubEndpoint,
+ bool hotReload,
+ CancellationToken cancellationToken)
+ {
+ HostApplicationBuilder builder = hotReload
+ ? Host.CreateApplicationBuilder(
+ new HostApplicationBuilderSettings { ContentRootPath = AppContext.BaseDirectory })
+ : Host.CreateApplicationBuilder();
+ builder.Logging.ClearProviders();
+ builder.Logging.AddConsole();
+
+ string? configFile = null;
+ XmlPubSubConfigurationStore? hotReloadStore = null;
+ string appSettingsFile = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
+ if (hotReload)
+ {
+ (configFile, hotReloadStore) = await ConfigureExternalBridgeHotReloadAsync(
+ builder,
+ mode,
+ pubSubEndpoint,
+ cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ ConfigureExternalBridge(builder, mode, readMode, affinity, externalEndpoint, pubSubEndpoint);
+ }
+
+ IHost host = builder.Build();
+ ILogger logger = host.Services
+ .GetRequiredService()
+ .CreateLogger("ConsoleReferencePubSubClient.External");
+ logger.LogInformation(
+ "External-server PubSub bridge starting: mode={Mode} readMode={ReadMode} " +
+ "affinity={Affinity} externalServer={ExternalEndpoint} pubSub={PubSubEndpoint}",
+ mode, readMode, affinity, externalEndpoint, pubSubEndpoint);
+ if (hotReload)
+ {
+ logger.LogInformation(
+ "Hot reload enabled. Edit {AppSettingsFile} (for example, change " +
+ "{PublisherOptionsName}:ReadMode to Subscription) or {ConfigFile} " +
+ "(for example, add or remove a DataSetWriter) and save to reconfigure " +
+ "the running bridge.",
+ appSettingsFile,
+ ExternalPublisherOptionsName,
+ configFile);
+ }
+ logger.LogInformation("Bridge started. Press Ctrl-C to exit.");
+ try
+ {
+ await host.RunAsync(cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ hotReloadStore?.Dispose();
+ }
+ return 0;
+ }
+
+ ///
+ /// Wires the selected external bridge directions on one UDP/UADP PubSub
+ /// application and one host.
+ ///
+ private static void ConfigureExternalBridge(
+ HostApplicationBuilder builder,
+ BridgeMode modes,
+ ReadMode readMode,
+ SubscriptionAffinity affinity,
+ string externalEndpoint,
+ string pubSubEndpoint)
+ {
+ builder.Services.AddOpcUa().AddPubSub(pubsub =>
+ {
+ IPubSubBuilder bridge = pubsub;
+ if (modes.HasFlag(BridgeMode.Publisher))
+ {
+ bridge = bridge.AddPublisher();
+ }
+ if (modes.HasFlag(BridgeMode.Subscriber) || modes.HasFlag(BridgeMode.Responder))
+ {
+ bridge = bridge.AddSubscriber();
+ }
+
+ bridge = bridge
+ .AddUdpTransport()
+ .ConfigureApplication(app => app.WithApplicationId(
+ "urn:opcfoundation:ConsoleReferencePubSubClient:ExternalBridge"))
+ .UseConfiguration(
+ ExternalServerPubSubConfiguration.BuildConfiguration(modes, pubSubEndpoint));
+
+ if (modes.HasFlag(BridgeMode.Publisher))
+ {
+ bridge = bridge.AddServerAsPublisher(options =>
+ {
+ options.Connection.EndpointUrl = externalEndpoint;
+ // The demo connects unsecured for zero-config interop. A
+ // production bridge must use SignAndEncrypt with a provisioned
+ // application instance certificate.
+ options.Connection.SecurityMode = MessageSecurityMode.None;
+ options.ReadMode = readMode;
+ options.Affinity = affinity;
+ });
+ }
+ if (modes.HasFlag(BridgeMode.Subscriber))
+ {
+ bridge = bridge.AddServerAsSubscriber(options =>
+ {
+ options.Connection.EndpointUrl = externalEndpoint;
+ options.Connection.SecurityMode = MessageSecurityMode.None;
+ });
+ }
+ if (modes.HasFlag(BridgeMode.Responder))
+ {
+ bridge.AddServerAsActionResponder(options =>
+ {
+ options.Connection.EndpointUrl = externalEndpoint;
+ options.Connection.SecurityMode = MessageSecurityMode.None;
+ options.AllowUnsecured = true;
+ // Map the "ResetCounters" action to an external method call.
+ options.MethodMap.Add(
+ "ResetCounters",
+ NodeId.Parse("ns=2;s=Demo.External.Methods"),
+ NodeId.Parse("ns=2;s=Demo.External.ResetCounters"));
+ options.Targets.Add(new PubSubActionTarget
+ {
+ DataSetWriterId = 1,
+ ActionName = "ResetCounters"
+ });
+ });
+ }
+ });
+ }
+
+ private static async Task<(string ConfigFile, XmlPubSubConfigurationStore Store)> ConfigureExternalBridgeHotReloadAsync(
+ HostApplicationBuilder builder,
+ BridgeMode modes,
+ string pubSubEndpoint,
+ CancellationToken cancellationToken)
+ {
+ ITelemetryContext telemetry = DefaultTelemetry.Create(logging => logging.AddConsole());
+ string configFile = Path.Combine(AppContext.BaseDirectory, "pubsub-config.xml");
+ var store = new XmlPubSubConfigurationStore(configFile, telemetry, watchForChanges: true);
+ try
+ {
+ await store.SaveAsync(
+ ExternalServerPubSubConfiguration.BuildConfiguration(modes, pubSubEndpoint),
+ cancellationToken).ConfigureAwait(false);
+
+ builder.Services.AddOpcUa().AddPubSub(pubsub =>
+ {
+ IPubSubBuilder bridge = pubsub;
+ if (modes.HasFlag(BridgeMode.Publisher))
+ {
+ bridge = bridge.AddPublisher();
+ }
+ if (modes.HasFlag(BridgeMode.Subscriber) || modes.HasFlag(BridgeMode.Responder))
+ {
+ bridge = bridge.AddSubscriber();
+ }
+
+ bridge = bridge
+ .AddUdpTransport()
+ .ConfigureApplication(app => app.WithApplicationId(
+ "urn:opcfoundation:ConsoleReferencePubSubClient:ExternalBridge"))
+ // WithConfigurationStore registers this externally-created singleton instance.
+ // The sample disposes it after the host stops instead of relying on the container.
+ .WithConfigurationStore(store);
+
+ if (modes.HasFlag(BridgeMode.Publisher))
+ {
+ bridge = bridge.AddServerAsPublisher(
+ ExternalPublisherOptionsName,
+ builder.Configuration.GetSection(ExternalPublisherOptionsName));
+ }
+ if (modes.HasFlag(BridgeMode.Subscriber))
+ {
+ bridge = bridge.AddServerAsSubscriber(
+ ExternalSubscriberOptionsName,
+ builder.Configuration.GetSection(ExternalSubscriberOptionsName));
+ }
+ if (modes.HasFlag(BridgeMode.Responder))
+ {
+ bridge.AddServerAsActionResponder(
+ ExternalResponderOptionsName,
+ builder.Configuration.GetSection(ExternalResponderOptionsName));
+ }
+ });
+
+ if (modes.HasFlag(BridgeMode.Responder))
+ {
+ builder.Services.Configure(
+ ExternalResponderOptionsName,
+ options =>
+ {
+ options.MethodMap.Add(
+ "ResetCounters",
+ NodeId.Parse("ns=2;s=Demo.External.Methods"),
+ NodeId.Parse("ns=2;s=Demo.External.ResetCounters"));
+ options.Targets.Add(new PubSubActionTarget
+ {
+ DataSetWriterId = 1,
+ ActionName = "ResetCounters"
+ });
+ });
+ }
+
+ return (configFile, store);
+ }
+ catch
+ {
+ store.Dispose();
+ throw;
+ }
+ }
+
+ private static bool TryParsePublisherProfile(string? text, out PublisherProfile profile)
+ {
+ switch (text)
+ {
+ case "udp-uadp":
+ profile = PublisherProfile.UdpUadp;
+ return true;
+ case "mqtt-uadp":
+ profile = PublisherProfile.MqttUadp;
+ return true;
+ case "mqtt-json":
+ profile = PublisherProfile.MqttJson;
+ return true;
+ case "eth-uadp":
+ profile = PublisherProfile.EthUadp;
+ return true;
+ default:
+ profile = PublisherProfile.UdpUadp;
+ return false;
+ }
+ }
+
+ private static bool TryParseSubscriberProfile(string? text, out SubscriberProfile profile)
+ {
+ switch (text)
+ {
+ case "udp-uadp":
+ profile = SubscriberProfile.UdpUadp;
+ return true;
+ case "mqtt-uadp":
+ profile = SubscriberProfile.MqttUadp;
+ return true;
+ case "mqtt-json":
+ profile = SubscriberProfile.MqttJson;
+ return true;
+ case "eth-uadp":
+ profile = SubscriberProfile.EthUadp;
+ return true;
+ default:
+ profile = SubscriberProfile.UdpUadp;
+ return false;
+ }
+ }
+
+ private static bool TryParseBridgeMode(string? text, out BridgeMode mode)
+ {
+ mode = BridgeMode.None;
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ mode = BridgeMode.Publisher;
+ return true;
+ }
+
+ string[] tokens = text.Split(
+ [',', '+'],
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (tokens.Length == 0)
+ {
+ mode = BridgeMode.Publisher;
+ return true;
+ }
+
+ foreach (string token in tokens)
+ {
+ switch (token)
+ {
+ case "publisher":
+ mode |= BridgeMode.Publisher;
+ break;
+ case "subscriber":
+ mode |= BridgeMode.Subscriber;
+ break;
+ case "responder":
+ mode |= BridgeMode.Responder;
+ break;
+ default:
+ mode = BridgeMode.Publisher;
+ return false;
+ }
+ }
+
+ return mode != BridgeMode.None;
+ }
+
+ private static bool TryParseReadMode(string? text, out ReadMode readMode)
+ {
+ switch (text)
+ {
+ case "cyclic":
+ readMode = ReadMode.Cyclic;
+ return true;
+ case "subscription":
+ readMode = ReadMode.Subscription;
+ return true;
+ default:
+ readMode = ReadMode.Cyclic;
+ return false;
+ }
+ }
+
+ private static bool TryParseAffinity(string? text, out SubscriptionAffinity affinity)
+ {
+ switch (text)
+ {
+ case "writergroup":
+ affinity = SubscriptionAffinity.WriterGroup;
+ return true;
+ case "datasetwriter":
+ affinity = SubscriptionAffinity.DataSetWriter;
+ return true;
+ default:
+ affinity = SubscriptionAffinity.WriterGroup;
+ return false;
+ }
+ }
+ }
+
+ ///
+ /// Publisher transport/message profile selected via publisher --profile.
+ ///
+ public enum PublisherProfile
+ {
+ ///
+ /// UDP transport with UADP message mapping.
+ ///
+ UdpUadp = 0,
+
+ ///
+ /// MQTT broker transport with UADP message mapping.
+ ///
+ MqttUadp = 1,
+
+ ///
+ /// MQTT broker transport with JSON message mapping.
+ ///
+ MqttJson = 2,
+
+ ///
+ /// Ethernet (Layer 2) transport with UADP message mapping.
+ ///
+ EthUadp = 3
+ }
+
+ ///
+ /// Subscriber transport/message profile selected via subscriber --profile.
+ ///
+ public enum SubscriberProfile
+ {
+ ///
+ /// UDP transport with UADP message mapping.
+ ///
+ UdpUadp = 0,
+
+ ///
+ /// MQTT broker transport with UADP message mapping.
+ ///
+ MqttUadp = 1,
+
+ ///
+ /// MQTT broker transport with JSON message mapping.
+ ///
+ MqttJson = 2,
+
+ ///
+ /// Ethernet (Layer 2) transport with UADP message mapping.
+ ///
+ EthUadp = 3
+ }
+
+ ///
+ /// The external-server adapter direction selected via external --mode.
+ ///
+ [Flags]
+ public enum BridgeMode
+ {
+ ///
+ /// No external bridge direction selected.
+ ///
+ None = 0,
+
+ ///
+ /// Read an external server and publish its data over PubSub.
+ ///
+ Publisher = 1,
+
+ ///
+ /// Receive PubSub data and write it back to an external server.
+ ///
+ Subscriber = 2,
+
+ ///
+ /// Map an inbound PubSub Action to an external server method call.
+ ///
+ Responder = 4
+ }
+}
diff --git a/Applications/ConsoleReferencePubSubClient/Properties/AssemblyInfo.cs b/Applications/ConsoleReferencePubSubClient/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..7798c9bd57
--- /dev/null
+++ b/Applications/ConsoleReferencePubSubClient/Properties/AssemblyInfo.cs
@@ -0,0 +1,32 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+
+[assembly: CLSCompliant(false)]
diff --git a/Applications/ConsoleReferencePubSubClient/PublisherConfigurationBuilder.cs b/Applications/ConsoleReferencePubSubClient/PublisherConfigurationBuilder.cs
new file mode 100644
index 0000000000..81b0997e9a
--- /dev/null
+++ b/Applications/ConsoleReferencePubSubClient/PublisherConfigurationBuilder.cs
@@ -0,0 +1,185 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using Opc.Ua;
+using Opc.Ua.PubSub.Configuration;
+using Opc.Ua.PubSub.Eth;
+
+namespace Quickstarts.ConsoleReferencePubSubClient
+{
+ ///
+ /// Builds minimal Part 14
+ /// payloads for the three demo wire profiles using the fluent
+ /// . The payloads use the
+ /// "Simple" DataSet exposed by
+ /// (BoolToggle, Int32 counter, DateTime).
+ ///
+ public static class PublisherConfigurationBuilder
+ {
+ public const string DataSetName = "Simple";
+ public const string DefaultUdpEndpoint = "opc.udp://239.0.0.1:4840";
+ public const string DefaultEthEndpoint = "opc.eth://01-00-5E-7F-00-01";
+ public const string DefaultMqttEndpoint = "mqtt://localhost:1883";
+ private const string MqttQueueName = "Quickstarts/Reference/Simple";
+
+ public static string DefaultEndpointFor(PublisherProfile profile)
+ {
+ return profile switch
+ {
+ PublisherProfile.UdpUadp => DefaultUdpEndpoint,
+ PublisherProfile.EthUadp => DefaultEthEndpoint,
+ _ => DefaultMqttEndpoint
+ };
+ }
+
+ public static PubSubConfigurationDataType Build(
+ PublisherProfile profile,
+ string endpoint,
+ ushort publisherId,
+ ushort writerGroupId,
+ ushort dataSetWriterId,
+ int intervalMs)
+ {
+ // UDP and Ethernet are datagram transports (no broker queue);
+ // the MQTT profiles use broker transport settings instead.
+ bool udp = profile is PublisherProfile.UdpUadp or PublisherProfile.EthUadp;
+
+ // UADP message security (SignAndEncrypt) is wired for the UADP
+ // profiles via the shared StaticSecurityKeyProvider. The JSON
+ // profile has no UADP security wrapper, so it stays unsecured.
+ bool secured = profile != PublisherProfile.MqttJson;
+
+ string transportProfileUri = profile switch
+ {
+ PublisherProfile.UdpUadp => Profiles.PubSubUdpUadpTransport,
+ PublisherProfile.EthUadp => EthProfiles.PubSubEthUadpTransport,
+ PublisherProfile.MqttUadp => Profiles.PubSubMqttUadpTransport,
+ PublisherProfile.MqttJson => Profiles.PubSubMqttJsonTransport,
+ _ => throw new ArgumentOutOfRangeException(nameof(profile))
+ };
+
+ return PubSubConfigurationBuilder.Create()
+ .AddPublishedDataSet(DataSetName, ds => ds
+ .AddField("BoolToggle", (byte)DataTypes.Boolean, DataTypeIds.Boolean)
+ .AddField("Int32", (byte)DataTypes.Int32, DataTypeIds.Int32)
+ .AddField("DateTime", (byte)DataTypes.DateTime, DataTypeIds.DateTime))
+ .AddConnection("Publisher Connection", connection => connection
+ .WithPublisherId(new Variant(publisherId))
+ .WithTransportProfile(transportProfileUri)
+ .WithAddress(endpoint)
+ .AddWriterGroup("WriterGroup 1", group =>
+ {
+ group
+ .WithWriterGroupId(writerGroupId)
+ .WithPublishingInterval(intervalMs)
+ .WithMessageSettings(WriterGroupMessageSettings(profile))
+ .WithTransportSettings(udp
+ ? new DatagramWriterGroupTransportDataType()
+ : new BrokerWriterGroupTransportDataType
+ {
+ QueueName = MqttQueueName
+ });
+ if (secured)
+ {
+ group.WithSecurity(
+ MessageSecurityMode.SignAndEncrypt,
+ SampleSecurity.SecurityGroupId,
+ SampleSecurity.SecurityKeyServiceUrl);
+ }
+ group.AddDataSetWriter("Writer 1", writer =>
+ {
+ writer
+ .WithDataSetWriterId(dataSetWriterId)
+ .WithDataSetName(DataSetName)
+ .WithKeyFrameCount(1)
+ .WithFieldContentMask(DataSetFieldContentMask.RawData)
+ .WithMessageSettings(WriterMessageSettings(profile));
+ if (!udp)
+ {
+ writer.WithTransportSettings(
+ new BrokerDataSetWriterTransportDataType
+ {
+ QueueName = MqttQueueName,
+ RequestedDeliveryGuarantee
+ = BrokerTransportQualityOfService.BestEffort
+ });
+ }
+ });
+ }))
+ .Build();
+ }
+
+ private static IEncodeable WriterGroupMessageSettings(PublisherProfile profile)
+ {
+ if (profile == PublisherProfile.MqttJson)
+ {
+ return new JsonWriterGroupMessageDataType
+ {
+ NetworkMessageContentMask = (uint)(
+ JsonNetworkMessageContentMask.NetworkMessageHeader |
+ JsonNetworkMessageContentMask.DataSetMessageHeader |
+ JsonNetworkMessageContentMask.PublisherId)
+ };
+ }
+ return new UadpWriterGroupMessageDataType
+ {
+ DataSetOrdering = DataSetOrderingType.AscendingWriterId,
+ NetworkMessageContentMask = (uint)(
+ UadpNetworkMessageContentMask.PublisherId |
+ UadpNetworkMessageContentMask.GroupHeader |
+ UadpNetworkMessageContentMask.WriterGroupId |
+ UadpNetworkMessageContentMask.PayloadHeader |
+ UadpNetworkMessageContentMask.NetworkMessageNumber |
+ UadpNetworkMessageContentMask.SequenceNumber)
+ };
+ }
+
+ private static IEncodeable WriterMessageSettings(PublisherProfile profile)
+ {
+ if (profile == PublisherProfile.MqttJson)
+ {
+ return new JsonDataSetWriterMessageDataType
+ {
+ DataSetMessageContentMask = (uint)(
+ JsonDataSetMessageContentMask.DataSetWriterId |
+ JsonDataSetMessageContentMask.SequenceNumber |
+ JsonDataSetMessageContentMask.Status |
+ JsonDataSetMessageContentMask.Timestamp)
+ };
+ }
+ return new UadpDataSetWriterMessageDataType
+ {
+ DataSetMessageContentMask = (uint)(
+ UadpDataSetMessageContentMask.Status |
+ UadpDataSetMessageContentMask.SequenceNumber)
+ };
+ }
+ }
+}
diff --git a/Applications/ConsoleReferencePubSubClient/README.md b/Applications/ConsoleReferencePubSubClient/README.md
new file mode 100644
index 0000000000..286bf25014
--- /dev/null
+++ b/Applications/ConsoleReferencePubSubClient/README.md
@@ -0,0 +1,98 @@
+# Console Reference PubSub
+
+A single, self-contained OPC UA **Part 14 PubSub** reference application built on the
+fluent `PubSubApplicationBuilder` + dependency injection + .NET Generic Host surface.
+One executable exposes three command-line-selectable **modes**, and publishes as a
+NativeAOT-ready single-file executable.
+
+## Modes
+
+```
+ConsoleReferencePubSubClient [options]
+```
+
+| Mode | Purpose |
+| ---- | ------- |
+| `publisher` | Publishes a built-in sample DataSet over UDP/UADP or MQTT (UADP/JSON). |
+| `subscriber` | Receives DataSets and logs each decoded message to the console. |
+| `external` | Bridges an **external** OPC UA server to PubSub via the `Opc.Ua.PubSub.Adapter` library (publisher / subscriber / responder direction). |
+
+### `publisher`
+
+```bash
+ConsoleReferencePubSubClient publisher --profile udp-uadp --interval 1000
+ConsoleReferencePubSubClient publisher --profile mqtt-json --endpoint mqtt://localhost:1883
+```
+
+Options: `--profile udp-uadp|mqtt-uadp|mqtt-json`, `--config-file `,
+`--publisher-id`, `--writer-group-id`, `--data-set-writer-id`, `--endpoint`, `--interval`.
+
+### `subscriber`
+
+```bash
+ConsoleReferencePubSubClient subscriber --profile udp-uadp
+```
+
+Options: `--profile`, `--config-file `, `--publisher-id-filter`,
+`--writer-group-id-filter`, `--data-set-writer-id-filter`, `--endpoint`.
+
+### `external`
+
+Bridges an external OPC UA server (defaults to the repository's ConsoleReferenceServer
+at `opc.tcp://localhost:62541/Quickstarts/ReferenceServer`; override with `--endpoint` or
+the `OPCUA_EXTERNAL_ENDPOINT` environment variable).
+
+```bash
+# Read an external server and publish its values (cyclic Read each cycle)
+ConsoleReferencePubSubClient external --mode publisher --read-mode cyclic
+
+# Read via a client Subscription cache, one subscription per WriterGroup
+ConsoleReferencePubSubClient external --mode publisher --read-mode subscription --affinity writergroup
+
+# Write received PubSub values back to an external server
+ConsoleReferencePubSubClient external --mode subscriber
+
+# Map an inbound PubSub Action to an external server method call
+ConsoleReferencePubSubClient external --mode responder
+
+# Run a bidirectional bridge in one process
+ConsoleReferencePubSubClient external --mode publisher,subscriber
+```
+
+Options: `--mode publisher|subscriber|responder` (comma-separated list accepted),
+`--read-mode cyclic|subscription`, `--affinity writergroup|datasetwriter`,
+`--endpoint `, `--pubsub-endpoint `, `--hot-reload`.
+
+#### Hot reload
+
+Add `--hot-reload` to the `external` bridge to run the opt-in live reconfiguration
+demo:
+
+```bash
+ConsoleReferencePubSubClient external --mode publisher,subscriber --hot-reload
+```
+
+The bridge writes `pubsub-config.xml` next to the executable before starting and
+loads adapter options from the copied `appsettings.json` in the same directory.
+Edit and save either file while the bridge is running:
+
+- `appsettings.json`: change `ExternalPublisher:ReadMode` from `Cyclic` to
+ `Subscription`, or change `ExternalPublisher:Affinity` to `DataSetWriter`, to
+ rewire the external-server publisher options.
+- `pubsub-config.xml`: add or remove a `DataSetWriter` to change the PubSub
+ topology. The watching XML store raises a configuration change and the adapter
+ incrementally rewires the affected binding.
+
+> The samples connect to the external server unsecured (`SecurityMode.None`) for
+> zero-config interop. A production bridge must use `SignAndEncrypt` with a provisioned
+> application instance certificate. See
+> [Docs/PubSub.md external-server adapter section](../../Docs/PubSub.md#binding-pubsub-to-an-external-opc-ua-server-client-session-adapters).
+
+## Build / publish
+
+```bash
+dotnet build Applications/ConsoleReferencePubSubClient/ConsoleReferencePubSubClient.csproj
+dotnet publish Applications/ConsoleReferencePubSubClient/ConsoleReferencePubSubClient.csproj -r win-x64
+```
+
+See [Docs/PubSub.md](../../Docs/PubSub.md) for the full PubSub guide.
diff --git a/Applications/ConsoleReferencePubSubClient/SampleDataSetSource.cs b/Applications/ConsoleReferencePubSubClient/SampleDataSetSource.cs
new file mode 100644
index 0000000000..e5e29d305a
--- /dev/null
+++ b/Applications/ConsoleReferencePubSubClient/SampleDataSetSource.cs
@@ -0,0 +1,125 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua;
+using Opc.Ua.PubSub.DataSets;
+using Opc.Ua.PubSub.Encoding;
+
+namespace Quickstarts.ConsoleReferencePubSubClient
+{
+ ///
+ /// In-process that mints a
+ /// fresh BoolToggle / Int32 counter / DateTime triple every time
+ /// the runtime samples the "Simple" DataSet. Demonstrates the
+ /// Part 14 §6.2.3 pluggable data-source extension point.
+ ///
+ public sealed class SampleDataSetSource : IPublishedDataSetSource
+ {
+ private long m_counter;
+
+ public DataSetMetaDataType BuildMetaData()
+ {
+ return new DataSetMetaDataType
+ {
+ Name = PublisherConfigurationBuilder.DataSetName,
+ DataSetClassId = Uuid.Empty,
+ Fields = new ArrayOf(new[]
+ {
+ new FieldMetaData
+ {
+ Name = "BoolToggle",
+ BuiltInType = (byte)DataTypes.Boolean,
+ DataType = DataTypeIds.Boolean,
+ ValueRank = ValueRanks.Scalar
+ },
+ new FieldMetaData
+ {
+ Name = "Int32",
+ BuiltInType = (byte)DataTypes.Int32,
+ DataType = DataTypeIds.Int32,
+ ValueRank = ValueRanks.Scalar
+ },
+ new FieldMetaData
+ {
+ Name = "DateTime",
+ BuiltInType = (byte)DataTypes.DateTime,
+ DataType = DataTypeIds.DateTime,
+ ValueRank = ValueRanks.Scalar
+ }
+ }),
+ ConfigurationVersion = new ConfigurationVersionDataType
+ {
+ MajorVersion = 1,
+ MinorVersion = 0
+ }
+ };
+ }
+
+ public ValueTask SampleAsync(
+ DataSetMetaDataType metaData,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ long counter = Interlocked.Increment(ref m_counter);
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ var fields = new List
+ {
+ new()
+ {
+ Name = "BoolToggle",
+ Value = new Variant((counter & 1) == 0)
+ },
+ new()
+ {
+ Name = "Int32",
+ Value = new Variant((int)counter)
+ },
+ new()
+ {
+ Name = "DateTime",
+ Value = new Variant(now.UtcDateTime)
+ }
+ };
+ ConfigurationVersionDataType version =
+ metaData?.ConfigurationVersion
+ ?? new ConfigurationVersionDataType
+ {
+ MajorVersion = 1,
+ MinorVersion = 0
+ };
+ return new ValueTask(
+ new PublishedDataSetSnapshot(
+ version, fields, DateTimeUtc.From(now)));
+ }
+ }
+}
diff --git a/Applications/ConsoleReferencePubSubClient/SampleSecurity.cs b/Applications/ConsoleReferencePubSubClient/SampleSecurity.cs
new file mode 100644
index 0000000000..f28e5f09ca
--- /dev/null
+++ b/Applications/ConsoleReferencePubSubClient/SampleSecurity.cs
@@ -0,0 +1,122 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using Opc.Ua;
+using Opc.Ua.PubSub.Security;
+
+namespace Quickstarts.ConsoleReferencePubSubClient
+{
+ ///
+ /// Demo-only shared symmetric key material wiring the reference
+ /// publisher and subscriber for SignAndEncrypt over the
+ /// PubSub-Aes256-CTR policy without an external Security Key
+ /// Service. The publisher and subscriber both build an identical
+ /// from the constants below.
+ ///
+ ///
+ /// The fixed keys here are a sample convenience ONLY. Production
+ /// deployments must source keys from a real SKS (see
+ ///
+ /// Part 14 §8.3) and never embed key material in source.
+ ///
+ public static class SampleSecurity
+ {
+ ///
+ /// SecurityGroupId shared by the demo publisher and subscriber.
+ ///
+ public const string SecurityGroupId = "DemoSecurityGroup";
+
+ ///
+ /// Placeholder Security Key Service endpoint URL. The demo sources
+ /// keys from a local , but
+ /// the configuration validator requires a secured group to declare
+ /// at least one SecurityKeyService endpoint per
+ ///
+ /// Part 14 §6.2.5.4.
+ ///
+ public const string SecurityKeyServiceUrl = "opc.tcp://localhost:4840/SecurityKeyService";
+
+ private const uint TokenId = 1U;
+
+ ///
+ /// Builds the shared static key provider. Identical on both the
+ /// publisher and subscriber so secured frames round-trip.
+ ///
+ /// Clock for the key ring.
+ /// A configured key provider.
+ public static IPubSubSecurityKeyProvider CreateKeyProvider(
+ TimeProvider? timeProvider = null)
+ {
+ byte[] signingKey = BuildKey(0x10, 32);
+ byte[] encryptingKey = BuildKey(0x20, 32);
+ byte[] keyNonce = BuildKey(0x30, 12);
+
+ PubSubSecurityKey? key = null;
+ PubSubSecurityKeyRing? ring = null;
+ try
+ {
+ key = new PubSubSecurityKey(
+ TokenId,
+ ByteString.Create(signingKey),
+ ByteString.Create(encryptingKey),
+ ByteString.Create(keyNonce),
+ DateTimeUtc.From(DateTime.UtcNow),
+ TimeSpan.FromHours(24));
+
+ ring = new PubSubSecurityKeyRing(SecurityGroupId, timeProvider);
+ ring.SetCurrent(key);
+ // Ownership of the key transfers to the ring; null out the
+ // local so it is not disposed here on the success path.
+ key = null;
+
+ var provider = new StaticSecurityKeyProvider(SecurityGroupId, ring);
+ // Ownership of the ring transfers to the returned provider,
+ // which lives for the application lifetime.
+ ring = null;
+ return provider;
+ }
+ finally
+ {
+ ring?.Dispose();
+ key?.Dispose();
+ }
+ }
+
+ private static byte[] BuildKey(byte seed, int length)
+ {
+ byte[] bytes = new byte[length];
+ for (int i = 0; i < length; i++)
+ {
+ bytes[i] = (byte)((seed + (i * 7)) & 0xFF);
+ }
+ return bytes;
+ }
+ }
+}
diff --git a/Applications/ConsoleReferencePubSubClient/SubscriberConfigurationBuilder.cs b/Applications/ConsoleReferencePubSubClient/SubscriberConfigurationBuilder.cs
new file mode 100644
index 0000000000..fa600d8bdd
--- /dev/null
+++ b/Applications/ConsoleReferencePubSubClient/SubscriberConfigurationBuilder.cs
@@ -0,0 +1,166 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using Opc.Ua;
+using Opc.Ua.PubSub.Configuration;
+using Opc.Ua.PubSub.Eth;
+
+namespace Quickstarts.ConsoleReferencePubSubClient
+{
+ ///
+ /// Builds minimal Part 14
+ /// payloads for the three demo wire profiles using the fluent
+ /// . Each payload wires one
+ /// PubSubConnection > ReaderGroup > DataSetReader filtered on
+ /// PublisherId / WriterGroupId / DataSetWriterId.
+ ///
+ public static class SubscriberConfigurationBuilder
+ {
+ public const string ReaderName = "Reader 1";
+ public const string DataSetName = "Simple";
+ public const string DefaultUdpEndpoint = "opc.udp://239.0.0.1:4840";
+ public const string DefaultEthEndpoint = "opc.eth://01-00-5E-7F-00-01";
+ public const string DefaultMqttEndpoint = "mqtt://localhost:1883";
+ private const string MqttQueueName = "Quickstarts/Reference/Simple";
+
+ public static string DefaultEndpointFor(SubscriberProfile profile)
+ {
+ return profile switch
+ {
+ SubscriberProfile.UdpUadp => DefaultUdpEndpoint,
+ SubscriberProfile.EthUadp => DefaultEthEndpoint,
+ _ => DefaultMqttEndpoint
+ };
+ }
+
+ public static PubSubConfigurationDataType Build(
+ SubscriberProfile profile,
+ string endpoint,
+ ushort publisherIdFilter,
+ ushort writerGroupIdFilter,
+ ushort dataSetWriterIdFilter)
+ {
+ // UDP and Ethernet are datagram transports (no broker queue);
+ // the MQTT profiles use broker transport settings instead.
+ bool udp = profile is SubscriberProfile.UdpUadp or SubscriberProfile.EthUadp;
+
+ // UADP message security (SignAndEncrypt) is wired for the UADP
+ // profiles via the shared StaticSecurityKeyProvider. The JSON
+ // profile has no UADP security wrapper, so it stays unsecured.
+ bool secured = profile != SubscriberProfile.MqttJson;
+
+ string transportProfileUri = profile switch
+ {
+ SubscriberProfile.UdpUadp => Profiles.PubSubUdpUadpTransport,
+ SubscriberProfile.EthUadp => EthProfiles.PubSubEthUadpTransport,
+ SubscriberProfile.MqttUadp => Profiles.PubSubMqttUadpTransport,
+ SubscriberProfile.MqttJson => Profiles.PubSubMqttJsonTransport,
+ _ => throw new ArgumentOutOfRangeException(nameof(profile))
+ };
+
+ return PubSubConfigurationBuilder.Create()
+ .AddConnection("Subscriber Connection", connection => connection
+ .WithPublisherId(new Variant(publisherIdFilter))
+ .WithTransportProfile(transportProfileUri)
+ .WithAddress(endpoint)
+ .AddReaderGroup("ReaderGroup 1", group =>
+ {
+ group.WithMaxNetworkMessageSize(1500);
+ if (secured)
+ {
+ group.WithSecurity(
+ MessageSecurityMode.SignAndEncrypt,
+ SampleSecurity.SecurityGroupId,
+ SampleSecurity.SecurityKeyServiceUrl);
+ }
+ group.AddDataSetReader(ReaderName, reader =>
+ {
+ reader
+ .WithFilter(
+ new Variant(publisherIdFilter),
+ writerGroupIdFilter,
+ dataSetWriterIdFilter)
+ .WithFieldContentMask(DataSetFieldContentMask.RawData)
+ .WithMessageReceiveTimeout(5000)
+ .WithMessageSettings(ReaderMessageSettings(profile))
+ .WithMirrorSubscribedDataSet(ReaderName)
+ .WithDataSetMetaData(DataSetName, metaData => metaData
+ .WithoutFieldIds()
+ .AddField("BoolToggle", (byte)DataTypes.Boolean, DataTypeIds.Boolean)
+ .AddField("Int32", (byte)DataTypes.Int32, DataTypeIds.Int32)
+ .AddField("DateTime", (byte)DataTypes.DateTime, DataTypeIds.DateTime));
+ if (!udp)
+ {
+ reader.WithTransportSettings(
+ new BrokerDataSetReaderTransportDataType
+ {
+ QueueName = MqttQueueName,
+ RequestedDeliveryGuarantee
+ = BrokerTransportQualityOfService.BestEffort
+ });
+ }
+ });
+ }))
+ .Build();
+ }
+
+ private static IEncodeable ReaderMessageSettings(SubscriberProfile profile)
+ {
+ if (profile == SubscriberProfile.MqttJson)
+ {
+ return new JsonDataSetReaderMessageDataType
+ {
+ NetworkMessageContentMask = (uint)(
+ JsonNetworkMessageContentMask.NetworkMessageHeader |
+ JsonNetworkMessageContentMask.DataSetMessageHeader |
+ JsonNetworkMessageContentMask.PublisherId),
+ DataSetMessageContentMask = (uint)(
+ JsonDataSetMessageContentMask.DataSetWriterId |
+ JsonDataSetMessageContentMask.SequenceNumber |
+ JsonDataSetMessageContentMask.Status |
+ JsonDataSetMessageContentMask.Timestamp)
+ };
+ }
+ return new UadpDataSetReaderMessageDataType
+ {
+ NetworkMessageContentMask = (uint)(
+ UadpNetworkMessageContentMask.PublisherId |
+ UadpNetworkMessageContentMask.GroupHeader |
+ UadpNetworkMessageContentMask.WriterGroupId |
+ UadpNetworkMessageContentMask.PayloadHeader |
+ UadpNetworkMessageContentMask.NetworkMessageNumber |
+ UadpNetworkMessageContentMask.SequenceNumber),
+ DataSetMessageContentMask = (uint)(
+ UadpDataSetMessageContentMask.Status |
+ UadpDataSetMessageContentMask.SequenceNumber)
+ };
+ }
+ }
+}
diff --git a/Applications/ConsoleReferencePubSubClient/appsettings.json b/Applications/ConsoleReferencePubSubClient/appsettings.json
new file mode 100644
index 0000000000..598c0f08ae
--- /dev/null
+++ b/Applications/ConsoleReferencePubSubClient/appsettings.json
@@ -0,0 +1,23 @@
+{
+ "ExternalPublisher": {
+ "Connection": {
+ "EndpointUrl": "opc.tcp://localhost:62541/Quickstarts/ReferenceServer",
+ "SecurityMode": "None"
+ },
+ "ReadMode": "Cyclic",
+ "Affinity": "WriterGroup"
+ },
+ "ExternalSubscriber": {
+ "Connection": {
+ "EndpointUrl": "opc.tcp://localhost:62541/Quickstarts/ReferenceServer",
+ "SecurityMode": "None"
+ }
+ },
+ "ExternalResponder": {
+ "Connection": {
+ "EndpointUrl": "opc.tcp://localhost:62541/Quickstarts/ReferenceServer",
+ "SecurityMode": "None"
+ },
+ "AllowUnsecured": true
+ }
+}
diff --git a/Applications/ConsoleReferencePublisher/ConsoleReferencePublisher.csproj b/Applications/ConsoleReferencePublisher/ConsoleReferencePublisher.csproj
deleted file mode 100644
index e87e55de09..0000000000
--- a/Applications/ConsoleReferencePublisher/ConsoleReferencePublisher.csproj
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
- $(AppTargetFrameWorks)
- ConsoleReferencePublisher
- Exe
- ConsoleReferencePublisher
- OPC Foundation
- .NET Console Reference Publisher
- Copyright © 2004-2020 OPC Foundation, Inc
- Quickstarts.ConsoleReferencePublisher
- enable
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Applications/ConsoleReferencePublisher/Program.cs b/Applications/ConsoleReferencePublisher/Program.cs
deleted file mode 100644
index c3282f1b26..0000000000
--- a/Applications/ConsoleReferencePublisher/Program.cs
+++ /dev/null
@@ -1,822 +0,0 @@
-/* ========================================================================
- * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
- *
- * OPC Foundation MIT License 1.00
- *
- * Permission is hereby granted, free of charge, to any person
- * obtaining a copy of this software and associated documentation
- * files (the "Software"), to deal in the Software without
- * restriction, including without limitation the rights to use,
- * copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the
- * Software is furnished to do so, subject to the following
- * conditions:
- *
- * The above copyright notice and this permission notice shall be
- * included in all copies or substantial portions of the Software.
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
- * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
- * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
- * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
- * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
- * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
- * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
- * OTHER DEALINGS IN THE SOFTWARE.
- *
- * The complete license agreement can be found here:
- * http://opcfoundation.org/License/MIT/1.00/
- * ======================================================================*/
-
-using System;
-using System.CommandLine;
-using System.Threading;
-using Opc.Ua;
-using Opc.Ua.PubSub;
-using Opc.Ua.PubSub.Configuration;
-using Opc.Ua.PubSub.Transport;
-
-namespace Quickstarts.ConsoleReferencePublisher
-{
- public static class Program
- {
- ///
- /// constant DateTime that represents the initial time when the metadata
- /// for the configuration was created
- ///
- private static readonly DateTime s_timeOfConfiguration = new(
- 2021,
- 6,
- 1,
- 0,
- 0,
- 0,
- DateTimeKind.Utc);
-
- public static void Main(string[] args)
- {
- Console.WriteLine("OPC UA Console Reference Publisher");
-
- // command line options
- var mqttJsonOption = new Option("--mqtt_json", "-m") { Description = "Use MQTT with Json encoding Profile. This is the default option." };
- var mqttUadpOption = new Option("--mqtt_uadp", "-p") { Description = "Use MQTT with UADP encoding Profile." };
- var udpUadpOption = new Option("--udp_uadp", "-u") { Description = "Use UDP with UADP encoding Profile" };
- var publisherUrlOption = new Option("--publisher_url", "--url") { Description = "Publisher Url Address" };
-
- var rootCommand = new RootCommand("OPC UA Console Reference Publisher")
- {
- mqttJsonOption,
- mqttUadpOption,
- udpUadpOption,
- publisherUrlOption
- };
-
- rootCommand.SetAction((parseResult) =>
- {
- bool useMqttUadp = parseResult.GetValue(mqttUadpOption);
- bool useUdpUadp = parseResult.GetValue(udpUadpOption);
- string? publisherUrl = parseResult.GetValue(publisherUrlOption);
-
- try
- {
- var telemetry = new ConsoleTelemetry();
-
- PubSubConfigurationDataType? pubSubConfiguration = null;
- if (useUdpUadp)
- {
- // set default UDP Publisher Url to local multi-cast if not sent in args.
- if (string.IsNullOrEmpty(publisherUrl))
- {
- publisherUrl = "opc.udp://239.0.0.1:4840";
- }
-
- // Create configuration using UDP protocol and UADP Encoding
- pubSubConfiguration = CreatePublisherConfiguration_UdpUadp(publisherUrl!);
- Console.WriteLine(
- "The PubSub Connection was initialized using UDP & UADP Profile.");
- }
- else
- {
- // set default MQTT Broker Url to localhost if not sent in args.
- if (string.IsNullOrEmpty(publisherUrl))
- {
- publisherUrl = "mqtt://localhost:1883";
- }
-
- if (useMqttUadp)
- {
- // Create configuration using MQTT protocol and UADP Encoding
- pubSubConfiguration = CreatePublisherConfiguration_MqttUadp(publisherUrl!);
- Console.WriteLine(
- "The PubSub Connection was initialized using MQTT & UADP Profile.");
- }
- else
- {
- // Create configuration using MQTT protocol and JSON Encoding
- pubSubConfiguration = CreatePublisherConfiguration_MqttJson(publisherUrl!);
- Console.WriteLine(
- "The PubSub Connection was initialized using MQTT & JSON Profile.");
- }
- }
-
- // Create the UA Publisher application using configuration file
- using (var uaPubSubApplication = UaPubSubApplication.Create(pubSubConfiguration, telemetry))
- {
- // Start values simulator
- var valuesSimulator = new PublishedValuesWrites(uaPubSubApplication, telemetry);
- valuesSimulator.Start();
-
- // Start the publisher
- uaPubSubApplication.Start();
-
- Console.WriteLine("Publisher Started. Press Ctrl-C to exit...");
-
- var quitEvent = new ManualResetEvent(false);
-
- Console.CancelKeyPress += (sender, eArgs) =>
- {
- quitEvent.Set();
- eArgs.Cancel = true;
- };
-
- // wait for timeout or Ctrl-C
- quitEvent.WaitOne();
- }
-
- Console.WriteLine("Program ended.");
- Console.WriteLine("Press any key to finish...");
- Console.ReadKey();
- }
- catch (Exception ex)
- {
- Console.WriteLine(ex.Message);
- }
- });
-
- ParseResult parseResult = rootCommand.Parse(args);
- parseResult.Invoke(new InvocationConfiguration());
- }
-
- ///
- /// Creates a PubSubConfiguration object for UDP & UADP programmatically.
- ///
- ///
- private static PubSubConfigurationDataType CreatePublisherConfiguration_UdpUadp(
- string urlAddress)
- {
- // Define a PubSub connection with PublisherId 1
- var pubSubConnection1 = new PubSubConnectionDataType
- {
- Name = "Publisher Connection UDP UADP",
- Enabled = true,
- PublisherId = (ushort)1,
- TransportProfileUri = Profiles.PubSubUdpUadpTransport
- };
- var address = new NetworkAddressUrlDataType
- {
- // Specify the local Network interface name to be used
- // e.g. address.NetworkInterface = "Ethernet";
- // Leave empty to publish on all available local interfaces.
- NetworkInterface = string.Empty,
- Url = urlAddress
- };
- pubSubConnection1.Address = new ExtensionObject(address);
-
- // configure custom DiscoveryAddress for Discovery messages
- pubSubConnection1.TransportSettings = new ExtensionObject(
- new DatagramConnectionTransportDataType
- {
- DiscoveryAddress = new ExtensionObject(
- new NetworkAddressUrlDataType
- {
- Url = "opc.udp://224.0.2.15:4840"
- })
- });
-
- var writerGroup1 = new WriterGroupDataType
- {
- Name = "WriterGroup 1",
- Enabled = true,
- WriterGroupId = 1,
- PublishingInterval = 5000,
- KeepAliveTime = 5000,
- MaxNetworkMessageSize = 1500,
- HeaderLayoutUri = "UADP-Cyclic-Fixed"
- };
- var uadpMessageSettings = new UadpWriterGroupMessageDataType
- {
- DataSetOrdering = DataSetOrderingType.AscendingWriterId,
- GroupVersion = 0,
- NetworkMessageContentMask = (uint)(
- UadpNetworkMessageContentMask.PublisherId |
- UadpNetworkMessageContentMask.GroupHeader |
- // needed to be able to decode the DataSetWriterId
- UadpNetworkMessageContentMask.PayloadHeader |
- UadpNetworkMessageContentMask.WriterGroupId |
- UadpNetworkMessageContentMask.GroupVersion |
- UadpNetworkMessageContentMask.NetworkMessageNumber |
- UadpNetworkMessageContentMask.SequenceNumber
- )
- };
-
- writerGroup1.MessageSettings = new ExtensionObject(uadpMessageSettings);
- // initialize Datagram (UDP) Transport Settings
- writerGroup1.TransportSettings = new ExtensionObject(
- new DatagramWriterGroupTransportDataType());
-
- // Define DataSetWriter 'Simple'
- var dataSetWriter1 = new DataSetWriterDataType
- {
- Name = "Writer 1",
- DataSetWriterId = 1,
- Enabled = true,
- DataSetFieldContentMask = (uint)DataSetFieldContentMask.RawData,
- DataSetName = "Simple",
- KeyFrameCount = 1
- };
- var uadpDataSetWriterMessage = new UadpDataSetWriterMessageDataType
- {
- NetworkMessageNumber = 1,
- DataSetMessageContentMask = (uint)(
- UadpDataSetMessageContentMask.Status |
- UadpDataSetMessageContentMask.SequenceNumber
- )
- };
-
- dataSetWriter1.MessageSettings = new ExtensionObject(uadpDataSetWriterMessage);
- writerGroup1.DataSetWriters = writerGroup1.DataSetWriters.AddItem(dataSetWriter1);
-
- // Define DataSetWriter 'AllTypes'
- var dataSetWriter2 = new DataSetWriterDataType
- {
- Name = "Writer 2",
- DataSetWriterId = 2,
- Enabled = true,
- DataSetFieldContentMask = (uint)DataSetFieldContentMask.RawData,
- DataSetName = "AllTypes",
- KeyFrameCount = 1
- };
- uadpDataSetWriterMessage = new UadpDataSetWriterMessageDataType
- {
- NetworkMessageNumber = 1,
- DataSetMessageContentMask = (uint)(
- UadpDataSetMessageContentMask.Status |
- UadpDataSetMessageContentMask.SequenceNumber
- )
- };
-
- dataSetWriter2.MessageSettings = new ExtensionObject(uadpDataSetWriterMessage);
- writerGroup1.DataSetWriters = writerGroup1.DataSetWriters.AddItem(dataSetWriter2);
- pubSubConnection1.WriterGroups = pubSubConnection1.WriterGroups.AddItem(writerGroup1);
-
- // Define PublishedDataSet Simple
- PublishedDataSetDataType publishedDataSetSimple = CreatePublishedDataSetSimple();
-
- // Define PublishedDataSet AllTypes
- PublishedDataSetDataType publishedDataSetAllTypes = CreatePublishedDataSetAllTypes();
-
- //create the PubSub configuration root object
- return new PubSubConfigurationDataType
- {
- Enabled = true,
- Connections = [pubSubConnection1],
- PublishedDataSets = [publishedDataSetSimple, publishedDataSetAllTypes]
- };
- }
-
- ///
- /// Creates a PubSubConfiguration object for MQTT & Json programmatically.
- ///
- ///
- private static PubSubConfigurationDataType CreatePublisherConfiguration_MqttJson(
- string urlAddress)
- {
- // Define a PubSub connection with PublisherId 2
- var pubSubConnection1 = new PubSubConnectionDataType
- {
- Name = "Publisher Connection MQTT Json",
- Enabled = true,
- PublisherId = (ushort)2,
- TransportProfileUri = Profiles.PubSubMqttJsonTransport
- };
- var address = new NetworkAddressUrlDataType
- {
- // Specify the local Network interface name to be used
- // e.g. address.NetworkInterface = "Ethernet";
- // Leave empty to publish on all available local interfaces.
- NetworkInterface = string.Empty,
- Url = urlAddress
- };
- pubSubConnection1.Address = new ExtensionObject(address);
-
- // Configure the mqtt specific configuration with the MQTT broker
- var mqttConfiguration = new MqttClientProtocolConfiguration(
- version: EnumMqttProtocolVersion.V500);
- pubSubConnection1.ConnectionProperties = mqttConfiguration.ConnectionProperties;
-
- const string brokerQueueName = "Json_WriterGroup_1";
- const string brokerMetaData = "$Metadata";
-
- var writerGroup1 = new WriterGroupDataType
- {
- Name = "WriterGroup 1",
- Enabled = true,
- WriterGroupId = 1,
- PublishingInterval = 5000,
- KeepAliveTime = 5000,
- MaxNetworkMessageSize = 1500
- };
-
- var jsonMessageSettings = new JsonWriterGroupMessageDataType
- {
- NetworkMessageContentMask = (uint)(
- JsonNetworkMessageContentMask.NetworkMessageHeader |
- JsonNetworkMessageContentMask.DataSetMessageHeader |
- JsonNetworkMessageContentMask.PublisherId |
- JsonNetworkMessageContentMask.DataSetClassId |
- JsonNetworkMessageContentMask.ReplyTo
- )
- };
-
- writerGroup1.MessageSettings = new ExtensionObject(jsonMessageSettings);
- writerGroup1.TransportSettings = new ExtensionObject(
- new BrokerWriterGroupTransportDataType { QueueName = brokerQueueName }
- );
-
- // Define DataSetWriter 'Simple' Variant encoding
- var dataSetWriter1 = new DataSetWriterDataType
- {
- Name = "Writer Variant Encoding",
- DataSetWriterId = 1,
- Enabled = true,
- DataSetFieldContentMask = (uint)DataSetFieldContentMask.None, // Variant encoding;
- DataSetName = "Simple",
- KeyFrameCount = 3
- };
-
- var jsonDataSetWriterMessage = new JsonDataSetWriterMessageDataType
- {
- DataSetMessageContentMask = (uint)(
- JsonDataSetMessageContentMask.DataSetWriterId |
- JsonDataSetMessageContentMask.MetaDataVersion |
- JsonDataSetMessageContentMask.SequenceNumber |
- JsonDataSetMessageContentMask.Status |
- JsonDataSetMessageContentMask.Timestamp
- )
- };
- dataSetWriter1.MessageSettings = new ExtensionObject(jsonDataSetWriterMessage);
-
- var jsonDataSetWriterTransport = new BrokerDataSetWriterTransportDataType
- {
- QueueName = brokerQueueName,
- RequestedDeliveryGuarantee = BrokerTransportQualityOfService.BestEffort,
- MetaDataQueueName = $"{brokerQueueName}/{brokerMetaData}",
- MetaDataUpdateTime = 0
- };
- dataSetWriter1.TransportSettings = new ExtensionObject(jsonDataSetWriterTransport);
-
- writerGroup1.DataSetWriters = writerGroup1.DataSetWriters.AddItem(dataSetWriter1);
-
- // Define DataSetWriter 'Simple' - Variant encoding
- var dataSetWriter2 = new DataSetWriterDataType
- {
- Name = "Writer RawData Encoding",
- DataSetWriterId = 2,
- Enabled = true,
- DataSetFieldContentMask = (uint)DataSetFieldContentMask.RawData,
- DataSetName = "AllTypes",
- KeyFrameCount = 1
- };
-
- jsonDataSetWriterMessage = new JsonDataSetWriterMessageDataType
- {
- DataSetMessageContentMask = (uint)(
- JsonDataSetMessageContentMask.DataSetWriterId |
- JsonDataSetMessageContentMask.MetaDataVersion |
- JsonDataSetMessageContentMask.SequenceNumber |
- JsonDataSetMessageContentMask.Status |
- JsonDataSetMessageContentMask.Timestamp
- )
- };
- dataSetWriter2.MessageSettings = new ExtensionObject(jsonDataSetWriterMessage);
-
- jsonDataSetWriterTransport = new BrokerDataSetWriterTransportDataType
- {
- QueueName = brokerQueueName,
- RequestedDeliveryGuarantee = BrokerTransportQualityOfService.BestEffort,
- MetaDataQueueName = $"{brokerQueueName}/{brokerMetaData}",
- MetaDataUpdateTime = 0
- };
- dataSetWriter2.TransportSettings = new ExtensionObject(jsonDataSetWriterTransport);
-
- writerGroup1.DataSetWriters = writerGroup1.DataSetWriters.AddItem(dataSetWriter2);
- pubSubConnection1.WriterGroups = pubSubConnection1.WriterGroups.AddItem(writerGroup1);
-
- // Define PublishedDataSet Simple
- PublishedDataSetDataType publishedDataSetSimple = CreatePublishedDataSetSimple();
-
- // Define PublishedDataSet AllTypes
- PublishedDataSetDataType publishedDataSetAllTypes = CreatePublishedDataSetAllTypes();
-
- //create the PubSub configuration root object
- return new PubSubConfigurationDataType
- {
- Enabled = true,
- Connections = [pubSubConnection1],
- PublishedDataSets = [publishedDataSetSimple, publishedDataSetAllTypes]
- };
- }
-
- ///
- /// Creates a PubSubConfiguration object for MQTT & UADP programmatically.
- ///
- ///
- private static PubSubConfigurationDataType CreatePublisherConfiguration_MqttUadp(
- string urlAddress)
- {
- // Define a PubSub connection with PublisherId 3
- var pubSubConnection1 = new PubSubConnectionDataType
- {
- Name = "Publisher Connection MQTT UADP",
- Enabled = true,
- PublisherId = (ushort)3,
- TransportProfileUri = Profiles.PubSubMqttUadpTransport
- };
- var address = new NetworkAddressUrlDataType
- {
- // Specify the local Network interface name to be used
- // e.g. address.NetworkInterface = "Ethernet";
- // Leave empty to publish on all available local interfaces.
- NetworkInterface = string.Empty,
- Url = urlAddress
- };
- pubSubConnection1.Address = new ExtensionObject(address);
-
- // Configure the mqtt specific configuration with the MQTTbroker
- var mqttConfiguration = new MqttClientProtocolConfiguration(
- version: EnumMqttProtocolVersion.V500);
- pubSubConnection1.ConnectionProperties = mqttConfiguration.ConnectionProperties;
-
- const string brokerQueueName = "Uadp_WriterGroup_1";
- const string brokerMetaData = "$Metadata";
-
- var writerGroup1 = new WriterGroupDataType
- {
- Name = "WriterGroup 1",
- Enabled = true,
- WriterGroupId = 1,
- PublishingInterval = 5000,
- KeepAliveTime = 5000,
- MaxNetworkMessageSize = 1500,
- HeaderLayoutUri = "UADP-Cyclic-Fixed"
- };
- var uadpMessageSettings = new UadpWriterGroupMessageDataType
- {
- DataSetOrdering = DataSetOrderingType.AscendingWriterId,
- GroupVersion = 0,
- NetworkMessageContentMask = (uint)(
- UadpNetworkMessageContentMask.PublisherId |
- UadpNetworkMessageContentMask.GroupHeader |
- UadpNetworkMessageContentMask.WriterGroupId |
- UadpNetworkMessageContentMask.PayloadHeader |
- UadpNetworkMessageContentMask.GroupVersion |
- UadpNetworkMessageContentMask.NetworkMessageNumber |
- UadpNetworkMessageContentMask.SequenceNumber
- )
- };
-
- writerGroup1.MessageSettings = new ExtensionObject(uadpMessageSettings);
- // initialize Broker transport settings
- writerGroup1.TransportSettings = new ExtensionObject(
- new BrokerWriterGroupTransportDataType { QueueName = brokerQueueName }
- );
-
- // Define DataSetWriter 'Simple'
- var dataSetWriter1 = new DataSetWriterDataType
- {
- Name = "Writer 1",
- DataSetWriterId = 1,
- Enabled = true,
- DataSetFieldContentMask = (uint)DataSetFieldContentMask.RawData,
- DataSetName = "Simple",
- KeyFrameCount = 1
- };
- var uadpDataSetWriterMessage = new UadpDataSetWriterMessageDataType
- {
- ConfiguredSize = 32,
- DataSetOffset = 15,
- NetworkMessageNumber = 1,
- DataSetMessageContentMask = (uint)(
- UadpDataSetMessageContentMask.Status |
- UadpDataSetMessageContentMask.SequenceNumber
- )
- };
-
- dataSetWriter1.MessageSettings = new ExtensionObject(uadpDataSetWriterMessage);
- var uadpDataSetWriterTransport = new BrokerDataSetWriterTransportDataType
- {
- QueueName = brokerQueueName,
- MetaDataQueueName = $"{brokerQueueName}/{brokerMetaData}",
- MetaDataUpdateTime = 60000
- };
- dataSetWriter1.TransportSettings = new ExtensionObject(uadpDataSetWriterTransport);
-
- writerGroup1.DataSetWriters = writerGroup1.DataSetWriters.AddItem(dataSetWriter1);
-
- // Define DataSetWriter 'AllTypes'
- var dataSetWriter2 = new DataSetWriterDataType
- {
- Name = "Writer 2",
- DataSetWriterId = 2,
- Enabled = true,
- DataSetFieldContentMask = (uint)DataSetFieldContentMask.RawData,
- DataSetName = "AllTypes",
- KeyFrameCount = 1
- };
- uadpDataSetWriterMessage = new UadpDataSetWriterMessageDataType
- {
- ConfiguredSize = 32,
- DataSetOffset = 47,
- NetworkMessageNumber = 1,
- DataSetMessageContentMask = (uint)(
- UadpDataSetMessageContentMask.Status |
- UadpDataSetMessageContentMask.SequenceNumber
- )
- };
-
- dataSetWriter2.MessageSettings = new ExtensionObject(uadpDataSetWriterMessage);
-
- dataSetWriter2.TransportSettings = new ExtensionObject(uadpDataSetWriterTransport);
- writerGroup1.DataSetWriters = writerGroup1.DataSetWriters.AddItem(dataSetWriter2);
-
- pubSubConnection1.WriterGroups = pubSubConnection1.WriterGroups.AddItem(writerGroup1);
-
- // Define PublishedDataSet Simple
- PublishedDataSetDataType publishedDataSetSimple = CreatePublishedDataSetSimple();
-
- // Define PublishedDataSet AllTypes
- PublishedDataSetDataType publishedDataSetAllTypes = CreatePublishedDataSetAllTypes();
-
- //create the PubSub configuration root object
- return new PubSubConfigurationDataType
- {
- Enabled = true,
- Connections = [pubSubConnection1],
- PublishedDataSets = [publishedDataSetSimple, publishedDataSetAllTypes]
- };
- }
-
- ///
- /// Creates the "Simple" DataSet
- ///
- ///
- private static PublishedDataSetDataType CreatePublishedDataSetSimple()
- {
- var publishedDataSetSimple = new PublishedDataSetDataType
- {
- Name = "Simple" //name shall be unique in a configuration
- };
- // Define publishedDataSetSimple.DataSetMetaData
- publishedDataSetSimple.DataSetMetaData = new DataSetMetaDataType
- {
- DataSetClassId = Uuid.Empty,
- Name = publishedDataSetSimple.Name,
- Fields =
- [
- new FieldMetaData
- {
- Name = "BoolToggle",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Boolean,
- DataType = DataTypeIds.Boolean,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Int32",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Int32,
- DataType = DataTypeIds.Int32,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Int32Fast",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Int32,
- DataType = DataTypeIds.Int32,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "DateTime",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.DateTime,
- DataType = DataTypeIds.DateTime,
- ValueRank = ValueRanks.Scalar
- }
- ],
-
- // set the ConfigurationVersion relative to kTimeOfConfiguration constant
- ConfigurationVersion = new ConfigurationVersionDataType
- {
- MinorVersion = ConfigurationVersionUtils.CalculateVersionTime(
- s_timeOfConfiguration),
- MajorVersion = ConfigurationVersionUtils.CalculateVersionTime(
- s_timeOfConfiguration)
- }
- };
-
- var publishedDataSetSimpleSource = new PublishedDataItemsDataType
- {
- PublishedData = []
- };
- //create PublishedData based on metadata names
- foreach (FieldMetaData field in publishedDataSetSimple.DataSetMetaData.Fields)
- {
- publishedDataSetSimpleSource.PublishedData = publishedDataSetSimpleSource.PublishedData.AddItem(
- new PublishedVariableDataType
- {
- PublishedVariable = new NodeId(
- field.Name!,
- PublishedValuesWrites.NamespaceIndexSimple),
- AttributeId = Attributes.Value
- }
- );
- }
-
- publishedDataSetSimple.DataSetSource
- = new ExtensionObject(publishedDataSetSimpleSource);
-
- return publishedDataSetSimple;
- }
-
- ///
- /// Creates the "AllTypes" DataSet
- ///
- ///
- private static PublishedDataSetDataType CreatePublishedDataSetAllTypes()
- {
- var publishedDataSetAllTypes = new PublishedDataSetDataType
- {
- Name = "AllTypes" //name shall be unique in a configuration
- };
- // Define publishedDataSetAllTypes.DataSetMetaData
- publishedDataSetAllTypes.DataSetMetaData = new DataSetMetaDataType
- {
- DataSetClassId = Uuid.Empty,
- Name = publishedDataSetAllTypes.Name,
- Fields =
- [
- new FieldMetaData
- {
- Name = "BoolToggle",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Boolean,
- DataType = DataTypeIds.Boolean,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Byte",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Byte,
- DataType = DataTypeIds.Byte,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Int16",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Int16,
- DataType = DataTypeIds.Int16,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Int32",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Int32,
- DataType = DataTypeIds.Int32,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "SByte",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.SByte,
- DataType = DataTypeIds.SByte,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "UInt16",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.UInt16,
- DataType = DataTypeIds.UInt16,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "UInt32",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.UInt32,
- DataType = DataTypeIds.UInt32,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "UInt64",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.UInt64,
- DataType = DataTypeIds.UInt64,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Float",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Float,
- DataType = DataTypeIds.Float,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Double",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Double,
- DataType = DataTypeIds.Double,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "String",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.String,
- DataType = DataTypeIds.String,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "ByteString",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.ByteString,
- DataType = DataTypeIds.ByteString,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Guid",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Guid,
- DataType = DataTypeIds.Guid,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "DateTime",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.DateTime,
- DataType = DataTypeIds.DateTime,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "UInt32Array",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.UInt32,
- DataType = DataTypeIds.UInt32,
- ValueRank = ValueRanks.OneDimension
- }
- ],
-
- // set the ConfigurationVersion relative to kTimeOfConfiguration constant
- ConfigurationVersion = new ConfigurationVersionDataType
- {
- MinorVersion = ConfigurationVersionUtils.CalculateVersionTime(
- s_timeOfConfiguration),
- MajorVersion = ConfigurationVersionUtils.CalculateVersionTime(
- s_timeOfConfiguration)
- }
- };
- var publishedDataSetAllTypesSource = new PublishedDataItemsDataType();
-
- //create PublishedData based on metadata names
- foreach (FieldMetaData field in publishedDataSetAllTypes.DataSetMetaData.Fields)
- {
- publishedDataSetAllTypesSource.PublishedData = publishedDataSetAllTypesSource.PublishedData.AddItem(
- new PublishedVariableDataType
- {
- PublishedVariable = new NodeId(
- field.Name!,
- PublishedValuesWrites.NamespaceIndexAllTypes),
- AttributeId = Attributes.Value
- }
- );
- }
- publishedDataSetAllTypes.DataSetSource
- = new ExtensionObject(publishedDataSetAllTypesSource);
-
- return publishedDataSetAllTypes;
- }
- }
-}
diff --git a/Applications/ConsoleReferencePublisher/PublishedValuesWrites.cs b/Applications/ConsoleReferencePublisher/PublishedValuesWrites.cs
deleted file mode 100644
index ae77e02e08..0000000000
--- a/Applications/ConsoleReferencePublisher/PublishedValuesWrites.cs
+++ /dev/null
@@ -1,526 +0,0 @@
-/* ========================================================================
- * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
- *
- * OPC Foundation MIT License 1.00
- *
- * Permission is hereby granted, free of charge, to any person
- * obtaining a copy of this software and associated documentation
- * files (the "Software"), to deal in the Software without
- * restriction, including without limitation the rights to use,
- * copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the
- * Software is furnished to do so, subject to the following
- * conditions:
- *
- * The above copyright notice and this permission notice shall be
- * included in all copies or substantial portions of the Software.
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
- * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
- * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
- * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
- * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
- * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
- * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
- * OTHER DEALINGS IN THE SOFTWARE.
- *
- * The complete license agreement can be found here:
- * http://opcfoundation.org/License/MIT/1.00/
- * ======================================================================*/
-
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using Microsoft.Extensions.Logging;
-using Opc.Ua;
-using Opc.Ua.PubSub;
-
-namespace Quickstarts.ConsoleReferencePublisher
-{
- internal sealed class PublishedValuesWrites : IDisposable
- {
- ///
- /// It should match the namespace index from configuration file
- ///
- public const ushort NamespaceIndexSimple = 2;
- public const ushort NamespaceIndexAllTypes = 3;
-
- private const string kDataSetNameSimple = "Simple";
- private const string kDataSetNameAllTypes = "AllTypes";
-
- ///
- /// simulate for BoolToogle changes to 3 seconds
- ///
- private int m_boolToogleCount;
- private const int kBoolToogleLimit = 2;
- private const int kSimpleInt32Limit = 10000;
-
- private readonly List m_simpleFields = [];
- private readonly List m_allTypesFields = [];
-
- private readonly ILogger m_logger;
- private readonly ArrayOf m_publishedDataSets;
- private readonly IUaPubSubDataStore m_dataStore;
- private readonly TimeProvider m_timeProvider;
- private ITimer m_updateValuesTimer = null!;
-
- private readonly string[] m_aviationAlphabet =
- [
- "Alfa",
- "Bravo",
- "Charlie",
- "Delta",
- "Echo",
- "Foxtrot",
- "Golf",
- "Hotel",
- "India",
- "Juliet",
- "Kilo",
- "Lima",
- "Mike",
- "November",
- "Oscar",
- "Papa",
- "Quebec",
- "Romeo",
- "Sierra",
- "Tango",
- "Uniform",
- "Victor",
- "Whiskey",
- "X-Ray",
- "Yankee",
- "Zulu"
- ];
-
- private int m_aviationAlphabetIndex;
- private readonly Lock m_lock = new();
-
- ///
- /// Constructor
- ///
- ///
- ///
- ///
- /// Optional time provider. Defaults to .
- ///
- public PublishedValuesWrites(
- UaPubSubApplication uaPubSubApplication,
- ITelemetryContext telemetry,
- TimeProvider? timeProvider = null)
- {
- m_logger = telemetry.CreateLogger();
- m_publishedDataSets = uaPubSubApplication.UaPubSubConfigurator.PubSubConfiguration
- .PublishedDataSets;
- m_dataStore = uaPubSubApplication.DataStore;
- m_timeProvider = timeProvider ?? TimeProvider.System;
- }
-
- public void Dispose()
- {
- m_updateValuesTimer.Dispose();
- }
-
- ///
- /// Initialize PublisherData with information from configuration and start timer to update data
- ///
- public void Start()
- {
- if (!m_publishedDataSets.IsNull)
- {
- // Remember the fields to be updated
- foreach (PublishedDataSetDataType publishedDataSet in m_publishedDataSets)
- {
- switch (publishedDataSet.Name)
- {
- case kDataSetNameSimple:
- m_simpleFields.AddRange(publishedDataSet.DataSetMetaData.Fields);
- break;
- case kDataSetNameAllTypes:
- m_allTypesFields.AddRange(publishedDataSet.DataSetMetaData.Fields);
- break;
- default:
- m_logger.LogInformation(
- "PublishedValuesWrites.Start: {DataSet} unknown.",
- publishedDataSet.Name);
- break;
- }
- }
- }
-
- try
- {
- LoadInitialData();
- }
- catch (Exception e)
- {
- m_logger.LogError(e,
- "SamplePublisher.DataStoreValuesGenerator.LoadInitialData wrong field");
- }
-
- m_updateValuesTimer = m_timeProvider.CreateTimer(
- UpdateValues,
- null,
- TimeSpan.FromSeconds(1),
- TimeSpan.FromSeconds(1));
- }
-
- ///
- /// Load initial demo data
- ///
- private void LoadInitialData()
- {
- WriteFieldData(
- "BoolToggle",
- NamespaceIndexSimple,
- new DataValue(new Variant(false), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "Int32",
- NamespaceIndexSimple,
- new DataValue(new Variant(0), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "Int32Fast",
- NamespaceIndexSimple,
- new DataValue(new Variant(0), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "DateTime",
- NamespaceIndexSimple,
- new DataValue(new Variant(DateTime.UtcNow), StatusCodes.Good, DateTime.UtcNow)
- );
-
- WriteFieldData(
- "BoolToggle",
- NamespaceIndexAllTypes,
- new DataValue(new Variant(true), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "Byte",
- NamespaceIndexAllTypes,
- new DataValue(new Variant((byte)0), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "Int16",
- NamespaceIndexAllTypes,
- new DataValue(new Variant((short)0), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "Int32",
- NamespaceIndexAllTypes,
- new DataValue(new Variant(0), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "SByte",
- NamespaceIndexAllTypes,
- new DataValue(new Variant((sbyte)0), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "UInt16",
- NamespaceIndexAllTypes,
- new DataValue(new Variant((ushort)0), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "UInt32",
- NamespaceIndexAllTypes,
- new DataValue(new Variant((uint)0), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "UInt64",
- NamespaceIndexAllTypes,
- new DataValue(new Variant((ulong)0), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "Float",
- NamespaceIndexAllTypes,
- new DataValue(new Variant((float)0F), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "Double",
- NamespaceIndexAllTypes,
- new DataValue(new Variant((double)0.0), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "String",
- NamespaceIndexAllTypes,
- new DataValue(new Variant(m_aviationAlphabet[0]), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "ByteString",
- NamespaceIndexAllTypes,
- new DataValue(
- new Variant(ByteString.From([1, 2, 3])),
- StatusCodes.Good,
- DateTime.UtcNow)
- );
- WriteFieldData(
- "Guid",
- NamespaceIndexAllTypes,
- new DataValue(new Variant(Uuid.NewUuid()), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "DateTime",
- NamespaceIndexAllTypes,
- new DataValue(new Variant(DateTime.UtcNow), StatusCodes.Good, DateTime.UtcNow)
- );
- WriteFieldData(
- "UInt32Array",
- NamespaceIndexAllTypes,
- new DataValue(
- new Variant(new uint[] { 1, 2, 3 }),
- StatusCodes.Good,
- DateTime.UtcNow)
- );
- }
-
- ///
- /// Write (update) field data
- ///
- ///
- ///
- private void WriteFieldData(
- string metaDatafieldName,
- ushort namespaceIndex,
- DataValue dataValue)
- {
- m_dataStore.WritePublishedDataItem(
- new NodeId(metaDatafieldName, namespaceIndex),
- Attributes.Value,
- dataValue
- );
- }
-
- ///
- /// Simulate value changes in dynamic nodes
- ///
- ///
- private void UpdateValues(object? state)
- {
- try
- {
- lock (m_lock)
- {
- foreach (FieldMetaData variable in m_simpleFields)
- {
- switch (variable.Name)
- {
- case "BoolToggle":
- m_boolToogleCount++;
- if (m_boolToogleCount >= kBoolToogleLimit)
- {
- m_boolToogleCount = 0;
- IncrementValue(variable, NamespaceIndexSimple);
- }
- break;
- case "Int32":
- IncrementValue(variable, NamespaceIndexSimple, kSimpleInt32Limit);
- break;
- case "Int32Fast":
- IncrementValue(
- variable,
- NamespaceIndexSimple,
- kSimpleInt32Limit,
- 100);
- break;
- case "DateTime":
- IncrementValue(variable, NamespaceIndexSimple);
- break;
- default:
- m_logger.LogDebug("{Variable} not processed.", variable.Name);
- break;
- }
- }
-
- foreach (FieldMetaData variable in m_allTypesFields)
- {
- IncrementValue(variable, NamespaceIndexAllTypes);
- }
- }
- }
- catch (Exception e)
- {
- m_logger.LogError(e, "Unexpected error doing simulation.");
- }
- }
-
- ///
- /// Increment value
- /// maxAllowedValue - maximum incremented value before reset value to initial value
- /// step - the increment value
- ///
- ///
- ///
- ///
- ///
- ///
- private void IncrementValue(
- FieldMetaData variable,
- ushort namespaceIndex,
- long maxAllowedValue = int.MaxValue,
- int step = 0
- )
- {
- // Read value to be incremented
- if (!m_dataStore.TryReadPublishedDataItem(
- new NodeId(variable.Name!, namespaceIndex),
- Attributes.Value,
- out DataValue dataValue) ||
- dataValue.WrappedValue.IsNull)
- {
- return;
- }
-
- bool valueUpdated = false;
-
- BuiltInType builtInType = TypeInfo.GetBuiltInType(variable.DataType);
- switch (builtInType)
- {
- case BuiltInType.Boolean:
- if (variable.ValueRank == ValueRanks.Scalar)
- {
- bool boolValue = (bool)dataValue.WrappedValue.ConvertToBoolean();
- dataValue = dataValue.WithWrappedValue(!boolValue);
- valueUpdated = true;
- }
- break;
- case BuiltInType.Byte:
- if (variable.ValueRank == ValueRanks.Scalar)
- {
- byte byteValue = (byte)dataValue.WrappedValue.ConvertToByte();
- dataValue = dataValue.WithWrappedValue((byte)(byteValue + 1));
- valueUpdated = true;
- }
- break;
- case BuiltInType.Int16:
- if (variable.ValueRank == ValueRanks.Scalar)
- {
- int intIdentifier = (short)dataValue.WrappedValue.ConvertToInt16();
- Interlocked.CompareExchange(ref intIdentifier, 0, short.MaxValue);
- dataValue = dataValue.WithWrappedValue((short)Interlocked.Increment(ref intIdentifier));
- valueUpdated = true;
- }
- break;
- case BuiltInType.Int32:
- if (variable.ValueRank == ValueRanks.Scalar)
- {
- int int32Value = (int)dataValue.WrappedValue.ConvertToInt32();
- if (step > 0)
- {
- int32Value += step - 1;
- }
- if (int32Value > maxAllowedValue)
- {
- int32Value = 0;
- }
- dataValue = dataValue.WithWrappedValue(Interlocked.Increment(ref int32Value));
- valueUpdated = true;
- }
- break;
- case BuiltInType.SByte:
- if (variable.ValueRank == ValueRanks.Scalar)
- {
- int intIdentifier = (sbyte)dataValue.WrappedValue.ConvertToSByte();
- Interlocked.CompareExchange(ref intIdentifier, 0, sbyte.MaxValue);
- dataValue = dataValue.WithWrappedValue((sbyte)Interlocked.Increment(ref intIdentifier));
- valueUpdated = true;
- }
- break;
- case BuiltInType.UInt16:
- if (variable.ValueRank == ValueRanks.Scalar)
- {
- int intIdentifier = dataValue.WrappedValue.ConvertToUInt16().GetUInt16();
- Interlocked.CompareExchange(ref intIdentifier, 0, ushort.MaxValue);
- dataValue = dataValue.WithWrappedValue((ushort)Interlocked.Increment(ref intIdentifier));
- valueUpdated = true;
- }
- break;
- case BuiltInType.UInt32:
- if (variable.ValueRank == ValueRanks.Scalar)
- {
- long longIdentifier = dataValue.WrappedValue.ConvertToUInt32().GetUInt32();
- Interlocked.CompareExchange(ref longIdentifier, 0, uint.MaxValue);
- dataValue = dataValue.WithWrappedValue((uint)Interlocked.Increment(ref longIdentifier));
- valueUpdated = true;
- }
- else if (variable.ValueRank == ValueRanks.OneDimension)
- {
- if (dataValue.WrappedValue.TryGetValue(out ArrayOf values))
- {
- var valuesList = values.ToList();
- for (int i = 0; i < values.Count; i++)
- {
- long longIdentifier = values[i];
- Interlocked.CompareExchange(ref longIdentifier, 0, uint.MaxValue);
- valuesList[i] = (uint)Interlocked.Increment(ref longIdentifier);
- }
- dataValue = dataValue.WithWrappedValue(valuesList.ToArrayOf());
- valueUpdated = true;
- }
- }
- break;
- case BuiltInType.UInt64:
- if (variable.ValueRank == ValueRanks.Scalar)
- {
- ulong uint64Value = (ulong)dataValue.WrappedValue.ConvertToUInt64();
- float longIdentifier = uint64Value + 1;
- Interlocked.CompareExchange(ref longIdentifier, 0, ulong.MaxValue);
- dataValue = dataValue.WithWrappedValue((ulong)longIdentifier);
- valueUpdated = true;
- }
- break;
- case BuiltInType.Float:
- if (variable.ValueRank == ValueRanks.Scalar)
- {
- float floatValue = (float)dataValue.WrappedValue.ConvertToFloat();
- Interlocked.CompareExchange(ref floatValue, 0, float.MaxValue);
- dataValue = dataValue.WithWrappedValue(floatValue + 1);
- valueUpdated = true;
- }
- break;
- case BuiltInType.Double:
- if (variable.ValueRank == ValueRanks.Scalar)
- {
- double doubleValue = (double)dataValue.WrappedValue.ConvertToDouble();
- Interlocked.CompareExchange(ref doubleValue, 0, double.MaxValue);
- dataValue = dataValue.WithWrappedValue(doubleValue + 1);
- valueUpdated = true;
- }
- break;
- case BuiltInType.DateTime:
- if (variable.ValueRank == ValueRanks.Scalar)
- {
- dataValue = dataValue.WithWrappedValue(DateTimeUtc.Now);
- valueUpdated = true;
- }
- break;
- case BuiltInType.Guid:
- if (variable.ValueRank == ValueRanks.Scalar)
- {
- dataValue = dataValue.WithWrappedValue(Uuid.NewUuid());
- valueUpdated = true;
- }
- break;
- case BuiltInType.String:
- if (variable.ValueRank == ValueRanks.Scalar)
- {
- m_aviationAlphabetIndex = (m_aviationAlphabetIndex + 1) %
- m_aviationAlphabet.Length;
- dataValue = dataValue.WithWrappedValue(m_aviationAlphabet[m_aviationAlphabetIndex]);
- valueUpdated = true;
- }
- break;
- case >= BuiltInType.Null and <= BuiltInType.Enumeration:
- break;
- default:
- throw ServiceResultException.Unexpected($"Unexpected BuiltInType {builtInType}");
- }
-
- if (valueUpdated)
- {
- // Save new updated value to data store
- WriteFieldData(variable.Name!, namespaceIndex, dataValue);
- }
- }
- }
-}
diff --git a/Applications/ConsoleReferencePublisher/README.md b/Applications/ConsoleReferencePublisher/README.md
deleted file mode 100644
index bab4863833..0000000000
--- a/Applications/ConsoleReferencePublisher/README.md
+++ /dev/null
@@ -1,160 +0,0 @@
-
-# OPC Foundation UA .NET Standard Library - Console Reference Publisher
-
-## Introduction
-
-This OPC application was created to provide the sample code for creating Publisher applications using the OPC Foundation UA .NET Standard PubSub Library. There is a .NET Core 3.1 (2.1) console version of the Publisher which runs on any OS supporting [.NET Standard 2.1](https://docs.microsoft.com/dotnet/articles/standard).
-The Reference Publisher is configured to run in parallel with the [Console Reference Subscriber](../ConsoleReferenceSubscriber/README.md)
-
-## How to build and run the console OPC UA Reference Publisher from Visual Studio
-
-1. Open the solution **UA.slnx** with Visual Studio 2026.
-2. Choose the project `ConsoleReferencePublisher` in the Solution Explorer and set it with a right click as `Startup Project`.
-3. Hit `F5` to build and execute the sample.
-
-## How to build and run the console OPC UA Reference Publisher on Windows, Linux and iOS
-
-This section describes how to run the **ConsoleReferencePublisher**.
-
-Please follow instructions in this [article](https://aka.ms/dotnetcoregs) to setup the dotnet command line environment for your platform.
-
-## Start the Publisher
-
-1. Open a command prompt.
-2. Navigate to the folder **Applications/ConsoleReferencePublisher**.
-3. To run the Publisher sample execute:
-
-`dotnet run --project ConsoleReferencePublisher.csproj --framework net10.0`
-
-The Publisher will start and publish network messages that can be consumed by the Reference Subscriber.
-Publisher Initialization
-
-## Command Line Arguments for *ConsoleReferencePublisher*
-
- **ConsoleReferencePublisher** can be executed using the following command line arguments:
-
-- -h|help - Shows usage information
-- -m|mqtt_json - Creates a connection using there MQTT with Json encoding Profile. This is the default option.
-- -u|udp_uadp - Creates a connection using there UDP with UADP encoding Profile.
-
-To run the Publisher sample using a connection with MQTT with Json encoding execute:
-
-```sh
- dotnet run --project ConsoleReferencePublisher.csproj --framework net10.0
-```
-
- or
-
-```sh
- dotnet run --project ConsoleReferencePublisher.csproj --framework net10.0 -m
-```
-
-To run the Publisher sample using a connection with the UDP with UADP encoding execute:
-
-```sh
- dotnet run --project ConsoleReferencePublisher.csproj --framework net10.0 -u
-```
-
-## Programmer's Guide
-
-To create a new OPC UA Publisher application:
-
-- Open Microsoft Visual Studio 2019 environment,
-- Create a new project and give it a name,
-- Add a reference to the [OPCFoundation.NetStandard.Opc.Ua.PubSub NuGet package](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.PubSub/),
-- Initialize Publisher application (see [Publisher Initialization](#publisher-initialization)).
-
-### Publisher Initialization
-
-The following four steps are required to implement a functional Publisher:
-
-1. Create [Publisher Configuration](#publisher-configuration).
-
- ```csharp
- // Create configuration using MQTT protocol and JSON Encoding
- PubSubConfigurationDataType pubSubConfiguration = CreatePublisherConfiguration_MqttJson();
- ```
-
- Or use the alternative configuration object for UDP with UADP encoding
-
- ```csharp
- // Create configuration using UDP protocol and UADP Encoding
- PubSubConfigurationDataType pubSubConfiguration = CreatePublisherConfiguration_UdpUadp();
- ```
-
- The CreatePublisherConfiguration methods can be found in [ConsoleReferencePublisher/Program.cs](./Program.cs) file.
-
-2. Create an instance of the [UaPubSubApplication Class](../../Docs/PubSub.md#uapubsubapplication-class) using the configuration data from step 1.
-
- ```csharp
- // Create an instance of UaPubSubApplication
- UaPubSubApplication uaPubSubApplication = UaPubSubApplication.Create(pubSubConfiguration);
- ```
-
-3. Provide the data to be published based on the configuration of published data sets. This step is described in the [Publisher Data](#publisher-data) section.
-4. Start PubSub application
-
- ```csharp
- // Start the publisher
- uaPubSubApplication.Start();
- ```
-
-After this step the Publisher will publish data as configured.
-
-### Publisher Configuration
-
-The Publisher configuration is a subset of the [PubSub Configuration](../../Docs/PubSub.md#pubsub-configuration). A functional *Publisher* application needs to have a configuration (*PubSubConfigurationDataType* instance) that contains a list of published data sets (*PublishedDataSetDataType* instances) and at least one connection (*PubSubConnectionDataType* instance) with at least one writer group configuration (*WriterGroupDataType* instance). The writer group contains at least one data set writer (*DataSetWriterDataType* instance) pointing to a published data set from the current configuration.
-
-The diagram shows the subset of classes involved in an *OPC UA Publisher* configuration.
-
-
-
-### Publisher Data
-
-The [UaPubSubApplication Class](../../Docs/PubSub.md#uapubsubapplication-class) provides a property of type [IUaPubSubDataStore](../../Docs/PubSub.md#iuapubsubdatastore-interface) called DataStore. In **ConsoleReferencePublisher** there is no custom implementation provided for *IUaPubSubDataStore* therefore the pub sub application object is initialized using the default implementation of this interface, an instance of *UaPubSubDataStore*.
-
-The code responsible for generating the data values to be published is located in the [PublishedValuesWrites](/PublishedValuesWrites.cs) file from the **ConsoleReferencePublisher** project. It maintains a list of all the fields from the table below and uses a timer for writing the values to *UaPubSubApplication.DataStore* using the *WritePublishedDataItem*() method from *DataStore* class. The data values simulator component is initialized like:
-
- // Start values simulator
- PublishedValuesWrites valuesSimulator = new PublishedValuesWrites(uaPubSubApplication);
- valuesSimulator.Start();
-
-The **Publisher** component from **ConsoleReferencePublisher** application will use the data generated by *PublishedValuesWrites* to create the *NetworkMessages* that will be published as configured in [Publisher Configuration](#publisher-configuration).
-
-Note:
-The current PubSub implementation only supports *PublishedDataItemsDataType* as *DataSetSource* of a *PublishedDataSetDataType* from the configuration. *Events* will be added in a future version.
-
-The **ConsoleReferencePublisher** application is configured to use the following data sets and will generate values as specified in the table below if the default configuration method is used:
-
-#### PublishedDataSet 'Simple' - NamespaceIndex = 2
-
-| Name | DataType | ValueRank |Behavior |
-|--|--|--|--|
-|BoolToggle |Boolean |Scalar |Toggles every 3 seconds|
-|Int32|Int32|Scalar |Counts (1 per second) from 0 to 10,000 and then resets|
-|Int32Fast|Int32Fast|Scalar |Counts (100 per second) from 0 to 10,000 and then resets|
-|DateTime|DateTime|Scalar |Current time refreshed with every packet sent|
-
-The *CreatePublishedDataSetSimple*() method from [Program.cs](Program.cs) creates a *PublishedDataSetDataType* configuration object that contains the metadata information for *'Simple' DataSet*.
-
-#### PublishedDataSet 'AllTypes' - NamespaceIndex = 3
-
-| Name | DataType | ValueRank |Behavior |
-|--|--|--|--|
-|BoolToggle |Boolean |Scalar |Toggles every second|
-|Byte|Byte|Scalar |Counts (1 per second) from 0 to type-max and then resets|
-|Int16|Int16|Scalar |Counts (1 per second) from 0 to type-max and then resets|
-|Int32|Int32|Scalar |Counts (1 per second) from 0 to type-max and then resets|
-|SByte|SByte|Scalar |Counts (1 per second) from 0 to type-max and then resets|
-|UInt16|UInt16|Scalar |Counts (1 per second) from 0 to type-max and then resets|
-|UInt32|UInt32|Scalar |Counts (1 per second) from 0 to type-max and then resets|
-|UInt64|UInt64|Scalar |Counts (1 per second) from 0 to type-max and then resets|
-|Float|Float|Scalar |Counts (1 per second) from 0 to type-max and then resets|
-|Double|Double|Scalar |Counts (1 per second) from 0 to type-max and then resets|
-|String|String|Scalar |Spells the aviation alphabet (Alpha, Bravo …) (1 per second)|
-|ByteString|ByteString|Scalar |1 new random ByteString per second|
-|Guid|Guid|Scalar |1 new random Guid per second|
-|DateTime|DateTime|Scalar |Current time refreshed with every packet sent|
-|UInt32Array|UInt32|OneDimension|Counts (1 per second on every element) from 0 to type-max and then resets. The count starting point for each value should differ|
-
-The *CreatePublishedDataSetAllTypes*() method from [Program.cs](Program.cs) creates a *PublishedDataSetDataType* configuration object that contains the metadata information for *'AllTypes' DataSet*.
diff --git a/Applications/ConsoleReferenceServer/Dockerfile b/Applications/ConsoleReferenceServer/Dockerfile
index 62788e43a7..a7c1d0bb9e 100644
--- a/Applications/ConsoleReferenceServer/Dockerfile
+++ b/Applications/ConsoleReferenceServer/Dockerfile
@@ -12,6 +12,7 @@ COPY ["Tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.csproj"
COPY ["Tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.csproj", "Tools/Opc.Ua.SourceGeneration/"]
COPY ["Stack/Opc.Ua.Core.Types/Opc.Ua.Core.Types.csproj", "Stack/Opc.Ua.Core.Types/"]
COPY ["Stack/Opc.Ua.Core/Opc.Ua.Core.csproj", "Stack/Opc.Ua.Core/"]
+COPY ["Stack/Opc.Ua.Core.Schema/Opc.Ua.Core.Schema.csproj", "Stack/Opc.Ua.Core.Schema/"]
COPY ["Stack/Opc.Ua.Types/Opc.Ua.Types.csproj", "Stack/Opc.Ua.Types/"]
COPY ["Stack/Opc.Ua.Security.Certificates/Opc.Ua.Security.Certificates.csproj", "Stack/Opc.Ua.Security.Certificates/"]
COPY ["Libraries/Opc.Ua.Configuration/Opc.Ua.Configuration.csproj", "Libraries/Opc.Ua.Configuration/"]
diff --git a/Applications/ConsoleReferenceSubscriber/ConsoleReferenceSubscriber.csproj b/Applications/ConsoleReferenceSubscriber/ConsoleReferenceSubscriber.csproj
deleted file mode 100644
index 29458c327d..0000000000
--- a/Applications/ConsoleReferenceSubscriber/ConsoleReferenceSubscriber.csproj
+++ /dev/null
@@ -1,35 +0,0 @@
-
-
- $(AppTargetFrameWorks)
- ConsoleReferenceSubscriber
- Exe
- ConsoleReferenceSubscriber
- OPC Foundation
- .NET Console Reference Subscriber
- Copyright © 2004-2020 OPC Foundation, Inc
- Quickstarts.ConsoleReferenceSubscriber
- enable
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Applications/ConsoleReferenceSubscriber/Program.cs b/Applications/ConsoleReferenceSubscriber/Program.cs
deleted file mode 100644
index 9973106dcb..0000000000
--- a/Applications/ConsoleReferenceSubscriber/Program.cs
+++ /dev/null
@@ -1,1017 +0,0 @@
-/* ========================================================================
- * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
- *
- * OPC Foundation MIT License 1.00
- *
- * Permission is hereby granted, free of charge, to any person
- * obtaining a copy of this software and associated documentation
- * files (the "Software"), to deal in the Software without
- * restriction, including without limitation the rights to use,
- * copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the
- * Software is furnished to do so, subject to the following
- * conditions:
- *
- * The above copyright notice and this permission notice shall be
- * included in all copies or substantial portions of the Software.
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
- * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
- * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
- * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
- * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
- * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
- * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
- * OTHER DEALINGS IN THE SOFTWARE.
- *
- * The complete license agreement can be found here:
- * http://opcfoundation.org/License/MIT/1.00/
- * ======================================================================*/
-
-using System;
-using System.CommandLine;
-using System.Threading;
-using Opc.Ua;
-using Opc.Ua.PubSub;
-using Opc.Ua.PubSub.Configuration;
-using Opc.Ua.PubSub.PublishedData;
-using Opc.Ua.PubSub.Transport;
-using Encoding = Opc.Ua.PubSub.Encoding;
-
-namespace Quickstarts.ConsoleReferenceSubscriber
-{
- public static class Program
- {
- public const ushort NamespaceIndexSimple = 2;
- public const ushort NamespaceIndexAllTypes = 3;
-
- ///
- /// constant DateTime that represents the initial time when the metadata for the configuration was created
- ///
- private static readonly DateTime s_timeOfConfiguration = new(
- 2021,
- 5,
- 1,
- 0,
- 0,
- 0,
- DateTimeKind.Utc);
-
- private const string kDisplaySeparator = "------------------------------------------------";
-
- private static readonly Lock s_lock = new();
-
- public static void Main(string[] args)
- {
- Console.WriteLine("OPC UA Console Reference Subscriber");
-
- // command line options
- var mqttJsonOption = new Option("--mqtt_json", "-m") { Description = "Use MQTT with Json encoding Profile. This is the default option." };
- var mqttUadpOption = new Option("--mqtt_uadp", "-p") { Description = "Use MQTT with UADP encoding Profile." };
- var udpUadpOption = new Option("--udp_uadp", "-u") { Description = "Use UDP with UADP encoding Profile" };
- var subscriberUrlOption = new Option("--subscriber_url", "--url") { Description = "Subscriber Url Address" };
-
- var rootCommand = new RootCommand("OPC UA Console Reference Subscriber")
- {
- mqttJsonOption,
- mqttUadpOption,
- udpUadpOption,
- subscriberUrlOption
- };
-
- rootCommand.SetAction((parseResult) =>
- {
- bool useMqttUadp = parseResult.GetValue(mqttUadpOption);
- bool useUdpUadp = parseResult.GetValue(udpUadpOption);
- string? subscriberUrl = parseResult.GetValue(subscriberUrlOption);
-
- try
- {
- var telemetry = new ConsoleTelemetry();
-
- PubSubConfigurationDataType? pubSubConfiguration = null;
- if (useUdpUadp)
- {
- // set default UDP Subscriber Url to local multicast if not sent in args.
- if (string.IsNullOrEmpty(subscriberUrl))
- {
- subscriberUrl = "opc.udp://239.0.0.1:4840";
- }
-
- // Create configuration using UDP protocol and UADP Encoding.
- // ! is required on net48 because string.IsNullOrEmpty lacks
- // [NotNullWhen(false)] in .NET Framework's BCL; the value is
- // provably non-null after the guard above.
- pubSubConfiguration = CreateSubscriberConfiguration_UdpUadp(subscriberUrl!);
- Console.WriteLine(
- "The Pubsub Connection was initialized using UDP & UADP Profile.");
- }
- else
- {
- // set default MQTT Broker Url to localhost if not sent in args.
- if (string.IsNullOrEmpty(subscriberUrl))
- {
- subscriberUrl = "mqtt://localhost:1883";
- }
-
- if (useMqttUadp)
- {
- // Create configuration using MQTT protocol and UADP Encoding
- pubSubConfiguration = CreateSubscriberConfiguration_MqttUadp(subscriberUrl!);
- Console.WriteLine(
- "The PubSub Connection was initialized using MQTT & UADP Profile.");
- }
- else
- {
- // Create configuration using MQTT protocol and JSON Encoding
- pubSubConfiguration = CreateSubscriberConfiguration_MqttJson(subscriberUrl!);
- Console.WriteLine(
- "The PubSub Connection was initialized using MQTT & JSON Profile.");
- }
- }
-
- // Create the UA Publisher application
- using (var uaPubSubApplication = UaPubSubApplication.Create(pubSubConfiguration, telemetry))
- {
- // Subscribte to RawDataReceived event
- uaPubSubApplication.RawDataReceived += UaPubSubApplication_RawDataReceived;
-
- // Subscribte to DataReceived event
- uaPubSubApplication.DataReceived += UaPubSubApplication_DataReceived;
-
- // Subscribte to MetaDataReceived event
- uaPubSubApplication.MetaDataReceived
- += UaPubSubApplication_MetaDataDataReceived;
-
- uaPubSubApplication.ConfigurationUpdating
- += UaPubSubApplication_ConfigurationUpdating;
-
- // Start the publisher
- uaPubSubApplication.Start();
-
- Console.WriteLine("Subscriber Started. Press Ctrl-C to exit...");
-
- var quitEvent = new ManualResetEvent(false);
- try
- {
- Console.CancelKeyPress += (sender, eArgs) =>
- {
- quitEvent.Set();
- eArgs.Cancel = true;
- };
- }
- catch
- {
- }
-
- // wait for timeout or Ctrl-C
- quitEvent.WaitOne();
- }
-
- Console.WriteLine("Program ended.");
- Console.WriteLine("Press any key to finish...");
- Console.ReadKey();
- }
- catch (Exception ex)
- {
- Console.WriteLine(ex.Message);
- }
- });
-
- ParseResult parseResult = rootCommand.Parse(args);
- parseResult.Invoke(new InvocationConfiguration());
- }
-
- ///
- /// Handler for event.
- ///
- ///
- ///
- private static void UaPubSubApplication_RawDataReceived(
- object? sender,
- RawDataReceivedEventArgs e)
- {
- lock (s_lock)
- {
- Console.WriteLine(
- "RawDataReceived bytes:{0}, Source:{1}, TransportProtocol:{2}, MessageMapping:{3}",
- e.Message.Length,
- e.Source,
- e.TransportProtocol,
- e.MessageMapping
- );
-
- Console.WriteLine(kDisplaySeparator);
- }
- }
-
- ///
- /// Handler for event.
- ///
- ///
- ///
- private static void UaPubSubApplication_DataReceived(
- object? sender,
- SubscribedDataEventArgs e)
- {
- lock (s_lock)
- {
- Console.WriteLine("DataReceived event:");
-
- if (e.NetworkMessage is Encoding.UadpNetworkMessage uadpMessage)
- {
- Console.WriteLine(
- "UADP Network DataSetMessage ({0} DataSets): Source={1}, SequenceNumber={2}",
- e.NetworkMessage.DataSetMessages.Count,
- e.Source,
- uadpMessage.SequenceNumber
- );
- }
- else if (e.NetworkMessage is Encoding.JsonNetworkMessage jsonMessage)
- {
- Console.WriteLine(
- "JSON Network DataSetMessage ({0} DataSets): Source={1}, MessageId={2}",
- e.NetworkMessage.DataSetMessages.Count,
- e.Source,
- jsonMessage.MessageId
- );
- }
-
- foreach (UaDataSetMessage dataSetMessage in e.NetworkMessage.DataSetMessages)
- {
- DataSet dataSet = dataSetMessage.DataSet;
- Console.WriteLine(
- "\tDataSet.Name={0}, DataSetWriterId={1}, SequenceNumber={2}",
- dataSet.Name,
- dataSet.DataSetWriterId,
- dataSetMessage.SequenceNumber
- );
-
- for (int i = 0; i < dataSet.Fields!.Length; i++)
- {
- Console.WriteLine(
- "\t\tTargetNodeId:{0}, Attribute:{1}, Value:{2}",
- dataSet.Fields[i].TargetNodeId,
- dataSet.Fields[i].TargetAttribute,
- dataSetMessage.DataSet!.Fields![i].Value
- );
- }
- }
- Console.WriteLine(kDisplaySeparator);
- }
- }
-
- ///
- /// Handler for event.
- ///
- ///
- ///
- private static void UaPubSubApplication_MetaDataDataReceived(
- object? sender,
- SubscribedDataEventArgs e)
- {
- lock (s_lock)
- {
- Console.WriteLine("MetaDataDataReceived event:");
- if (e.NetworkMessage is Encoding.JsonNetworkMessage jsonMessage)
- {
- Console.WriteLine(
- "JSON Network MetaData Message: Source={0}, PublisherId={1}, DataSetWriterId={2} Fields count={3}\n",
- e.Source,
- jsonMessage.PublisherId,
- jsonMessage.DataSetWriterId,
- e.NetworkMessage.DataSetMetaData!.Fields.Count
- );
- }
- if (e.NetworkMessage is Encoding.UadpNetworkMessage uapdMessage)
- {
- Console.WriteLine(
- "UADP Network MetaData Message: Source={0}, PublisherId={1}, DataSetWriterId={2} Fields count={3}\n",
- e.Source,
- uapdMessage.PublisherId,
- uapdMessage.DataSetWriterId,
- e.NetworkMessage.DataSetMetaData!.Fields.Count
- );
- }
-
- Console.WriteLine(
- "\tMetaData.Name={0}, MajorVersion={1} MinorVersion={2}",
- e.NetworkMessage.DataSetMetaData!.Name,
- e.NetworkMessage.DataSetMetaData.ConfigurationVersion.MajorVersion,
- e.NetworkMessage.DataSetMetaData.ConfigurationVersion.MinorVersion
- );
-
- foreach (FieldMetaData metaDataField in e.NetworkMessage.DataSetMetaData.Fields)
- {
- Console.WriteLine(
- "\t\t{0, -20} DataType:{1, 10}, ValueRank:{2, 5}",
- metaDataField.Name,
- metaDataField.DataType,
- metaDataField.ValueRank
- );
- }
- Console.WriteLine(kDisplaySeparator);
- }
- }
-
- ///
- /// Handler for
- ///
- ///
- ///
- private static void UaPubSubApplication_ConfigurationUpdating(
- object? sender,
- ConfigurationUpdatingEventArgs e)
- {
- Console.WriteLine(
- "The UaPubSubApplication.ConfigurationUpdating event was triggered for part: {0} for {1}, With new value: {2}",
- e.ChangedProperty,
- e.Parent.GetType().Name,
- e.NewValue.GetType().Name
- );
- Console.WriteLine(kDisplaySeparator);
- }
-
- ///
- /// Creates a Subscriber PubSubConfiguration object for UDP & UADP programmatically.
- ///
- ///
- private static PubSubConfigurationDataType CreateSubscriberConfiguration_UdpUadp(
- string urlAddress)
- {
- // Define a PubSub connection with PublisherId 1
- var pubSubConnection1 = new PubSubConnectionDataType
- {
- Name = "Subscriber Connection UDP UADP",
- Enabled = true,
- PublisherId = (ushort)1,
- TransportProfileUri = Profiles.PubSubUdpUadpTransport
- };
- var address = new NetworkAddressUrlDataType
- {
- // Specify the local Network interface name to be used
- // e.g. address.NetworkInterface = "Ethernet";
- // Leave empty to subscribe on all available local interfaces.
- NetworkInterface = string.Empty,
- Url = urlAddress
- };
- pubSubConnection1.Address = new ExtensionObject(address);
-
- // configure custoom DicoveryAddress for Dicovery messages
- pubSubConnection1.TransportSettings = new ExtensionObject(
- new DatagramConnectionTransportDataType
- {
- DiscoveryAddress = new ExtensionObject(
- new NetworkAddressUrlDataType
- {
- Url = "opc.udp://224.0.2.15:4840"
- })
- });
-
- var readerGroup1 = new ReaderGroupDataType
- {
- Name = "ReaderGroup 1",
- Enabled = true,
- MaxNetworkMessageSize = 1500
- };
-
- var dataSetReaderSimple = new DataSetReaderDataType
- {
- Name = "Reader 1 UDP UADP",
- PublisherId = (ushort)1,
- WriterGroupId = 0,
- DataSetWriterId = 1,
- Enabled = true,
- DataSetFieldContentMask = (uint)DataSetFieldContentMask.RawData,
- KeyFrameCount = 1
- };
-
- var uadpDataSetReaderMessage = new UadpDataSetReaderMessageDataType
- {
- GroupVersion = 0,
- NetworkMessageNumber = 0,
- NetworkMessageContentMask = (uint)(
- UadpNetworkMessageContentMask.PublisherId |
- UadpNetworkMessageContentMask.GroupHeader |
- UadpNetworkMessageContentMask.WriterGroupId |
- UadpNetworkMessageContentMask.GroupVersion |
- UadpNetworkMessageContentMask.NetworkMessageNumber |
- UadpNetworkMessageContentMask.SequenceNumber
- ),
- DataSetMessageContentMask = (uint)(
- UadpDataSetMessageContentMask.Status |
- UadpDataSetMessageContentMask.SequenceNumber
- )
- };
- dataSetReaderSimple.MessageSettings = new ExtensionObject(uadpDataSetReaderMessage);
-
- // Create and set DataSetMetaData for DataSet Simple
- DataSetMetaDataType simpleMetaData = CreateDataSetMetaDataSimple();
- dataSetReaderSimple.DataSetMetaData = simpleMetaData;
- // Create and set SubscribedDataSet
- var subscribedDataSet = new TargetVariablesDataType { TargetVariables = [] };
- foreach (FieldMetaData fieldMetaData in simpleMetaData.Fields)
- {
- // Name is always set to a non-null literal in CreateDataSetMetaDataSimple/AllTypes.
- subscribedDataSet.TargetVariables = subscribedDataSet.TargetVariables.AddItem(
- new FieldTargetDataType
- {
- DataSetFieldId = fieldMetaData.DataSetFieldId,
- TargetNodeId = new NodeId(fieldMetaData.Name!, NamespaceIndexSimple),
- AttributeId = Attributes.Value,
- OverrideValueHandling = OverrideValueHandling.OverrideValue,
- OverrideValue = TypeInfo.GetDefaultVariantValue(fieldMetaData.DataType, ValueRanks.Scalar)
- }
- );
- }
-
- dataSetReaderSimple.SubscribedDataSet = new ExtensionObject(subscribedDataSet);
- readerGroup1.DataSetReaders = readerGroup1.DataSetReaders.AddItem(dataSetReaderSimple);
-
- var dataSetReaderAllTypes = new DataSetReaderDataType
- {
- Name = "Reader 2 UDP UADP",
- PublisherId = (ushort)1,
- WriterGroupId = 0,
- DataSetWriterId = 2,
- Enabled = true,
- DataSetFieldContentMask = (uint)DataSetFieldContentMask.RawData,
- KeyFrameCount = 1
- };
-
- uadpDataSetReaderMessage = new UadpDataSetReaderMessageDataType
- {
- GroupVersion = 0,
- NetworkMessageNumber = 0,
- NetworkMessageContentMask = (uint)(
- UadpNetworkMessageContentMask.PublisherId |
- UadpNetworkMessageContentMask.GroupHeader |
- UadpNetworkMessageContentMask.WriterGroupId |
- UadpNetworkMessageContentMask.GroupVersion |
- UadpNetworkMessageContentMask.NetworkMessageNumber |
- UadpNetworkMessageContentMask.SequenceNumber
- ),
- DataSetMessageContentMask = (uint)(
- UadpDataSetMessageContentMask.Status |
- UadpDataSetMessageContentMask.SequenceNumber
- )
- };
- dataSetReaderAllTypes.MessageSettings = new ExtensionObject(uadpDataSetReaderMessage);
-
- // Create and set DataSetMetaData for DataSet AllTypes
- DataSetMetaDataType allTypesMetaData = CreateDataSetMetaDataAllTypes();
- dataSetReaderAllTypes.DataSetMetaData = allTypesMetaData;
- // Create and set SubscribedDataSet
- subscribedDataSet = new TargetVariablesDataType { TargetVariables = [] };
- foreach (FieldMetaData fieldMetaData in allTypesMetaData.Fields)
- {
- subscribedDataSet.TargetVariables = subscribedDataSet.TargetVariables.AddItem(
- new FieldTargetDataType
- {
- DataSetFieldId = fieldMetaData.DataSetFieldId,
- TargetNodeId = new NodeId(fieldMetaData.Name!, NamespaceIndexAllTypes),
- AttributeId = Attributes.Value,
- OverrideValueHandling = OverrideValueHandling.OverrideValue,
- OverrideValue = TypeInfo.GetDefaultVariantValue(fieldMetaData.DataType, ValueRanks.Scalar)
- }
- );
- }
-
- dataSetReaderAllTypes.SubscribedDataSet = new ExtensionObject(subscribedDataSet);
-
- readerGroup1.DataSetReaders = readerGroup1.DataSetReaders.AddItem(dataSetReaderAllTypes);
- pubSubConnection1.ReaderGroups = pubSubConnection1.ReaderGroups.AddItem(readerGroup1);
-
- //create pub sub configuration root object
- return new PubSubConfigurationDataType { Enabled = true, Connections = [pubSubConnection1] };
- }
-
- ///
- /// Creates a Subscriber PubSubConfiguration object for MQTT & Json programmatically.
- ///
- ///
- private static PubSubConfigurationDataType CreateSubscriberConfiguration_MqttJson(
- string urlAddress)
- {
- // Define a PubSub connection with PublisherId 2
- var pubSubConnection1 = new PubSubConnectionDataType
- {
- Name = "Subscriber Connection MQTT Json",
- Enabled = true,
- PublisherId = (ushort)2,
- TransportProfileUri = Profiles.PubSubMqttJsonTransport
- };
- var address = new NetworkAddressUrlDataType
- {
- // Specify the local Network interface name to be used
- // e.g. address.NetworkInterface = "Ethernet";
- // Leave empty to subscribe on all available local interfaces.
- NetworkInterface = string.Empty,
- Url = urlAddress
- };
- pubSubConnection1.Address = new ExtensionObject(address);
-
- // Configure the mqtt specific configuration with the MQTTbroker
- var mqttConfiguration = new MqttClientProtocolConfiguration(
- version: EnumMqttProtocolVersion.V500);
- pubSubConnection1.ConnectionProperties = mqttConfiguration.ConnectionProperties;
-
- const string brokerQueueName = "Json_WriterGroup_1";
- const string brokerMetaData = "$Metadata";
-
- var readerGroup1 = new ReaderGroupDataType
- {
- Name = "ReaderGroup 1",
- Enabled = true,
- MaxNetworkMessageSize = 1500
- };
-
- var dataSetReaderSimple = new DataSetReaderDataType
- {
- Name = "Reader 1 MQTT JSON Variant Encoding",
- PublisherId = (ushort)2,
- WriterGroupId = 1,
- DataSetWriterId = 1,
- Enabled = true,
- DataSetFieldContentMask = (uint)DataSetFieldContentMask.None, // Variant encoding;
- KeyFrameCount = 3
- };
-
- var jsonDataSetReaderMessage = new JsonDataSetReaderMessageDataType
- {
- NetworkMessageContentMask = (uint)(
- JsonNetworkMessageContentMask.NetworkMessageHeader |
- JsonNetworkMessageContentMask.DataSetMessageHeader |
- JsonNetworkMessageContentMask.PublisherId |
- JsonNetworkMessageContentMask.DataSetClassId |
- JsonNetworkMessageContentMask.ReplyTo
- ),
- DataSetMessageContentMask = (uint)(
- JsonDataSetMessageContentMask.DataSetWriterId |
- JsonDataSetMessageContentMask.MetaDataVersion |
- JsonDataSetMessageContentMask.SequenceNumber |
- JsonDataSetMessageContentMask.Status |
- JsonDataSetMessageContentMask.Timestamp
- )
- };
- dataSetReaderSimple.MessageSettings = new ExtensionObject(jsonDataSetReaderMessage);
-
- var brokerTransportSettings = new BrokerDataSetReaderTransportDataType
- {
- QueueName = brokerQueueName,
- RequestedDeliveryGuarantee = BrokerTransportQualityOfService.BestEffort,
- MetaDataQueueName = $"{brokerQueueName}/{brokerMetaData}"
- };
- dataSetReaderSimple.TransportSettings = new ExtensionObject(brokerTransportSettings);
-
- // Create and set DataSetMetaData for DataSet Simple
- DataSetMetaDataType simpleMetaData = CreateDataSetMetaDataSimple();
- dataSetReaderSimple.DataSetMetaData = simpleMetaData;
- // Create and set SubscribedDataSet
- var subscribedDataSet = new TargetVariablesDataType { TargetVariables = [] };
- foreach (FieldMetaData fieldMetaData in simpleMetaData.Fields)
- {
- subscribedDataSet.TargetVariables = subscribedDataSet.TargetVariables.AddItem(
- new FieldTargetDataType
- {
- DataSetFieldId = fieldMetaData.DataSetFieldId,
- TargetNodeId = new NodeId(fieldMetaData.Name!, NamespaceIndexSimple),
- AttributeId = Attributes.Value,
- OverrideValueHandling = OverrideValueHandling.OverrideValue,
- OverrideValue = TypeInfo.GetDefaultVariantValue(fieldMetaData.DataType, ValueRanks.Scalar)
- }
- );
- }
-
- dataSetReaderSimple.SubscribedDataSet = new ExtensionObject(subscribedDataSet);
-
- readerGroup1.DataSetReaders = readerGroup1.DataSetReaders.AddItem(dataSetReaderSimple);
-
- var dataSetReaderAllTypes = new DataSetReaderDataType
- {
- Name = "Reader 2 MQTT JSON RawData Encoding",
- PublisherId = (ushort)2,
- WriterGroupId = 1,
- DataSetWriterId = 2,
- Enabled = true,
- DataSetFieldContentMask = (uint)DataSetFieldContentMask.RawData, // RawData encoding;
- KeyFrameCount = 1
- };
-
- jsonDataSetReaderMessage = new JsonDataSetReaderMessageDataType
- {
- NetworkMessageContentMask = (uint)(
- JsonNetworkMessageContentMask.NetworkMessageHeader |
- JsonNetworkMessageContentMask.DataSetMessageHeader |
- JsonNetworkMessageContentMask.PublisherId |
- JsonNetworkMessageContentMask.DataSetClassId |
- JsonNetworkMessageContentMask.ReplyTo
- ),
- DataSetMessageContentMask = (uint)(
- JsonDataSetMessageContentMask.DataSetWriterId |
- JsonDataSetMessageContentMask.MetaDataVersion |
- JsonDataSetMessageContentMask.SequenceNumber |
- JsonDataSetMessageContentMask.Status |
- JsonDataSetMessageContentMask.Timestamp
- )
- };
- dataSetReaderAllTypes.MessageSettings = new ExtensionObject(jsonDataSetReaderMessage);
-
- brokerTransportSettings = new BrokerDataSetReaderTransportDataType
- {
- QueueName = brokerQueueName,
- RequestedDeliveryGuarantee = BrokerTransportQualityOfService.BestEffort,
- MetaDataQueueName = $"{brokerQueueName}/{brokerMetaData}"
- };
- dataSetReaderAllTypes.TransportSettings = new ExtensionObject(brokerTransportSettings);
-
- // Create and set DataSetMetaData for DataSet AllTypes
- DataSetMetaDataType allTypesMetaData = CreateDataSetMetaDataAllTypes();
- dataSetReaderAllTypes.DataSetMetaData = allTypesMetaData;
- // Create and set SubscribedDataSet
- subscribedDataSet = new TargetVariablesDataType { TargetVariables = [] };
- foreach (FieldMetaData fieldMetaData in allTypesMetaData.Fields)
- {
- subscribedDataSet.TargetVariables = subscribedDataSet.TargetVariables.AddItem(
- new FieldTargetDataType
- {
- DataSetFieldId = fieldMetaData.DataSetFieldId,
- TargetNodeId = new NodeId(fieldMetaData.Name!, NamespaceIndexAllTypes),
- AttributeId = Attributes.Value,
- OverrideValueHandling = OverrideValueHandling.OverrideValue,
- OverrideValue = TypeInfo.GetDefaultVariantValue(fieldMetaData.DataType, ValueRanks.Scalar)
- }
- );
- }
-
- dataSetReaderAllTypes.SubscribedDataSet = new ExtensionObject(subscribedDataSet);
-
- readerGroup1.DataSetReaders = readerGroup1.DataSetReaders.AddItem(dataSetReaderAllTypes);
- pubSubConnection1.ReaderGroups = pubSubConnection1.ReaderGroups.AddItem(readerGroup1);
-
- //create pub sub configuration root object
- return new PubSubConfigurationDataType { Enabled = true, Connections = [pubSubConnection1] };
- }
-
- ///
- /// Creates a Subscriber PubSubConfiguration object for UDP & UADP programmatically.
- ///
- ///
- private static PubSubConfigurationDataType CreateSubscriberConfiguration_MqttUadp(
- string urlAddress)
- {
- // Define a PubSub connection with PublisherId 3
- var pubSubConnection1 = new PubSubConnectionDataType
- {
- Name = "Subscriber Connection MQTT UADP",
- Enabled = true,
- PublisherId = (ushort)3,
- TransportProfileUri = Profiles.PubSubMqttUadpTransport
- };
- var address = new NetworkAddressUrlDataType
- {
- // Specify the local Network interface name to be used
- // e.g. address.NetworkInterface = "Ethernet";
- // Leave empty to subscribe on all available local interfaces.
- NetworkInterface = string.Empty,
- Url = urlAddress
- };
- pubSubConnection1.Address = new ExtensionObject(address);
-
- // Configure the mqtt specific configuration with the MQTTbroker
- var mqttConfiguration = new MqttClientProtocolConfiguration(
- version: EnumMqttProtocolVersion.V500);
- pubSubConnection1.ConnectionProperties = mqttConfiguration.ConnectionProperties;
-
- const string brokerQueueName = "Uadp_WriterGroup_1";
- const string brokerMetaData = "$Metadata";
-
- var readerGroup1 = new ReaderGroupDataType
- {
- Name = "ReaderGroup 1",
- Enabled = true,
- MaxNetworkMessageSize = 1500
- };
-
- var dataSetReaderSimple = new DataSetReaderDataType
- {
- Name = "Reader 1 MQTT UADP",
- PublisherId = (ushort)3,
- WriterGroupId = 0,
- DataSetWriterId = 1,
- Enabled = true,
- DataSetFieldContentMask = (uint)DataSetFieldContentMask.RawData,
- KeyFrameCount = 1
- };
- var brokerTransportSettings = new BrokerDataSetReaderTransportDataType
- {
- QueueName = brokerQueueName,
- MetaDataQueueName = $"{brokerQueueName}/{brokerMetaData}"
- };
-
- dataSetReaderSimple.TransportSettings = new ExtensionObject(brokerTransportSettings);
-
- var uadpDataSetReaderMessage = new UadpDataSetReaderMessageDataType
- {
- GroupVersion = 0,
- NetworkMessageNumber = 0,
- NetworkMessageContentMask = (uint)(
- UadpNetworkMessageContentMask.PublisherId |
- UadpNetworkMessageContentMask.GroupHeader |
- UadpNetworkMessageContentMask.WriterGroupId |
- UadpNetworkMessageContentMask.PayloadHeader |
- UadpNetworkMessageContentMask.GroupVersion |
- UadpNetworkMessageContentMask.NetworkMessageNumber |
- UadpNetworkMessageContentMask.SequenceNumber
- ),
- DataSetMessageContentMask = (uint)(
- UadpDataSetMessageContentMask.Status |
- UadpDataSetMessageContentMask.SequenceNumber
- )
- };
- dataSetReaderSimple.MessageSettings = new ExtensionObject(uadpDataSetReaderMessage);
-
- // Create and set DataSetMetaData for DataSet Simple
- DataSetMetaDataType simpleMetaData = CreateDataSetMetaDataSimple();
- dataSetReaderSimple.DataSetMetaData = simpleMetaData;
- // Create and set SubscribedDataSet
- var subscribedDataSet = new TargetVariablesDataType { TargetVariables = [] };
- foreach (FieldMetaData fieldMetaData in simpleMetaData.Fields)
- {
- subscribedDataSet.TargetVariables = subscribedDataSet.TargetVariables.AddItem(
- new FieldTargetDataType
- {
- DataSetFieldId = fieldMetaData.DataSetFieldId,
- TargetNodeId = new NodeId(fieldMetaData.Name!, NamespaceIndexSimple),
- AttributeId = Attributes.Value,
- OverrideValueHandling = OverrideValueHandling.OverrideValue,
- OverrideValue = TypeInfo.GetDefaultVariantValue(fieldMetaData.DataType, ValueRanks.Scalar)
- }
- );
- }
-
- dataSetReaderSimple.SubscribedDataSet = new ExtensionObject(subscribedDataSet);
-
- readerGroup1.DataSetReaders = readerGroup1.DataSetReaders.AddItem(dataSetReaderSimple);
-
- var dataSetReaderAllTypes = new DataSetReaderDataType
- {
- Name = "Reader 2 MQTT UADP",
- PublisherId = (ushort)3,
- WriterGroupId = 0,
- DataSetWriterId = 2,
- Enabled = true,
- DataSetFieldContentMask = (uint)DataSetFieldContentMask.RawData,
- KeyFrameCount = 1,
-
- TransportSettings = new ExtensionObject(brokerTransportSettings)
- };
-
- uadpDataSetReaderMessage = new UadpDataSetReaderMessageDataType
- {
- GroupVersion = 0,
- NetworkMessageNumber = 0,
- NetworkMessageContentMask = (uint)(
- UadpNetworkMessageContentMask.PublisherId |
- UadpNetworkMessageContentMask.GroupHeader |
- UadpNetworkMessageContentMask.WriterGroupId |
- UadpNetworkMessageContentMask.PayloadHeader |
- UadpNetworkMessageContentMask.GroupVersion |
- UadpNetworkMessageContentMask.NetworkMessageNumber |
- UadpNetworkMessageContentMask.SequenceNumber
- ),
- DataSetMessageContentMask = (uint)(
- UadpDataSetMessageContentMask.Status |
- UadpDataSetMessageContentMask.SequenceNumber
- )
- };
- dataSetReaderAllTypes.MessageSettings = new ExtensionObject(uadpDataSetReaderMessage);
-
- // Create and set DataSetMetaData for DataSet AllTypes
- DataSetMetaDataType allTypesMetaData = CreateDataSetMetaDataAllTypes();
- dataSetReaderAllTypes.DataSetMetaData = allTypesMetaData;
- // Create and set SubscribedDataSet
- subscribedDataSet = new TargetVariablesDataType { TargetVariables = [] };
- foreach (FieldMetaData fieldMetaData in allTypesMetaData.Fields)
- {
- subscribedDataSet.TargetVariables = subscribedDataSet.TargetVariables.AddItem(
- new FieldTargetDataType
- {
- DataSetFieldId = fieldMetaData.DataSetFieldId,
- TargetNodeId = new NodeId(fieldMetaData.Name!, NamespaceIndexAllTypes),
- AttributeId = Attributes.Value,
- OverrideValueHandling = OverrideValueHandling.OverrideValue,
- OverrideValue = TypeInfo.GetDefaultVariantValue(fieldMetaData.DataType, ValueRanks.Scalar)
- }
- );
- }
-
- dataSetReaderAllTypes.SubscribedDataSet = new ExtensionObject(subscribedDataSet);
-
- readerGroup1.DataSetReaders = readerGroup1.DataSetReaders.AddItem(dataSetReaderAllTypes);
- pubSubConnection1.ReaderGroups = pubSubConnection1.ReaderGroups.AddItem(readerGroup1);
-
- //create pub sub configuration root object
- return new PubSubConfigurationDataType { Enabled = true, Connections = [pubSubConnection1] };
- }
-
- ///
- /// Creates the "Simple" DataSetMetaData
- ///
- ///
- private static DataSetMetaDataType CreateDataSetMetaDataSimple()
- {
- return new DataSetMetaDataType
- {
- DataSetClassId = Uuid.Empty,
- Name = "Simple",
- Fields =
- [
- new FieldMetaData
- {
- Name = "BoolToggle",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Boolean,
- DataType = DataTypeIds.Boolean,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Int32",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Int32,
- DataType = DataTypeIds.Int32,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Int32Fast",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Int32,
- DataType = DataTypeIds.Int32,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "DateTime",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.DateTime,
- DataType = DataTypeIds.DateTime,
- ValueRank = ValueRanks.Scalar
- }
- ],
- // set the ConfigurationVersion relative to kTimeOfConfiguration constant
- ConfigurationVersion = new ConfigurationVersionDataType
- {
- MinorVersion = ConfigurationVersionUtils.CalculateVersionTime(
- s_timeOfConfiguration),
- MajorVersion = ConfigurationVersionUtils.CalculateVersionTime(
- s_timeOfConfiguration)
- }
- };
- }
-
- ///
- /// Creates the "AllTypes" DataSetMetaData
- ///
- ///
- private static DataSetMetaDataType CreateDataSetMetaDataAllTypes()
- {
- return new DataSetMetaDataType
- {
- DataSetClassId = Uuid.Empty,
- Name = "AllTypes",
- Fields =
- [
- new FieldMetaData
- {
- Name = "BoolToggle",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Boolean,
- DataType = DataTypeIds.Boolean,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Byte",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Byte,
- DataType = DataTypeIds.Byte,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Int16",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Int16,
- DataType = DataTypeIds.Int16,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Int32",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Int32,
- DataType = DataTypeIds.Int32,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "SByte",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.SByte,
- DataType = DataTypeIds.SByte,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "UInt16",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.UInt16,
- DataType = DataTypeIds.UInt16,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "UInt32",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.UInt32,
- DataType = DataTypeIds.UInt32,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "UInt64",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.UInt64,
- DataType = DataTypeIds.UInt64,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Float",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Float,
- DataType = DataTypeIds.Float,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Double",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Double,
- DataType = DataTypeIds.Double,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "String",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.String,
- DataType = DataTypeIds.String,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "ByteString",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.ByteString,
- DataType = DataTypeIds.ByteString,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "Guid",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.Guid,
- DataType = DataTypeIds.Guid,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "DateTime",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.DateTime,
- DataType = DataTypeIds.DateTime,
- ValueRank = ValueRanks.Scalar
- },
- new FieldMetaData
- {
- Name = "UInt32Array",
- DataSetFieldId = Uuid.NewUuid(),
- BuiltInType = (byte)DataTypes.UInt32,
- DataType = DataTypeIds.UInt32,
- ValueRank = ValueRanks.OneDimension
- }
- ],
- // set the ConfigurationVersion relative to kTimeOfConfiguration constant
- ConfigurationVersion = new ConfigurationVersionDataType
- {
- MinorVersion = ConfigurationVersionUtils.CalculateVersionTime(
- s_timeOfConfiguration),
- MajorVersion = ConfigurationVersionUtils.CalculateVersionTime(
- s_timeOfConfiguration)
- }
- };
- }
- }
-}
diff --git a/Applications/ConsoleReferenceSubscriber/README.md b/Applications/ConsoleReferenceSubscriber/README.md
deleted file mode 100644
index 8c638b37bf..0000000000
--- a/Applications/ConsoleReferenceSubscriber/README.md
+++ /dev/null
@@ -1,108 +0,0 @@
-
-# OPC Foundation UA .NET Standard Library - Console Reference Subscriber
-
-## Introduction
-
-This OPC application was created to provide the sample code for creating Subscriber applications using the OPC Foundation UA .NET Standard PubSub Library. There is a .NET Core 3.1 (2.1) console version of the Subscriber which runs on any OS supporting [.NET Standard](https://docs.microsoft.com/en-us/dotnet/articles/standard).
-The Reference Subscriber is configured to run in parallel with the [Console Reference Publisher](../ConsoleReferencePublisher/README.md)
-
-## How to build and run the Windows OPC UA Reference Server from Visual Studio
-
-1. Open the solution **UA.slnx** with Visual Studio 2026.
-2. Choose the project `ConsoleReferenceSubscriber` in the Solution Explorer and set it with a right click as `Startup Project`.
-3. Hit `F5` to build and execute the sample.
-
-## How to build and run the console OPC UA Reference Subscriber on Windows, Linux and iOS
-
-This section describes how to run the **ConsoleReferenceSubscriber**.
-
-Please follow instructions in this [article](https://aka.ms/dotnetcoregs) to setup the dotnet command line environment for your platform.
-
-## Start the Subscriber
-
-1. Open a command prompt.
-2. Navigate to the folder **Applications/ConsoleReferenceSubscriber**.
-3. To run the Subscriber sample type
-
-`dotnet run --project ConsoleReferenceSubscriber.csproj --framework net10.0.`
-
-The Subscriber will start and listen for network messages sent by the Reference Publisher.
-
-## Command Line Arguments for *ConsoleReferenceSubscriber*
-
- **ConsoleReferenceSubscriber** can be executed using the following command line arguments:
-
-- -h|help - Shows usage information
-- -m|mqtt_json - Creates a connection using there MQTT with Json encoding Profile. This is the default option.
-- -u|udp_uadp - Creates a connection using there UDP with UADP encoding Profile.
-
-To run the Subscriber sample using a connection with MQTT with Json encoding execute:
-
- dotnet run --project ConsoleReferenceSubscriber.csproj --framework net10.0
-
- or
-
- dotnet run --project ConsoleReferenceSubscriber.csproj --framework net10.0 -m
-
-To run the Subscriber sample using a connection with the UDP with UADP encoding execute:
-
- dotnet run --project ConsoleReferenceSubscriber.csproj --framework net10.0 -u
-
-## Programmer's Guide
-
-To create a new OPC UA Subscriber application:
-
-- Open Microsoft Visual Studio 2019 environment,
-- Create a new project and give it a name,
-- Add a reference to the [OPCFoundation.NetStandard.Opc.Ua.PubSub NuGet package](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.PubSub/),
-- Initialize Subscriber application (see [Subscriber Initialization](#subscriber-initialization)).
-
-### Subscriber Initialization
-
-The following four steps are required to implement a functional Subscriber:
-
- 1. Create [Subscriber Configuration](#subscriber-configuration).
-
- ```csharp
- // Create configuration using UDP protocol and UADP Encoding
- PubSubConfigurationDataType pubSubConfiguration = CreateSubscriberConfiguration_UdpUadp();
- ```
-
- Or use the alternative configuration object for MQTT with JSON encoding
-
- ```csharp
- // Create configuration using MQTT protocol and JSON Encoding
- PubSubConfigurationDataType pubSubConfiguration = CreateSubscriberConfiguration_MqttJson();
- ```
-
- The CreateSubscriberConfiguration methods can be found in [ConsoleReferenceSubscriber/Program.cs](./Program.cs) file.
-
- 2. Create an instance of the [UaPubSubApplication Class](../../Docs/PubSub.md#uapubsubapplication-class) using the configuration data from step 1.
-
- ```csharp
- // Subscribe to data events
- UaPubSubApplication uaPubSubApplication = UaPubSubApplication.Create(pubSubConfiguration);
- ```
-
- 3. Provide the event handler for the *DataReceived* event. This event will be raised when data sets matching the subscriber configuration arrive over the network. See the DataReceived Event section for more details.
-
- ```csharp
- // Create an instance of UaPubSubApplication
- uaPubSubApplication.DataReceived += PubSubApplication_DataReceived;
- ```
-
- 4. Start PubSub application
-
- ```csharp
- // Start the publisher
- uaPubSubApplication.Start();
- ```
-
-After this step the *Subscriber* will listen for *NetworkMessages* as configured.
-
-### Subscriber Configuration
-
-The Subscriber configuration is a subset of the [PubSub Configuration](../../Docs/PubSub.md#pubsub-configuration). A functional *Subscriber* application needs to have a configuration (*PubSubConfigurationDataType* instance) that contains at least one connection (*PubSubConnectionDataType* instance) with at least one reader group configuration (*ReaderGroupDataType* instance). The reader group contains at least one data set reader (*DataSetReaderDataType* instance) that describes a published data set that can be processed and retrieved by the *Subscriber* application.
-The diagram shows the subset of classes involved in an *OPC UA Publisher* configuration.
-
-
diff --git a/Applications/McpServer/Opc.Ua.Mcp.csproj b/Applications/McpServer/Opc.Ua.Mcp.csproj
index 762a74b1f3..7d8ede6136 100644
--- a/Applications/McpServer/Opc.Ua.Mcp.csproj
+++ b/Applications/McpServer/Opc.Ua.Mcp.csproj
@@ -36,6 +36,7 @@
+
diff --git a/Applications/McpServer/Program.cs b/Applications/McpServer/Program.cs
index 2b4e6c8315..42ffd7d77a 100644
--- a/Applications/McpServer/Program.cs
+++ b/Applications/McpServer/Program.cs
@@ -37,6 +37,7 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Opc.Ua.Pcap.DependencyInjection;
+using Opc.Ua.PubSub.Pcap;
using Opc.Ua.Mcp;
using Opc.Ua.Mcp.Tools;
@@ -134,6 +135,7 @@ static void ConfigureServices(IServiceCollection services, PcapOptions pcapOptio
{
services.AddOpcUa().AddClient(options => { });
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton(_ => CreateMcpServerOptions());
services.AddPcap(options =>
{
@@ -143,6 +145,7 @@ static void ConfigureServices(IServiceCollection services, PcapOptions pcapOptio
});
services.AddPcapFormatters();
services.AddPcapReplay();
+ services.AddPubSubPcap();
}
static McpServerOptions CreateMcpServerOptions()
@@ -194,6 +197,10 @@ static void ConfigureMcpTools(IMcpServerBuilder mcpServerBuilder, bool diagnosti
.WithTools()
.WithTools()
.WithTools()
+ .WithTools()
+ .WithTools()
+ .WithTools()
+ .WithTools()
.WithTools()
.WithTools();
@@ -201,7 +208,8 @@ static void ConfigureMcpTools(IMcpServerBuilder mcpServerBuilder, bool diagnosti
{
mcpServerBuilder
.WithTools()
- .WithTools();
+ .WithTools()
+ .WithTools();
}
mcpServerBuilder.WithResources();
diff --git a/Applications/McpServer/PubSubRuntimeManager.cs b/Applications/McpServer/PubSubRuntimeManager.cs
new file mode 100644
index 0000000000..d4b09f51b3
--- /dev/null
+++ b/Applications/McpServer/PubSubRuntimeManager.cs
@@ -0,0 +1,1243 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Opc.Ua.PubSub.Application;
+using Opc.Ua.PubSub.Configuration;
+using Opc.Ua.PubSub.DataSets;
+using Opc.Ua.PubSub.Encoding;
+using Opc.Ua.PubSub.Encoding.Uadp;
+using Opc.Ua.PubSub.Transports;
+using Opc.Ua.PubSub.Udp;
+
+namespace Opc.Ua.Mcp
+{
+ ///
+ /// Manages one in-process OPC UA PubSub publisher or subscriber for MCP tools.
+ ///
+ public sealed partial class PubSubRuntimeManager : IAsyncDisposable
+ {
+ private const string DataSetName = "McpDataSet";
+ private const string WriterName = "Writer 1";
+ private const string ReaderName = "Reader 1";
+ private const ushort DataSetWriterId = 1;
+ private const int DefaultPublishingIntervalMs = 100;
+ private const int DefaultRingCapacity = 64;
+
+ private readonly IServiceProvider m_services;
+ private readonly ILogger m_logger;
+ private readonly SemaphoreSlim m_gate = new(1, 1);
+ private IPubSubApplication? m_application;
+ private MutablePublishedDataSetSource? m_source;
+ private BufferedSubscribedDataSetSink? m_sink;
+ private readonly List m_actionResponders = [];
+ private PubSubRuntimeMode m_mode = PubSubRuntimeMode.Stopped;
+ private string m_endpoint = string.Empty;
+ private ushort m_publisherId;
+ private ushort m_writerGroupId;
+
+ ///
+ /// Initializes a new .
+ ///
+ public PubSubRuntimeManager(IServiceProvider services, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ m_services = services;
+ m_logger = logger;
+ }
+
+ ///
+ /// Starts an in-process UDP/UADP publisher.
+ ///
+ public async ValueTask StartPublisherAsync(
+ string endpoint,
+ ushort publisherId,
+ ushort writerGroupId,
+ string? fieldSpec,
+ CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(endpoint);
+
+ List fields = ParseFieldSpec(fieldSpec);
+ var source = new MutablePublishedDataSetSource(DataSetName, fields);
+ IPubSubApplication app = CreateBuilder("urn:opcfoundation:OpcUaMcp:PubSubPublisher")
+ .AddDataSetSource(DataSetName, source)
+ .UseConfiguration(BuildPublisherConfiguration(endpoint, publisherId, writerGroupId, fields))
+ .Build();
+
+ await ReplaceAndStartAsync(
+ app,
+ PubSubRuntimeMode.Publisher,
+ endpoint,
+ publisherId,
+ writerGroupId,
+ source,
+ sink: null,
+ ct).ConfigureAwait(false);
+
+ return await StatusAsync(ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Starts an in-process UDP/UADP subscriber with a bounded receive buffer.
+ ///
+ public async ValueTask StartSubscriberAsync(
+ string endpoint,
+ ushort publisherId,
+ ushort writerGroupId,
+ string? fieldSpec,
+ CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(endpoint);
+
+ List fields = ParseFieldSpec(fieldSpec);
+ var sink = new BufferedSubscribedDataSetSink(DefaultRingCapacity);
+ IPubSubApplication app = CreateBuilder("urn:opcfoundation:OpcUaMcp:PubSubSubscriber")
+ .AddSubscribedDataSetSink(ReaderName, sink)
+ .UseConfiguration(BuildSubscriberConfiguration(endpoint, publisherId, writerGroupId, fields))
+ .Build();
+
+ await ReplaceAndStartAsync(
+ app,
+ PubSubRuntimeMode.Subscriber,
+ endpoint,
+ publisherId,
+ writerGroupId,
+ source: null,
+ sink,
+ ct).ConfigureAwait(false);
+
+ return await StatusAsync(ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Updates the active publisher's next sampled DataSet fields.
+ ///
+ public async ValueTask PublishAsync(
+ string fieldValues,
+ CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(fieldValues);
+
+ await m_gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ if (m_mode != PubSubRuntimeMode.Publisher || m_source is null)
+ {
+ throw new InvalidOperationException("Start a publisher with pubsub_runtime_start_publisher first.");
+ }
+
+ ArrayOf values = await m_source.UpdateAsync(fieldValues, ct)
+ .ConfigureAwait(false);
+ return new PubSubRuntimePublishResult
+ {
+ Mode = m_mode.ToString(),
+ Endpoint = m_endpoint,
+ PublisherId = m_publisherId,
+ WriterGroupId = m_writerGroupId,
+ Fields = values,
+ Message = "Field values updated; the publisher sends them on the next publishing interval."
+ };
+ }
+ finally
+ {
+ m_gate.Release();
+ }
+ }
+
+ ///
+ /// Reads buffered DataSets from the active subscriber.
+ ///
+ public async ValueTask> ReadReceivedAsync(
+ bool clear,
+ CancellationToken ct = default)
+ {
+ await m_gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ return m_mode == PubSubRuntimeMode.Subscriber && m_sink is not null
+ ? await m_sink.ReadAsync(clear, ct).ConfigureAwait(false)
+ : [];
+ }
+ finally
+ {
+ m_gate.Release();
+ }
+ }
+
+ ///
+ /// Returns the current runtime status.
+ ///
+ public async ValueTask StatusAsync(CancellationToken ct = default)
+ {
+ await m_gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ return CreateStatus();
+ }
+ finally
+ {
+ m_gate.Release();
+ }
+ }
+
+ ///
+ /// Sends a PubSub discovery request from the active runtime and
+ /// collects the publisher responses within the timeout.
+ ///
+ /// The discovery request.
+ /// How long to collect responses.
+ /// Cancellation token.
+ /// The collected discovery result.
+ public async ValueTask RequestDiscoveryAsync(
+ PubSubDiscoveryRequest request,
+ TimeSpan timeout,
+ CancellationToken ct = default)
+ {
+ IPubSubApplication app;
+ await m_gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ app = m_application ?? throw new InvalidOperationException(
+ "No PubSub runtime is active. Start a publisher or subscriber first.");
+ }
+ finally
+ {
+ m_gate.Release();
+ }
+ return await app.RequestDiscoveryAsync(request, timeout, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Sends a PubSub Action request from the active runtime and awaits the correlated response.
+ ///
+ /// The Action request.
+ /// How long to wait for the response.
+ /// Cancellation token.
+ /// The correlated Action response.
+ public async ValueTask InvokeActionAsync(
+ PubSubActionRequest request,
+ TimeSpan timeout,
+ CancellationToken ct = default)
+ {
+ IPubSubApplication app;
+ await m_gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ app = m_application ?? throw new InvalidOperationException(
+ "No PubSub runtime is active. Start a publisher or subscriber first.");
+ }
+ finally
+ {
+ m_gate.Release();
+ }
+ return await app.InvokeActionAsync(request, timeout, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Registers a responder-side Action handler on the active runtime.
+ ///
+ /// The Action target.
+ /// The Action handler.
+ /// The JSON-friendly responder kind.
+ /// The JSON-friendly responder details.
+ /// Cancellation token.
+ /// The registered responder information.
+ public async ValueTask RegisterActionResponderAsync(
+ PubSubActionTarget target,
+ IPubSubActionHandler handler,
+ string responderKind,
+ string details,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ ArgumentNullException.ThrowIfNull(handler);
+ ArgumentException.ThrowIfNullOrWhiteSpace(responderKind);
+
+ var registration = new PubSubActionResponderRegistration(
+ target.ConnectionName,
+ target.DataSetWriterId,
+ target.ActionTargetId,
+ target.ActionName,
+ responderKind,
+ details ?? string.Empty);
+
+ await m_gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ IPubSubApplication app = m_application ?? throw new InvalidOperationException(
+ "No PubSub runtime is active. Start a publisher or subscriber first.");
+ // The MCP runtime is a local diagnostic surface that binds Action
+ // responders onto connections that are typically unsecured, so it
+ // opts in explicitly (SA-ACT-01). This makes serving Action requests
+ // without message security a deliberate, auditable choice rather than
+ // a silent default.
+ app.RegisterActionHandler(target, handler, allowUnsecured: true);
+ m_actionResponders.RemoveAll(item => item.MatchesTarget(registration));
+ m_actionResponders.Add(registration);
+ return registration;
+ }
+ finally
+ {
+ m_gate.Release();
+ }
+ }
+
+ ///
+ /// Lists Action targets known from the active configuration and registered responders.
+ ///
+ /// Cancellation token.
+ /// The JSON-friendly Action target list.
+ public async ValueTask> ListActionTargetsAsync(
+ CancellationToken ct = default)
+ {
+ await m_gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ var targets = new List();
+ if (m_application is not null)
+ {
+ AddConfiguredActionTargets(m_application.GetConfiguration(), targets);
+ }
+
+ foreach (PubSubActionResponderRegistration registration in m_actionResponders)
+ {
+ if (!targets.Any(registration.MatchesTarget))
+ {
+ targets.Add(registration.ToTargetInfo("registered-responder", string.Empty, string.Empty));
+ }
+ }
+
+ return [.. targets];
+ }
+ finally
+ {
+ m_gate.Release();
+ }
+ }
+
+ ///
+ /// Lists Action responders registered through MCP.
+ ///
+ /// Cancellation token.
+ /// The JSON-friendly responder list.
+ public async ValueTask> ListActionRespondersAsync(
+ CancellationToken ct = default)
+ {
+ await m_gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ return [.. m_actionResponders];
+ }
+ finally
+ {
+ m_gate.Release();
+ }
+ }
+
+ ///
+ /// Stops and disposes the active PubSub application.
+ ///
+ public async ValueTask StopAsync(CancellationToken ct = default)
+ {
+ await m_gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ await StopCurrentAsync(ct).ConfigureAwait(false);
+ return CreateStatus();
+ }
+ finally
+ {
+ m_gate.Release();
+ }
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await StopAsync(CancellationToken.None).ConfigureAwait(false);
+ m_gate.Dispose();
+ }
+
+ private async ValueTask ReplaceAndStartAsync(
+ IPubSubApplication app,
+ PubSubRuntimeMode mode,
+ string endpoint,
+ ushort publisherId,
+ ushort writerGroupId,
+ MutablePublishedDataSetSource? source,
+ BufferedSubscribedDataSetSink? sink,
+ CancellationToken ct)
+ {
+ await m_gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ await StopCurrentAsync(ct).ConfigureAwait(false);
+ await app.StartAsync(ct).ConfigureAwait(false);
+ m_application = app;
+ m_mode = mode;
+ m_endpoint = endpoint;
+ m_publisherId = publisherId;
+ m_writerGroupId = writerGroupId;
+ m_source = source;
+ m_sink = sink;
+ }
+ catch
+ {
+ await app.DisposeAsync().ConfigureAwait(false);
+ throw;
+ }
+ finally
+ {
+ m_gate.Release();
+ }
+ }
+
+ private PubSubApplicationBuilder CreateBuilder(string applicationId)
+ {
+ var telemetry = new ServiceProviderTelemetryContext(m_services);
+ return new PubSubApplicationBuilder(telemetry)
+ .WithApplicationId(applicationId)
+ .AddTransportFactory(new UdpPubSubTransportFactory(
+ Options.Create(new UdpTransportOptions())))
+ .AddEncoder(new UadpEncoder())
+ .AddDecoder(new UadpDecoder());
+ }
+
+ private async ValueTask StopCurrentAsync(CancellationToken ct)
+ {
+ IPubSubApplication? app = m_application;
+ MutablePublishedDataSetSource? source = m_source;
+ BufferedSubscribedDataSetSink? sink = m_sink;
+ m_application = null;
+ m_source = null;
+ m_sink = null;
+ m_mode = PubSubRuntimeMode.Stopped;
+ m_endpoint = string.Empty;
+ m_publisherId = 0;
+ m_writerGroupId = 0;
+ m_actionResponders.Clear();
+
+ if (app is null)
+ {
+ return;
+ }
+
+ try
+ {
+ await app.StopAsync(ct).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ m_logger.LogWarning(ex, "Stopping the PubSub runtime failed.");
+ }
+
+ await app.DisposeAsync().ConfigureAwait(false);
+ source?.Dispose();
+ sink?.Dispose();
+ }
+
+ private PubSubRuntimeStatus CreateStatus()
+ {
+ return new PubSubRuntimeStatus
+ {
+ Mode = m_mode.ToString(),
+ IsRunning = m_application is not null,
+ Endpoint = m_endpoint,
+ PublisherId = m_publisherId,
+ WriterGroupId = m_writerGroupId,
+ BufferedDataSetCount = m_sink?.Count ?? 0
+ };
+ }
+
+ private static void AddConfiguredActionTargets(
+ PubSubConfigurationDataType configuration,
+ List targets)
+ {
+ var actionDataSets = new Dictionary(StringComparer.Ordinal);
+ if (!configuration.PublishedDataSets.IsNull)
+ {
+ foreach (PublishedDataSetDataType publishedDataSet in configuration.PublishedDataSets)
+ {
+ if (publishedDataSet is not null
+ && TryGetPublishedAction(publishedDataSet, out PublishedActionDataType? action))
+ {
+ actionDataSets[publishedDataSet.Name ?? string.Empty] = action!;
+ }
+ }
+ }
+
+ if (configuration.Connections.IsNull)
+ {
+ return;
+ }
+
+ foreach (PubSubConnectionDataType connection in configuration.Connections)
+ {
+ if (connection?.WriterGroups.IsNull != false)
+ {
+ continue;
+ }
+
+ foreach (WriterGroupDataType writerGroup in connection.WriterGroups)
+ {
+ if (writerGroup?.DataSetWriters.IsNull != false)
+ {
+ continue;
+ }
+
+ foreach (DataSetWriterDataType writer in writerGroup.DataSetWriters)
+ {
+ string dataSetName = writer?.DataSetName ?? string.Empty;
+ if (writer is null
+ || !actionDataSets.TryGetValue(dataSetName, out PublishedActionDataType? action)
+ || action.ActionTargets.IsNull)
+ {
+ continue;
+ }
+
+ foreach (ActionTargetDataType actionTarget in action.ActionTargets)
+ {
+ if (actionTarget is null)
+ {
+ continue;
+ }
+
+ targets.Add(new PubSubActionTargetInfo(
+ connection.Name ?? string.Empty,
+ writer.DataSetWriterId,
+ actionTarget.ActionTargetId,
+ actionTarget.Name ?? string.Empty,
+ writer.Name ?? string.Empty,
+ dataSetName,
+ actionTarget.Description.IsNull
+ ? string.Empty
+ : actionTarget.Description.Text ?? string.Empty,
+ "configuration"));
+ }
+ }
+ }
+ }
+ }
+
+ private static bool TryGetPublishedAction(
+ PublishedDataSetDataType publishedDataSet,
+ out PublishedActionDataType? action)
+ {
+ action = null;
+ if (publishedDataSet.DataSetSource.IsNull)
+ {
+ return false;
+ }
+
+ if (publishedDataSet.DataSetSource.TryGetValue(out PublishedActionMethodDataType? methodAction))
+ {
+ action = methodAction;
+ return true;
+ }
+
+ if (publishedDataSet.DataSetSource.TryGetValue(out PublishedActionDataType? publishedAction))
+ {
+ action = publishedAction;
+ return true;
+ }
+
+ return false;
+ }
+
+ private static PubSubConfigurationDataType BuildPublisherConfiguration(
+ string endpoint,
+ ushort publisherId,
+ ushort writerGroupId,
+ List fields)
+ {
+ return PubSubConfigurationBuilder.Create()
+ .AddPublishedDataSet(DataSetName, ds =>
+ {
+ foreach (RuntimeFieldDefinition field in fields)
+ {
+ ds.AddField(field.Name, field.BuiltInType, field.DataTypeId);
+ }
+ })
+ .AddConnection("MCP Publisher Connection", connection =>
+ {
+ connection
+ .WithPublisherId(new Variant(publisherId))
+ .WithTransportProfile(Profiles.PubSubUdpUadpTransport)
+ .WithAddress(endpoint)
+ .AddWriterGroup("WriterGroup 1", group => group
+ .WithWriterGroupId(writerGroupId)
+ .WithPublishingInterval(DefaultPublishingIntervalMs)
+ .WithMessageSettings(CreateWriterGroupMessageSettings())
+ .WithTransportSettings(new DatagramWriterGroupTransportDataType())
+ .AddDataSetWriter(WriterName, writer => writer
+ .WithDataSetWriterId(DataSetWriterId)
+ .WithDataSetName(DataSetName)
+ .WithKeyFrameCount(1)
+ .WithFieldContentMask(DataSetFieldContentMask.RawData)
+ .WithMessageSettings(CreateWriterMessageSettings())));
+ })
+ .Build();
+ }
+
+ private static PubSubConfigurationDataType BuildSubscriberConfiguration(
+ string endpoint,
+ ushort publisherId,
+ ushort writerGroupId,
+ List fields)
+ {
+ return PubSubConfigurationBuilder.Create()
+ .AddConnection("MCP Subscriber Connection", connection =>
+ {
+ connection
+ .WithPublisherId(new Variant(publisherId))
+ .WithTransportProfile(Profiles.PubSubUdpUadpTransport)
+ .WithAddress(endpoint)
+ .AddReaderGroup("ReaderGroup 1", group => group
+ .WithMaxNetworkMessageSize(1500)
+ .AddDataSetReader(ReaderName, reader =>
+ {
+ reader
+ .WithFilter(new Variant(publisherId), writerGroupId, DataSetWriterId)
+ .WithFieldContentMask(DataSetFieldContentMask.RawData)
+ .WithMessageReceiveTimeout(5000)
+ .WithMessageSettings(CreateReaderMessageSettings())
+ .WithMirrorSubscribedDataSet(ReaderName)
+ .WithDataSetMetaData(DataSetName, metaData =>
+ {
+ metaData.WithoutFieldIds();
+ foreach (RuntimeFieldDefinition field in fields)
+ {
+ metaData.AddField(field.Name, field.BuiltInType, field.DataTypeId);
+ }
+ });
+ }));
+ })
+ .Build();
+ }
+
+ private static UadpWriterGroupMessageDataType CreateWriterGroupMessageSettings()
+ {
+ return new UadpWriterGroupMessageDataType
+ {
+ DataSetOrdering = DataSetOrderingType.AscendingWriterId,
+ NetworkMessageContentMask = (uint)(
+ UadpNetworkMessageContentMask.PublisherId
+ | UadpNetworkMessageContentMask.GroupHeader
+ | UadpNetworkMessageContentMask.WriterGroupId
+ | UadpNetworkMessageContentMask.PayloadHeader
+ | UadpNetworkMessageContentMask.NetworkMessageNumber
+ | UadpNetworkMessageContentMask.SequenceNumber)
+ };
+ }
+
+ private static UadpDataSetWriterMessageDataType CreateWriterMessageSettings()
+ {
+ return new UadpDataSetWriterMessageDataType
+ {
+ DataSetMessageContentMask = (uint)(
+ UadpDataSetMessageContentMask.Status
+ | UadpDataSetMessageContentMask.SequenceNumber)
+ };
+ }
+
+ private static UadpDataSetReaderMessageDataType CreateReaderMessageSettings()
+ {
+ return new UadpDataSetReaderMessageDataType
+ {
+ NetworkMessageContentMask = (uint)(
+ UadpNetworkMessageContentMask.PublisherId
+ | UadpNetworkMessageContentMask.GroupHeader
+ | UadpNetworkMessageContentMask.WriterGroupId
+ | UadpNetworkMessageContentMask.PayloadHeader
+ | UadpNetworkMessageContentMask.NetworkMessageNumber
+ | UadpNetworkMessageContentMask.SequenceNumber),
+ DataSetMessageContentMask = (uint)(
+ UadpDataSetMessageContentMask.Status
+ | UadpDataSetMessageContentMask.SequenceNumber)
+ };
+ }
+
+ private static List ParseFieldSpec(string? fieldSpec)
+ {
+ if (string.IsNullOrWhiteSpace(fieldSpec))
+ {
+ return
+ [
+ RuntimeFieldDefinition.Create("BoolToggle", "Boolean"),
+ RuntimeFieldDefinition.Create("Int32", "Int32"),
+ RuntimeFieldDefinition.Create("DateTime", "DateTime")
+ ];
+ }
+
+ var fields = new List();
+ foreach (string entry in fieldSpec.Split(
+ [';', ','],
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ string[] parts = entry.Split(':', 2, StringSplitOptions.TrimEntries);
+ fields.Add(RuntimeFieldDefinition.Create(parts[0], parts.Length == 2 ? parts[1] : "String"));
+ }
+
+ if (fields.Count == 0)
+ {
+ throw new ArgumentException("At least one field must be specified.", nameof(fieldSpec));
+ }
+
+ return fields;
+ }
+
+ private sealed class MutablePublishedDataSetSource : IPublishedDataSetSource, IDisposable
+ {
+ private readonly string m_name;
+ private readonly List m_definitions;
+ private readonly Dictionary m_definitionByName;
+ private readonly Dictionary m_values;
+ private readonly SemaphoreSlim m_valueGate = new(1, 1);
+ private uint m_minorVersion;
+
+ public MutablePublishedDataSetSource(string name, List definitions)
+ {
+ m_name = name;
+ m_definitions = definitions;
+ m_definitionByName = definitions.ToDictionary(field => field.Name, StringComparer.Ordinal);
+ m_values = definitions.ToDictionary(
+ field => field.Name,
+ field => field.CreateDefaultValue(),
+ StringComparer.Ordinal);
+ }
+
+ public DataSetMetaDataType BuildMetaData()
+ {
+ var fields = new List();
+ foreach (RuntimeFieldDefinition field in m_definitions)
+ {
+ fields.Add(new FieldMetaData
+ {
+ Name = field.Name,
+ BuiltInType = field.BuiltInType,
+ DataType = field.DataTypeId,
+ ValueRank = ValueRanks.Scalar
+ });
+ }
+
+ return new DataSetMetaDataType
+ {
+ Name = m_name,
+ DataSetClassId = Uuid.Empty,
+ Fields = new ArrayOf(fields.ToArray()),
+ ConfigurationVersion = new ConfigurationVersionDataType
+ {
+ MajorVersion = 1,
+ MinorVersion = m_minorVersion
+ }
+ };
+ }
+
+ public async ValueTask SampleAsync(
+ DataSetMetaDataType metaData,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await m_valueGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var fields = new List();
+ foreach (RuntimeFieldDefinition definition in m_definitions)
+ {
+ fields.Add(new DataSetField
+ {
+ Name = definition.Name,
+ Value = m_values[definition.Name]
+ });
+ }
+
+ ConfigurationVersionDataType version = metaData?.ConfigurationVersion
+ ?? new ConfigurationVersionDataType { MajorVersion = 1, MinorVersion = m_minorVersion };
+ return new PublishedDataSetSnapshot(
+ version,
+ new ArrayOf(fields.ToArray()),
+ DateTimeUtc.From(DateTimeOffset.UtcNow));
+ }
+ finally
+ {
+ m_valueGate.Release();
+ }
+ }
+
+ public async ValueTask> UpdateAsync(
+ string fieldValues,
+ CancellationToken ct)
+ {
+ Dictionary parsed = ParseFieldValues(fieldValues);
+ await m_valueGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ foreach (KeyValuePair item in parsed)
+ {
+ if (!m_definitionByName.TryGetValue(item.Key, out RuntimeFieldDefinition? definition))
+ {
+ throw new ArgumentException(
+ $"Unknown field '{item.Key}'. Add it to fieldSpec before starting the publisher.",
+ nameof(fieldValues));
+ }
+ m_values[item.Key] = definition.ParseValue(item.Value);
+ }
+ m_minorVersion++;
+ return new ArrayOf(m_definitions
+ .Select(field => new PubSubRuntimeFieldValue
+ {
+ Name = field.Name,
+ BuiltInType = field.BuiltInTypeName,
+ Value = m_values[field.Name].ToString()
+ })
+ .ToArray());
+ }
+ finally
+ {
+ m_valueGate.Release();
+ }
+ }
+
+ public void Dispose()
+ {
+ m_valueGate.Dispose();
+ }
+
+ private static Dictionary ParseFieldValues(string fieldValues)
+ {
+ string trimmed = fieldValues.Trim();
+ if (trimmed.StartsWith('{'))
+ {
+ using JsonDocument document = JsonDocument.Parse(trimmed);
+ var values = new Dictionary(StringComparer.Ordinal);
+ foreach (JsonProperty property in document.RootElement.EnumerateObject())
+ {
+ values[property.Name] = ElementToString(property.Value);
+ }
+ return values;
+ }
+
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (string entry in trimmed.Split(
+ [';', ','],
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ string[] parts = entry.Split('=', 2, StringSplitOptions.TrimEntries);
+ if (parts.Length != 2)
+ {
+ throw new ArgumentException(
+ "Field values must be JSON object text or name=value pairs separated by ';'.",
+ nameof(fieldValues));
+ }
+ result[parts[0]] = parts[1];
+ }
+ return result;
+ }
+
+ private static string ElementToString(JsonElement element)
+ {
+ return element.ValueKind switch
+ {
+ JsonValueKind.String => element.GetString() ?? string.Empty,
+ JsonValueKind.Number => element.GetRawText(),
+ JsonValueKind.True => bool.TrueString,
+ JsonValueKind.False => bool.FalseString,
+ JsonValueKind.Null => string.Empty,
+ _ => element.GetRawText()
+ };
+ }
+ }
+
+ private sealed class BufferedSubscribedDataSetSink : ISubscribedDataSetSink, IDisposable
+ {
+ private readonly int m_capacity;
+ private readonly Queue m_received;
+ private readonly SemaphoreSlim m_bufferGate = new(1, 1);
+ private long m_sequence;
+
+ public BufferedSubscribedDataSetSink(int capacity)
+ {
+ m_capacity = capacity;
+ m_received = new Queue(capacity);
+ }
+
+ public int Count => m_received.Count;
+
+ public async ValueTask WriteAsync(
+ IReadOnlyList fields,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var values = new List();
+ for (int i = 0; i < fields.Count; i++)
+ {
+ DataSetField field = fields[i];
+ values.Add(new PubSubRuntimeFieldValue
+ {
+ Name = string.IsNullOrEmpty(field.Name)
+ ? string.Create(CultureInfo.InvariantCulture, $"f{i}")
+ : field.Name,
+ BuiltInType = field.Value.TypeInfo.BuiltInType.ToString(),
+ Value = field.Value.IsNull ? string.Empty : field.Value.ToString()
+ });
+ }
+
+ var received = new PubSubReceivedDataSet
+ {
+ Sequence = Interlocked.Increment(ref m_sequence),
+ ReceivedAt = DateTimeOffset.UtcNow,
+ Fields = new ArrayOf(values.ToArray())
+ };
+
+ await m_bufferGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ while (m_received.Count >= m_capacity)
+ {
+ m_received.Dequeue();
+ }
+ m_received.Enqueue(received);
+ }
+ finally
+ {
+ m_bufferGate.Release();
+ }
+ }
+
+ public async ValueTask> ReadAsync(bool clear, CancellationToken ct)
+ {
+ await m_bufferGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ var snapshot = new ArrayOf(m_received.ToArray());
+ if (clear)
+ {
+ m_received.Clear();
+ }
+ return snapshot;
+ }
+ finally
+ {
+ m_bufferGate.Release();
+ }
+ }
+
+ public void Dispose()
+ {
+ m_bufferGate.Dispose();
+ }
+ }
+
+ private sealed record RuntimeFieldDefinition(
+ string Name,
+ string BuiltInTypeName,
+ byte BuiltInType,
+ NodeId DataTypeId)
+ {
+ public static RuntimeFieldDefinition Create(string name, string builtInTypeName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+ ArgumentException.ThrowIfNullOrWhiteSpace(builtInTypeName);
+
+ return builtInTypeName.Trim().ToLowerInvariant() switch
+ {
+ "boolean" or "bool" => new(name, "Boolean", (byte)DataTypes.Boolean, DataTypeIds.Boolean),
+ "sbyte" => new(name, "SByte", (byte)DataTypes.SByte, DataTypeIds.SByte),
+ "byte" => new(name, "Byte", (byte)DataTypes.Byte, DataTypeIds.Byte),
+ "int16" => new(name, "Int16", (byte)DataTypes.Int16, DataTypeIds.Int16),
+ "uint16" => new(name, "UInt16", (byte)DataTypes.UInt16, DataTypeIds.UInt16),
+ "int32" or "int" => new(name, "Int32", (byte)DataTypes.Int32, DataTypeIds.Int32),
+ "uint32" => new(name, "UInt32", (byte)DataTypes.UInt32, DataTypeIds.UInt32),
+ "int64" or "long" => new(name, "Int64", (byte)DataTypes.Int64, DataTypeIds.Int64),
+ "uint64" => new(name, "UInt64", (byte)DataTypes.UInt64, DataTypeIds.UInt64),
+ "float" => new(name, "Float", (byte)DataTypes.Float, DataTypeIds.Float),
+ "double" => new(name, "Double", (byte)DataTypes.Double, DataTypeIds.Double),
+ "datetime" => new(name, "DateTime", (byte)DataTypes.DateTime, DataTypeIds.DateTime),
+ "string" => new(name, "String", (byte)DataTypes.String, DataTypeIds.String),
+ _ => throw new ArgumentException(
+ $"Unsupported PubSub field type '{builtInTypeName}'.",
+ nameof(builtInTypeName))
+ };
+ }
+
+ public Variant CreateDefaultValue()
+ {
+ return BuiltInTypeName switch
+ {
+ "Boolean" => new Variant(false),
+ "SByte" => new Variant((sbyte)0),
+ "Byte" => new Variant((byte)0),
+ "Int16" => new Variant((short)0),
+ "UInt16" => new Variant((ushort)0),
+ "Int32" => new Variant(0),
+ "UInt32" => new Variant(0u),
+ "Int64" => new Variant(0L),
+ "UInt64" => new Variant(0UL),
+ "Float" => new Variant(0f),
+ "Double" => new Variant(0d),
+ "DateTime" => new Variant(DateTime.UtcNow),
+ _ => new Variant(string.Empty)
+ };
+ }
+
+ public Variant ParseValue(string text)
+ {
+ return BuiltInTypeName switch
+ {
+ "Boolean" => new Variant(bool.Parse(text)),
+ "SByte" => new Variant(sbyte.Parse(text, CultureInfo.InvariantCulture)),
+ "Byte" => new Variant(byte.Parse(text, CultureInfo.InvariantCulture)),
+ "Int16" => new Variant(short.Parse(text, CultureInfo.InvariantCulture)),
+ "UInt16" => new Variant(ushort.Parse(text, CultureInfo.InvariantCulture)),
+ "Int32" => new Variant(int.Parse(text, CultureInfo.InvariantCulture)),
+ "UInt32" => new Variant(uint.Parse(text, CultureInfo.InvariantCulture)),
+ "Int64" => new Variant(long.Parse(text, CultureInfo.InvariantCulture)),
+ "UInt64" => new Variant(ulong.Parse(text, CultureInfo.InvariantCulture)),
+ "Float" => new Variant(float.Parse(text, CultureInfo.InvariantCulture)),
+ "Double" => new Variant(double.Parse(text, CultureInfo.InvariantCulture)),
+ "DateTime" => new Variant(DateTime.Parse(text, CultureInfo.InvariantCulture).ToUniversalTime()),
+ _ => new Variant(text)
+ };
+ }
+ }
+ }
+
+ ///
+ /// Current PubSub runtime mode.
+ ///
+ public enum PubSubRuntimeMode
+ {
+ ///
+ /// No PubSub application is running.
+ ///
+ Stopped,
+
+ ///
+ /// A publisher application is running.
+ ///
+ Publisher,
+
+ ///
+ /// A subscriber application is running.
+ ///
+ Subscriber
+ }
+
+ ///
+ /// Current runtime status.
+ ///
+ public sealed class PubSubRuntimeStatus
+ {
+ ///
+ /// Gets whether an application is running.
+ ///
+ public bool IsRunning { get; init; }
+
+ ///
+ /// Gets the current mode.
+ ///
+ public string Mode { get; init; } = string.Empty;
+
+ ///
+ /// Gets the transport endpoint.
+ ///
+ public string Endpoint { get; init; } = string.Empty;
+
+ ///
+ /// Gets the publisher id.
+ ///
+ public ushort PublisherId { get; init; }
+
+ ///
+ /// Gets the writer group id.
+ ///
+ public ushort WriterGroupId { get; init; }
+
+ ///
+ /// Gets the number of buffered received DataSets.
+ ///
+ public int BufferedDataSetCount { get; init; }
+ }
+
+ ///
+ /// Result of updating the active publisher's fields.
+ ///
+ public sealed class PubSubRuntimePublishResult
+ {
+ ///
+ /// Gets the current mode.
+ ///
+ public string Mode { get; init; } = string.Empty;
+
+ ///
+ /// Gets the transport endpoint.
+ ///
+ public string Endpoint { get; init; } = string.Empty;
+
+ ///
+ /// Gets the publisher id.
+ ///
+ public ushort PublisherId { get; init; }
+
+ ///
+ /// Gets the writer group id.
+ ///
+ public ushort WriterGroupId { get; init; }
+
+ ///
+ /// Gets the fields that will be sampled by the publisher.
+ ///
+ public ArrayOf Fields { get; init; } = [];
+
+ ///
+ /// Gets a status message.
+ ///
+ public string Message { get; init; } = string.Empty;
+ }
+
+ ///
+ /// A JSON-friendly PubSub field value.
+ ///
+ public sealed class PubSubRuntimeFieldValue
+ {
+ ///
+ /// Gets the field name.
+ ///
+ public string Name { get; init; } = string.Empty;
+
+ ///
+ /// Gets the OPC UA built-in type name.
+ ///
+ public string BuiltInType { get; init; } = string.Empty;
+
+ ///
+ /// Gets the field value as text.
+ ///
+ public string Value { get; init; } = string.Empty;
+ }
+
+ ///
+ /// One buffered DataSet received by the subscriber.
+ ///
+ public sealed class PubSubReceivedDataSet
+ {
+ ///
+ /// Gets the local receive sequence.
+ ///
+ public long Sequence { get; init; }
+
+ ///
+ /// Gets the receive timestamp.
+ ///
+ public DateTimeOffset ReceivedAt { get; init; }
+
+ ///
+ /// Gets the received fields.
+ ///
+ public ArrayOf Fields { get; init; } = [];
+ }
+
+ ///
+ /// A JSON-friendly PubSub Action target.
+ ///
+ public sealed record PubSubActionTargetInfo(
+ string ConnectionName,
+ ushort DataSetWriterId,
+ ushort ActionTargetId,
+ string ActionName,
+ string DataSetWriterName,
+ string PublishedDataSetName,
+ string Description,
+ string Source);
+
+ ///
+ /// A JSON-friendly PubSub Action responder registration.
+ ///
+ public sealed record PubSubActionResponderRegistration(
+ string ConnectionName,
+ ushort DataSetWriterId,
+ ushort ActionTargetId,
+ string ActionName,
+ string ResponderKind,
+ string Details)
+ {
+ ///
+ /// Gets whether the responder target matches a target info.
+ ///
+ public bool MatchesTarget(PubSubActionTargetInfo target)
+ {
+ return string.Equals(ConnectionName, target.ConnectionName, StringComparison.Ordinal)
+ && DataSetWriterId == target.DataSetWriterId
+ && ActionTargetId == target.ActionTargetId
+ && string.Equals(ActionName, target.ActionName, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Gets whether the responder target matches another responder registration.
+ ///
+ public bool MatchesTarget(PubSubActionResponderRegistration target)
+ {
+ return string.Equals(ConnectionName, target.ConnectionName, StringComparison.Ordinal)
+ && DataSetWriterId == target.DataSetWriterId
+ && ActionTargetId == target.ActionTargetId
+ && string.Equals(ActionName, target.ActionName, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Converts the registration to a target info entry.
+ ///
+ public PubSubActionTargetInfo ToTargetInfo(
+ string source,
+ string dataSetWriterName,
+ string publishedDataSetName)
+ {
+ return new PubSubActionTargetInfo(
+ ConnectionName,
+ DataSetWriterId,
+ ActionTargetId,
+ ActionName,
+ dataSetWriterName,
+ publishedDataSetName,
+ Details,
+ source);
+ }
+ }
+}
diff --git a/Applications/McpServer/Tools/PubSubActionTools.cs b/Applications/McpServer/Tools/PubSubActionTools.cs
new file mode 100644
index 0000000000..b1b355064c
--- /dev/null
+++ b/Applications/McpServer/Tools/PubSubActionTools.cs
@@ -0,0 +1,453 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Globalization;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using ModelContextProtocol.Server;
+using Opc.Ua.Client;
+using Opc.Ua.Mcp.Serialization;
+using Opc.Ua.PubSub.Application;
+using Opc.Ua.PubSub.Encoding;
+
+namespace Opc.Ua.Mcp.Tools
+{
+ ///
+ /// MCP tools that exercise OPC UA PubSub Actions (Part 14 §7.2.4):
+ /// they invoke Action targets and register responder handlers on the active runtime.
+ ///
+ [McpServerToolType]
+ public sealed class PubSubActionTools
+ {
+ ///
+ /// Invokes a PubSub Action target and awaits the correlated response.
+ ///
+ [McpServerTool(Name = "pubsub_invoke_action")]
+ [Description("Send a PubSub Action request and await the correlated response.")]
+ public static async Task InvokeActionAsync(
+ PubSubRuntimeManager manager,
+ [Description("DataSetWriterId that owns the Action target.")] ushort dataSetWriterId,
+ [Description("ActionTargetId to invoke; leave 0 when actionName resolves metadata.")]
+ ushort actionTargetId = 0,
+ [Description("Optional action name used to resolve ActionTargetId from metadata.")]
+ string? actionName = null,
+ [Description("Optional PubSub connection name used by runtime routing.")] string? connectionName = null,
+ [Description("Input fields as JSON object or name[:type]=value pairs separated by ';'.")]
+ string? inputFields = null,
+ [Description("Optional response address carried on the request.")] string? responseAddress = null,
+ [Description("Action response timeout in milliseconds.")] int timeoutMs = 2000,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(manager);
+
+ var request = new PubSubActionRequest
+ {
+ Target = CreateTarget(connectionName, dataSetWriterId, actionTargetId, actionName),
+ InputFields = ParseFields(inputFields),
+ ResponseAddress = responseAddress ?? string.Empty,
+ TimeoutHint = timeoutMs <= 0 ? 2000 : timeoutMs
+ };
+ TimeSpan timeout = TimeSpan.FromMilliseconds(timeoutMs <= 0 ? 2000 : timeoutMs);
+ PubSubActionResponse response = await manager.InvokeActionAsync(request, timeout, ct).ConfigureAwait(false);
+ return Summarize(response);
+ }
+
+ ///
+ /// Registers an echo responder for round-trip Action testing.
+ ///
+ [McpServerTool(Name = "pubsub_register_action_responder")]
+ [Description("Register a demo PubSub Action responder that echoes input fields to output fields.")]
+ public static async Task RegisterActionResponderAsync(
+ PubSubRuntimeManager manager,
+ [Description("DataSetWriterId that owns the Action target.")] ushort dataSetWriterId,
+ [Description("ActionTargetId handled by the responder.")] ushort actionTargetId,
+ [Description("Optional action name associated with the target.")] string? actionName = null,
+ [Description("Optional PubSub connection name used by runtime routing.")] string? connectionName = null,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(manager);
+
+ PubSubActionTarget target = CreateTarget(connectionName, dataSetWriterId, actionTargetId, actionName);
+ return await manager.RegisterActionResponderAsync(
+ target,
+ new DelegatePubSubActionHandler(static (invocation, cancellationToken) =>
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return ValueTask.FromResult(new PubSubActionHandlerResult
+ {
+ StatusCode = StatusCodes.Good,
+ OutputFields = CopyFields(invocation.InputFields)
+ });
+ }),
+ "echo",
+ "Echoes Action input fields to output fields with StatusCode Good.",
+ ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Binds a PubSub Action responder to an OPC UA server method through an active session.
+ ///
+ [McpServerTool(Name = "pubsub_bind_action_method")]
+ [Description("Register a PubSub Action responder that calls an OPC UA server method.")]
+ public static async Task BindActionMethodAsync(
+ PubSubRuntimeManager manager,
+ OpcUaSessionManager sessionManager,
+ [Description("DataSetWriterId that owns the Action target.")] ushort dataSetWriterId,
+ [Description("ActionTargetId handled by the responder.")] ushort actionTargetId,
+ [Description("Object node ID on which the method is defined.")] string objectId,
+ [Description("Method node ID to call.")] string methodId,
+ [Description("Optional action name associated with the target.")] string? actionName = null,
+ [Description("Optional PubSub connection name used by runtime routing.")] string? connectionName = null,
+ [Description("Session name to use; defaults to the only active session.")] string? sessionName = null,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(manager);
+ ArgumentNullException.ThrowIfNull(sessionManager);
+ ArgumentException.ThrowIfNullOrWhiteSpace(objectId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(methodId);
+
+ ISession session = sessionManager.GetSessionOrThrow(sessionName);
+ NodeId parsedObjectId = OpcUaJsonHelper.ParseNodeId(objectId);
+ NodeId parsedMethodId = OpcUaJsonHelper.ParseNodeId(methodId);
+ PubSubActionTarget target = CreateTarget(connectionName, dataSetWriterId, actionTargetId, actionName);
+
+ return await manager.RegisterActionResponderAsync(
+ target,
+ new DelegatePubSubActionHandler((invocation, cancellationToken) =>
+ CallBoundMethodAsync(session, parsedObjectId, parsedMethodId, invocation, cancellationToken)),
+ "method",
+ string.Create(
+ CultureInfo.InvariantCulture,
+ $"Calls objectId={parsedObjectId}, methodId={parsedMethodId}, " +
+ $"sessionName={sessionName ?? string.Empty}."),
+ ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Lists known PubSub Action targets.
+ ///
+ [McpServerTool(Name = "pubsub_list_action_targets")]
+ [Description("List Action targets known from the active PubSub configuration and MCP responders.")]
+ public static async Task> ListActionTargetsAsync(
+ PubSubRuntimeManager manager,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(manager);
+ return await manager.ListActionTargetsAsync(ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Lists PubSub Action responders registered through MCP.
+ ///
+ [McpServerTool(Name = "pubsub_list_action_responders")]
+ [Description("List Action responders registered on the active PubSub runtime through MCP.")]
+ public static async Task> ListActionRespondersAsync(
+ PubSubRuntimeManager manager,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(manager);
+ return await manager.ListActionRespondersAsync(ct).ConfigureAwait(false);
+ }
+
+ private static async ValueTask CallBoundMethodAsync(
+ ISession session,
+ NodeId objectId,
+ NodeId methodId,
+ PubSubActionInvocation invocation,
+ CancellationToken ct)
+ {
+ ArrayOf methodsToCall =
+ [
+ new CallMethodRequest
+ {
+ ObjectId = objectId,
+ MethodId = methodId,
+ InputArguments = CreateInputArguments(invocation.InputFields)
+ }
+ ];
+
+ try
+ {
+ CallResponse response = await session.CallAsync(null, methodsToCall, ct).ConfigureAwait(false);
+ CallMethodResult result = response.Results[0];
+ return new PubSubActionHandlerResult
+ {
+ StatusCode = result.StatusCode,
+ OutputFields = result.OutputArguments.IsNull
+ ? []
+ : CreateOutputFields(result.OutputArguments)
+ };
+ }
+ catch (ServiceResultException ex)
+ {
+ return new PubSubActionHandlerResult
+ {
+ StatusCode = ex.StatusCode
+ };
+ }
+ }
+
+ private static PubSubActionTarget CreateTarget(
+ string? connectionName,
+ ushort dataSetWriterId,
+ ushort actionTargetId,
+ string? actionName)
+ {
+ return new PubSubActionTarget
+ {
+ ConnectionName = connectionName ?? string.Empty,
+ DataSetWriterId = dataSetWriterId,
+ ActionTargetId = actionTargetId,
+ ActionName = actionName ?? string.Empty
+ };
+ }
+
+ private static PubSubActionResponseSummary Summarize(PubSubActionResponse response)
+ {
+ return new PubSubActionResponseSummary(
+ response.Target.ConnectionName,
+ response.Target.DataSetWriterId,
+ response.Target.ActionTargetId,
+ response.Target.ActionName,
+ response.RequestId,
+ ToBase64(response.CorrelationData),
+ response.StatusCode.ToString(),
+ response.ActionState.ToString(),
+ SummarizeFields(response.OutputFields));
+ }
+
+ private static ArrayOf ParseFields(string? fieldText)
+ {
+ if (string.IsNullOrWhiteSpace(fieldText))
+ {
+ return [];
+ }
+
+ string trimmed = fieldText.Trim();
+ return trimmed.StartsWith('{')
+ ? ParseJsonFields(trimmed)
+ : ParseNameValueFields(trimmed);
+ }
+
+ private static ArrayOf ParseJsonFields(string fieldText)
+ {
+ using JsonDocument document = JsonDocument.Parse(fieldText);
+ if (document.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ throw new ArgumentException("Action input fields JSON must be an object.", nameof(fieldText));
+ }
+
+ var fields = new List();
+ foreach (JsonProperty property in document.RootElement.EnumerateObject())
+ {
+ string? dataType = null;
+ JsonElement value = property.Value;
+ if (property.Value.ValueKind == JsonValueKind.Object
+ && property.Value.TryGetProperty("value", out JsonElement wrappedValue))
+ {
+ value = wrappedValue;
+ if (property.Value.TryGetProperty("dataType", out JsonElement dataTypeElement)
+ && dataTypeElement.ValueKind == JsonValueKind.String)
+ {
+ dataType = dataTypeElement.GetString();
+ }
+ }
+
+ fields.Add(new DataSetField
+ {
+ Name = property.Name,
+ Value = OpcUaJsonHelper.JsonElementToVariant(value, dataType)
+ });
+ }
+
+ return [.. fields];
+ }
+
+ private static ArrayOf ParseNameValueFields(string fieldText)
+ {
+ var fields = new List();
+ foreach (string entry in fieldText.Split(
+ [';', ','],
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ string[] parts = entry.Split('=', 2, StringSplitOptions.TrimEntries);
+ if (parts.Length != 2)
+ {
+ throw new ArgumentException(
+ "Action input fields must be JSON object text or name[:type]=value pairs separated by ';'.",
+ nameof(fieldText));
+ }
+
+ string[] nameParts = parts[0].Split(':', 2, StringSplitOptions.TrimEntries);
+ fields.Add(new DataSetField
+ {
+ Name = nameParts[0],
+ Value = ParseTextVariant(parts[1], nameParts.Length == 2 ? nameParts[1] : "String")
+ });
+ }
+
+ return [.. fields];
+ }
+
+ private static Variant ParseTextVariant(string text, string dataType)
+ {
+ return dataType.Trim().ToLowerInvariant() switch
+ {
+ "boolean" or "bool" => new Variant(bool.Parse(text)),
+ "sbyte" => new Variant(sbyte.Parse(text, CultureInfo.InvariantCulture)),
+ "byte" => new Variant(byte.Parse(text, CultureInfo.InvariantCulture)),
+ "int16" => new Variant(short.Parse(text, CultureInfo.InvariantCulture)),
+ "uint16" => new Variant(ushort.Parse(text, CultureInfo.InvariantCulture)),
+ "int32" or "int" => new Variant(int.Parse(text, CultureInfo.InvariantCulture)),
+ "uint32" => new Variant(uint.Parse(text, CultureInfo.InvariantCulture)),
+ "int64" or "long" => new Variant(long.Parse(text, CultureInfo.InvariantCulture)),
+ "uint64" => new Variant(ulong.Parse(text, CultureInfo.InvariantCulture)),
+ "float" => new Variant(float.Parse(text, CultureInfo.InvariantCulture)),
+ "double" => new Variant(double.Parse(text, CultureInfo.InvariantCulture)),
+ "datetime" => new Variant(DateTime.Parse(text, CultureInfo.InvariantCulture).ToUniversalTime()),
+ _ => new Variant(text)
+ };
+ }
+
+ private static ArrayOf CopyFields(ArrayOf fields)
+ {
+ if (fields.IsNull)
+ {
+ return [];
+ }
+
+ var copy = new List();
+ foreach (DataSetField field in fields)
+ {
+ copy.Add(new DataSetField
+ {
+ Name = field.Name,
+ Value = field.Value,
+ StatusCode = field.StatusCode,
+ SourceTimestamp = field.SourceTimestamp,
+ SourcePicoSeconds = field.SourcePicoSeconds,
+ ServerTimestamp = field.ServerTimestamp,
+ ServerPicoSeconds = field.ServerPicoSeconds,
+ Encoding = field.Encoding
+ });
+ }
+
+ return [.. copy];
+ }
+
+ private static ArrayOf CreateInputArguments(ArrayOf fields)
+ {
+ if (fields.IsNull)
+ {
+ return [];
+ }
+
+ var inputArguments = new List();
+ foreach (DataSetField field in fields)
+ {
+ inputArguments.Add(field.Value);
+ }
+
+ return [.. inputArguments];
+ }
+
+ private static ArrayOf CreateOutputFields(ArrayOf outputArguments)
+ {
+ var outputFields = new List();
+ for (int i = 0; i < outputArguments.Count; i++)
+ {
+ outputFields.Add(new DataSetField
+ {
+ Name = string.Create(CultureInfo.InvariantCulture, $"output{i}"),
+ Value = outputArguments[i]
+ });
+ }
+
+ return [.. outputFields];
+ }
+
+ private static ArrayOf SummarizeFields(ArrayOf fields)
+ {
+ if (fields.IsNull)
+ {
+ return [];
+ }
+
+ var values = new List();
+ foreach (DataSetField field in fields)
+ {
+ values.Add(ToFieldValue(field));
+ }
+
+ return [.. values];
+ }
+
+ private static PubSubActionFieldValue ToFieldValue(DataSetField field)
+ {
+ return new PubSubActionFieldValue(
+ field.Name,
+ field.Value.IsNull ? string.Empty : field.Value.TypeInfo.BuiltInType.ToString(),
+ field.Value.IsNull ? string.Empty : field.Value.ToString(),
+ field.StatusCode.ToString());
+ }
+
+ private static string ToBase64(ByteString value)
+ {
+ return value.IsNull ? string.Empty : Convert.ToBase64String(value.Span);
+ }
+ }
+
+ ///
+ /// One JSON-friendly PubSub Action field value.
+ ///
+ public sealed record PubSubActionFieldValue(
+ string Name,
+ string BuiltInType,
+ string Value,
+ string StatusCode);
+
+ ///
+ /// JSON-friendly PubSub Action response returned to MCP callers.
+ ///
+ public sealed record PubSubActionResponseSummary(
+ string ConnectionName,
+ ushort DataSetWriterId,
+ ushort ActionTargetId,
+ string ActionName,
+ ushort RequestId,
+ string CorrelationData,
+ string StatusCode,
+ string ActionState,
+ ArrayOf OutputFields);
+}
diff --git a/Applications/McpServer/Tools/PubSubCaptureTools.cs b/Applications/McpServer/Tools/PubSubCaptureTools.cs
new file mode 100644
index 0000000000..7f8897407b
--- /dev/null
+++ b/Applications/McpServer/Tools/PubSubCaptureTools.cs
@@ -0,0 +1,485 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using ModelContextProtocol.Server;
+using Opc.Ua.Pcap.Capture;
+using Opc.Ua.Pcap.DependencyInjection;
+using Opc.Ua.PubSub.Pcap;
+
+using OpcUaMcpServerOptions = Opc.Ua.Mcp.McpServerOptions;
+
+namespace Opc.Ua.Mcp.Tools
+{
+ ///
+ /// MCP tools for OPC UA PubSub packet capture sessions.
+ ///
+ [McpServerToolType]
+ [SuppressMessage(
+ "Performance",
+ "CA1812:Avoid uninstantiated internal classes",
+ Justification = "MCP discovers tool types through reflection; TODO: remove when supported.")]
+ internal sealed class PubSubCaptureTools
+ {
+ ///
+ /// Starts an in-process OPC UA PubSub capture session.
+ ///
+ [McpServerTool(Name = "pubsub_start_capture")]
+ [Description("Starts a new in-process OPC UA PubSub capture session. The MCP server must share the " +
+ "registered PubSub capture registry with the PubSub transports for live frames to appear.")]
+ public static async Task StartCaptureAsync(
+ PubSubCaptureSessionManager manager,
+ CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(manager);
+
+ await ClearLastSourceAsync(ct).ConfigureAwait(false);
+ IPubSubCaptureSource source = await manager.StartAsync(ct).ConfigureAwait(false);
+ return CreateInfo(source, isActive: true);
+ }
+
+ ///
+ /// Stops the active OPC UA PubSub capture session.
+ ///
+ [McpServerTool(Name = "pubsub_stop_capture")]
+ [Description("Stops the active in-process PubSub capture session and keeps a reusable in-memory snapshot " +
+ "for pubsub_write_pcap and pubsub_dissect_capture.")]
+ public static async Task StopCaptureAsync(
+ PubSubCaptureSessionManager manager,
+ CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(manager);
+
+ IPubSubCaptureSource? source = await manager.StopAsync(ct).ConfigureAwait(false);
+ if (source is null)
+ {
+ IPubSubCaptureSource? last = await GetLastSourceAsync(ct).ConfigureAwait(false);
+ if (last is null)
+ {
+ return new PubSubCaptureSessionInfo
+ {
+ IsActive = false,
+ FrameCount = 0,
+ ByteCount = 0,
+ State = "idle"
+ };
+ }
+
+ return CreateInfo(last, isActive: false);
+ }
+
+ SnapshotPubSubCaptureSource snapshot = await SnapshotPubSubCaptureSource.CreateAsync(source, ct)
+ .ConfigureAwait(false);
+ await source.DisposeAsync().ConfigureAwait(false);
+ await StoreLastSourceAsync(snapshot, ct).ConfigureAwait(false);
+ return CreateInfo(snapshot, isActive: false);
+ }
+
+ ///
+ /// Reports the active or last OPC UA PubSub capture status.
+ ///
+ [McpServerTool(Name = "pubsub_capture_status")]
+ [Description("Reports whether a PubSub capture is active and returns frame/byte counters for the active " +
+ "capture or the last stopped capture snapshot.")]
+ public static async Task CaptureStatusAsync(
+ PubSubCaptureSessionManager manager,
+ CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(manager);
+
+ IPubSubCaptureSource? active = manager.ActiveSource;
+ if (active is not null)
+ {
+ return CreateInfo(active, isActive: true);
+ }
+
+ IPubSubCaptureSource? last = await GetLastSourceAsync(ct).ConfigureAwait(false);
+ return last is null
+ ? new PubSubCaptureSessionInfo
+ {
+ IsActive = false,
+ FrameCount = 0,
+ ByteCount = 0,
+ State = "idle"
+ }
+ : CreateInfo(last, isActive: false);
+ }
+
+ ///
+ /// Writes the active or last OPC UA PubSub capture to a pcap file.
+ ///
+ [McpServerTool(Name = "pubsub_write_pcap")]
+ [Description("Writes the active or last stopped PubSub capture to a .pcap or .pcapng file. If a capture is " +
+ "active, it is stopped first so the buffered frames can be flushed safely. Only UDP/UADP frames are " +
+ "written; MQTT payloads are skipped by the PubSub pcap writer.")]
+ public static async Task WritePcapAsync(
+ IServiceProvider services,
+ PubSubCaptureSessionManager manager,
+ [Description("Destination .pcap or .pcapng path under the MCP pcap base folder. ")] string filePath,
+ CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(manager);
+ ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
+
+ string allowedRoot = GetPcapAllowedRoot(services);
+ filePath = PacketDecodeTools.ResolveAndValidateDecodePath(filePath, allowedRoot);
+ string? directory = Path.GetDirectoryName(filePath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ IPubSubCaptureSource source = await GetStoppedSourceAsync(manager, ct).ConfigureAwait(false);
+ var writer = new PubSubPcapWriter();
+ bool isPcapNg = IsPcapNgPath(filePath);
+ long framesWritten = isPcapNg
+ ? await writer.WritePcapNgAsync(source.ReadCapturedFramesAsync(null, ct), filePath, ct)
+ .ConfigureAwait(false)
+ : await writer.WritePcapAsync(source.ReadCapturedFramesAsync(null, ct), filePath, ct)
+ .ConfigureAwait(false);
+ return new PubSubPcapWriteInfo
+ {
+ FilePath = filePath,
+ Format = isPcapNg ? "pcapng" : "pcap",
+ FramesCaptured = source.FrameCount,
+ BytesCaptured = source.ByteCount,
+ FramesWritten = framesWritten
+ };
+ }
+
+ internal static async ValueTask GetLastStoppedSourceAsync(
+ PubSubCaptureSessionManager manager,
+ CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(manager);
+
+ if (manager.ActiveSource is not null)
+ {
+ throw new PcapDiagnosticsException(
+ "A PubSub capture is still active; stop it with pubsub_stop_capture before dissecting it.");
+ }
+
+ IPubSubCaptureSource? source = await GetLastSourceAsync(ct).ConfigureAwait(false);
+ return source ?? throw new PcapDiagnosticsException(
+ "No stopped PubSub capture is available. Start and stop a capture first.");
+ }
+
+ private static async ValueTask GetStoppedSourceAsync(
+ PubSubCaptureSessionManager manager,
+ CancellationToken ct)
+ {
+ IPubSubCaptureSource? active = manager.ActiveSource;
+ if (active is not null)
+ {
+ IPubSubCaptureSource? stopped = await manager.StopAsync(ct).ConfigureAwait(false);
+ if (stopped is not null)
+ {
+ SnapshotPubSubCaptureSource snapshot = await SnapshotPubSubCaptureSource.CreateAsync(stopped, ct)
+ .ConfigureAwait(false);
+ await stopped.DisposeAsync().ConfigureAwait(false);
+ await StoreLastSourceAsync(snapshot, ct).ConfigureAwait(false);
+ return snapshot;
+ }
+ }
+
+ return await GetLastStoppedSourceAsync(manager, ct).ConfigureAwait(false);
+ }
+
+ private static PubSubCaptureSessionInfo CreateInfo(IPubSubCaptureSource source, bool isActive)
+ {
+ return new PubSubCaptureSessionInfo
+ {
+ IsActive = isActive,
+ FrameCount = source.FrameCount,
+ ByteCount = source.ByteCount,
+ State = isActive ? "running" : "stopped"
+ };
+ }
+
+ private static async ValueTask StoreLastSourceAsync(IPubSubCaptureSource source, CancellationToken ct)
+ {
+ await m_lastSourceGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ IPubSubCaptureSource? previous = m_lastSource;
+ m_lastSource = source;
+ if (previous is not null && !ReferenceEquals(previous, source))
+ {
+ await previous.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ m_lastSourceGate.Release();
+ }
+ }
+
+ private static async ValueTask ClearLastSourceAsync(CancellationToken ct)
+ {
+ await m_lastSourceGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ IPubSubCaptureSource? previous = m_lastSource;
+ m_lastSource = null;
+ if (previous is not null)
+ {
+ await previous.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ m_lastSourceGate.Release();
+ }
+ }
+
+ private static async ValueTask GetLastSourceAsync(CancellationToken ct)
+ {
+ await m_lastSourceGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ return m_lastSource;
+ }
+ finally
+ {
+ m_lastSourceGate.Release();
+ }
+ }
+
+ private static string GetPcapAllowedRoot(IServiceProvider services)
+ {
+ OpcUaMcpServerOptions? mcpOptions =
+ services.GetService(typeof(OpcUaMcpServerOptions)) as OpcUaMcpServerOptions;
+ if (mcpOptions is not null &&
+ !string.IsNullOrWhiteSpace(mcpOptions.PcapBaseFolder))
+ {
+ return Path.GetFullPath(mcpOptions.PcapBaseFolder!);
+ }
+
+ PcapOptions? options = services.GetService(typeof(PcapOptions)) as PcapOptions;
+ return options?.BaseFolder ??
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "OPCFoundation",
+ "opcua-pcap");
+ }
+
+ private static bool IsPcapNgPath(string filePath)
+ {
+ return string.Equals(Path.GetExtension(filePath), ".pcapng", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static readonly SemaphoreSlim m_lastSourceGate = new(1, 1);
+ private static IPubSubCaptureSource? m_lastSource;
+
+ private sealed class SnapshotPubSubCaptureSource : IPubSubCaptureSource
+ {
+ private SnapshotPubSubCaptureSource(
+ IReadOnlyList frames,
+ IReadOnlyList keyMaterial,
+ long byteCount)
+ {
+ m_frames = frames;
+ m_keyMaterial = keyMaterial;
+ ByteCount = byteCount;
+ }
+
+ public long FrameCount => m_frames.Count;
+
+ public long ByteCount { get; }
+
+ public static async ValueTask CreateAsync(
+ IPubSubCaptureSource source,
+ CancellationToken ct)
+ {
+ List frames = [];
+ await foreach (PubSubCaptureFrame frame in source.ReadCapturedFramesAsync(null, ct)
+ .WithCancellation(ct)
+ .ConfigureAwait(false))
+ {
+ frames.Add(CopyFrame(in frame));
+ }
+
+ List keys = [];
+ await foreach (PubSubKeyMaterial key in source.ReadKeyMaterialAsync(ct)
+ .WithCancellation(ct)
+ .ConfigureAwait(false))
+ {
+ keys.Add(CopyKeyMaterial(key));
+ }
+
+ return new SnapshotPubSubCaptureSource(frames, keys, source.ByteCount);
+ }
+
+ public ValueTask StartAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask StopAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return ValueTask.CompletedTask;
+ }
+
+ public async IAsyncEnumerable ReadCapturedFramesAsync(
+ long? maxFrames,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ long yielded = 0;
+ foreach (PubSubCaptureFrame frame in m_frames)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (maxFrames.HasValue && yielded >= maxFrames.Value)
+ {
+ yield break;
+ }
+ yielded++;
+ yield return frame;
+ }
+
+ await Task.CompletedTask.ConfigureAwait(false);
+ }
+
+ public async IAsyncEnumerable ReadKeyMaterialAsync(
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ foreach (PubSubKeyMaterial key in m_keyMaterial)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ yield return CopyKeyMaterial(key);
+ }
+
+ await Task.CompletedTask.ConfigureAwait(false);
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ foreach (PubSubKeyMaterial key in m_keyMaterial)
+ {
+ key.Dispose();
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ private static PubSubCaptureFrame CopyFrame(in PubSubCaptureFrame frame)
+ {
+ return new PubSubCaptureFrame(
+ frame.Timestamp,
+ frame.Direction,
+ frame.TransportProfileUri,
+ frame.Data.ToArray(),
+ frame.Endpoint,
+ frame.Topic);
+ }
+
+ private static PubSubKeyMaterial CopyKeyMaterial(PubSubKeyMaterial key)
+ {
+ return new PubSubKeyMaterial(
+ key.SecurityGroupId,
+ key.TokenId,
+ key.SecurityPolicyUri,
+ key.SigningKey.ToArray(),
+ key.EncryptingKey.ToArray(),
+ key.KeyNonce.ToArray());
+ }
+
+ private readonly IReadOnlyList m_frames;
+ private readonly IReadOnlyList m_keyMaterial;
+ }
+ }
+
+ ///
+ /// Status and counters for a PubSub capture session.
+ ///
+ public sealed class PubSubCaptureSessionInfo
+ {
+ ///
+ /// Gets whether a PubSub capture is currently active.
+ ///
+ public bool IsActive { get; init; }
+
+ ///
+ /// Gets the number of captured PubSub frames.
+ ///
+ public long FrameCount { get; init; }
+
+ ///
+ /// Gets the number of captured PubSub payload bytes.
+ ///
+ public long ByteCount { get; init; }
+
+ ///
+ /// Gets the capture state.
+ ///
+ public string State { get; init; } = string.Empty;
+ }
+
+ ///
+ /// Result of writing a PubSub capture to disk.
+ ///
+ public sealed class PubSubPcapWriteInfo
+ {
+ ///
+ /// Gets the destination file path.
+ ///
+ public string FilePath { get; init; } = string.Empty;
+
+ ///
+ /// Gets the pcap file format.
+ ///
+ public string Format { get; init; } = string.Empty;
+
+ ///
+ /// Gets the number of frames held by the capture.
+ ///
+ public long FramesCaptured { get; init; }
+
+ ///
+ /// Gets the number of payload bytes held by the capture.
+ ///
+ public long BytesCaptured { get; init; }
+
+ ///
+ /// Gets the number of UDP frames written to the pcap file.
+ ///
+ public long FramesWritten { get; init; }
+ }
+}
+
+
diff --git a/Applications/McpServer/Tools/PubSubDecodeTools.cs b/Applications/McpServer/Tools/PubSubDecodeTools.cs
new file mode 100644
index 0000000000..a4c9ac8e34
--- /dev/null
+++ b/Applications/McpServer/Tools/PubSubDecodeTools.cs
@@ -0,0 +1,416 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Net;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using Opc.Ua.Pcap.Capture;
+using Opc.Ua.Pcap.Frame;
+using Opc.Ua.Pcap.DependencyInjection;
+using Opc.Ua.PubSub.Diagnostics;
+using Opc.Ua.PubSub.Encoding;
+using Opc.Ua.PubSub.MetaData;
+using Opc.Ua.PubSub.Pcap;
+using Opc.Ua.PubSub.Pcap.KeyLog;
+using Opc.Ua.PubSub.Transports;
+
+using OpcUaMcpServerOptions = Opc.Ua.Mcp.McpServerOptions;
+
+namespace Opc.Ua.Mcp.Tools
+{
+ ///
+ /// MCP tools for dissecting OPC UA PubSub packet captures.
+ ///
+ [McpServerToolType]
+ [SuppressMessage(
+ "Performance",
+ "CA1812:Avoid uninstantiated internal classes",
+ Justification = "MCP discovers tool types through reflection; TODO: remove when supported.")]
+ internal sealed class PubSubDecodeTools
+ {
+ private const int kMaxResponseBytes = 10 * 1024 * 1024;
+ private const uint kLinkTypeEthernet = 1;
+ private const string kUadpTransportProfileUri = "http://opcfoundation.org/UA-Profile/Transport/pubsub-udp-uadp";
+
+ ///
+ /// Dissects the last stopped OPC UA PubSub capture.
+ ///
+ [McpServerTool(Name = "pubsub_dissect_capture")]
+ [Description("Dissects the last stopped in-process PubSub capture. format='text' returns a timeline; " +
+ "format='json' returns an array of dissection results. Provide keyLogPath, or call pubsub_load_keylog " +
+ "first, to decrypt encrypted UADP frames.")]
+ public static async Task> DissectCaptureAsync(
+ PubSubCaptureSessionManager manager,
+ [Description("Output format: text | json.")] string format = "text",
+ [Description("Optional PubSub JSON-lines key-log path under the MCP pcap base folder.")]
+ string? keyLogPath = null,
+ [Description("Maximum frames to dissect. Default = all.")] long? maxFrames = null,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(manager);
+
+ IPubSubCaptureSource source = await PubSubCaptureTools.GetLastStoppedSourceAsync(manager, ct)
+ .ConfigureAwait(false);
+ using CapturedKeyLogKeyResolver? resolver = await CreateKeyResolverAsync(keyLogPath, ct)
+ .ConfigureAwait(false);
+ PubSubOfflineDissector dissector = CreateDissector(resolver);
+ return await FormatAsync(source.ReadCapturedFramesAsync(maxFrames, ct), dissector, format, ct)
+ .ConfigureAwait(false);
+ }
+
+ ///
+ /// Decodes a libpcap file containing UDP PubSub UADP datagrams.
+ ///
+ [McpServerTool(Name = "pubsub_decode_pcap")]
+ [Description("Reads a libpcap .pcap file containing Ethernet/IPv4/UDP PubSub UADP datagrams and dissects " +
+ "the UDP payloads. pcapng input is not supported by the shared pcap reader.")]
+ public static async Task> DecodePcapAsync(
+ IServiceProvider services,
+ [Description("Absolute or relative .pcap path under the MCP pcap base folder.")] string pcapPath,
+ [Description("Output format: text | json.")] string format = "text",
+ [Description("Optional PubSub JSON-lines key-log path under the MCP pcap base folder.")]
+ string? keyLogPath = null,
+ [Description("Maximum UDP frames to dissect. Default = all.")] long? maxFrames = null,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentException.ThrowIfNullOrWhiteSpace(pcapPath);
+
+ string allowedRoot = GetPcapAllowedRoot(services);
+ pcapPath = PacketDecodeTools.ResolveAndValidateDecodePath(pcapPath, allowedRoot);
+ keyLogPath = string.IsNullOrWhiteSpace(keyLogPath)
+ ? null
+ : PacketDecodeTools.ResolveAndValidateDecodePath(keyLogPath, allowedRoot);
+
+ using CapturedKeyLogKeyResolver? resolver = await CreateKeyResolverAsync(keyLogPath, ct)
+ .ConfigureAwait(false);
+ PubSubOfflineDissector dissector = CreateDissector(resolver);
+ return await FormatAsync(ReadPubSubFramesFromPcapAsync(pcapPath, maxFrames, ct), dissector, format, ct)
+ .ConfigureAwait(false);
+ }
+
+ ///
+ /// Loads a PubSub key-log file for later capture dissection.
+ ///
+ [McpServerTool(Name = "pubsub_load_keylog")]
+ [Description("Loads a PubSub JSON-lines key-log file and keeps it in memory for later pubsub_dissect_capture " +
+ "or pubsub_decode_pcap calls when keyLogPath is omitted. Treat this file as a secret.")]
+ public static async Task LoadKeyLogAsync(
+ IServiceProvider services,
+ [Description("PubSub JSON-lines key-log path under the MCP pcap base folder.")] string keyLogPath,
+ CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentException.ThrowIfNullOrWhiteSpace(keyLogPath);
+
+ string allowedRoot = GetPcapAllowedRoot(services);
+ keyLogPath = PacketDecodeTools.ResolveAndValidateDecodePath(keyLogPath, allowedRoot);
+ List keys = await ReadKeyMaterialAsync(keyLogPath, ct).ConfigureAwait(false);
+ await StoreLoadedKeyMaterialAsync(keys, ct).ConfigureAwait(false);
+ return new PubSubKeyLogInfo
+ {
+ FilePath = keyLogPath,
+ KeyCount = keys.Count
+ };
+ }
+
+ private static async Task> FormatAsync(
+ IAsyncEnumerable frames,
+ PubSubOfflineDissector dissector,
+ string format,
+ CancellationToken ct)
+ {
+ string normalized = format.Trim().ToLowerInvariant();
+ if (normalized is "json")
+ {
+ var formatter = new PubSubJsonFormatter();
+ byte[] bytes = await formatter.FormatAsync(frames, dissector, ct).ConfigureAwait(false);
+ return CreateText(Encoding.UTF8.GetString(bytes));
+ }
+
+ if (normalized is "text" or "")
+ {
+ var formatter = new PubSubTextFormatter();
+ string text = await formatter.FormatAsync(frames, dissector, ct).ConfigureAwait(false);
+ return CreateText(text);
+ }
+
+ throw new PcapDiagnosticsException(
+ $"Unsupported PubSub dissection format '{format}'. Use text or json.");
+ }
+
+ private static async ValueTask CreateKeyResolverAsync(
+ string? keyLogPath,
+ CancellationToken ct)
+ {
+ List? keys = string.IsNullOrWhiteSpace(keyLogPath)
+ ? await CopyLoadedKeyMaterialAsync(ct).ConfigureAwait(false)
+ : await ReadKeyMaterialAsync(keyLogPath!, ct).ConfigureAwait(false);
+ if (keys.Count == 0)
+ {
+ return null;
+ }
+
+ try
+ {
+ return new CapturedKeyLogKeyResolver(keys);
+ }
+ finally
+ {
+ foreach (PubSubKeyMaterial key in keys)
+ {
+ key.Dispose();
+ }
+ }
+ }
+
+ private static PubSubOfflineDissector CreateDissector(IPubSubKeyResolver? resolver)
+ {
+ if (resolver is null)
+ {
+ return new PubSubOfflineDissector();
+ }
+
+ var context = new PubSubNetworkMessageContext(
+ ServiceMessageContext.CreateEmpty(null!),
+ new DataSetMetaDataRegistry(),
+ new PubSubDiagnostics(PubSubDiagnosticsLevel.Low),
+ TimeProvider.System);
+ return new PubSubOfflineDissector(context, resolver);
+ }
+
+ private static async Task> ReadKeyMaterialAsync(
+ string keyLogPath,
+ CancellationToken ct)
+ {
+ var reader = new PubSubKeyLogReader(keyLogPath);
+ List keys = [];
+ await foreach (PubSubKeyMaterial key in reader.ReadAllAsync(ct).WithCancellation(ct).ConfigureAwait(false))
+ {
+ keys.Add(CopyKeyMaterial(key));
+ key.Dispose();
+ }
+
+ return keys;
+ }
+
+ private static async ValueTask StoreLoadedKeyMaterialAsync(
+ List keys,
+ CancellationToken ct)
+ {
+ await m_loadedKeyGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ foreach (PubSubKeyMaterial key in m_loadedKeyMaterial)
+ {
+ key.Dispose();
+ }
+
+ m_loadedKeyMaterial = [.. keys];
+ }
+ finally
+ {
+ m_loadedKeyGate.Release();
+ }
+ }
+
+ private static async ValueTask> CopyLoadedKeyMaterialAsync(CancellationToken ct)
+ {
+ await m_loadedKeyGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ List keys = [];
+ foreach (PubSubKeyMaterial key in m_loadedKeyMaterial)
+ {
+ keys.Add(CopyKeyMaterial(key));
+ }
+
+ return keys;
+ }
+ finally
+ {
+ m_loadedKeyGate.Release();
+ }
+ }
+
+ private static async IAsyncEnumerable ReadPubSubFramesFromPcapAsync(
+ string pcapPath,
+ long? maxFrames,
+ [EnumeratorCancellation] CancellationToken ct)
+ {
+ long yielded = 0;
+ await foreach (PcapRecord record in PcapFileReader.ReadAllAsync(pcapPath, ct)
+ .WithCancellation(ct)
+ .ConfigureAwait(false))
+ {
+ if (maxFrames.HasValue && yielded >= maxFrames.Value)
+ {
+ yield break;
+ }
+
+ if (!TryGetUdpPayload(record, out ReadOnlyMemory payload, out string? endpoint))
+ {
+ continue;
+ }
+
+ yielded++;
+ yield return new PubSubCaptureFrame(
+ record.Timestamp,
+ PubSubCaptureDirection.Unknown,
+ kUadpTransportProfileUri,
+ payload.ToArray(),
+ endpoint);
+ }
+ }
+
+ private static bool TryGetUdpPayload(
+ in PcapRecord record,
+ out ReadOnlyMemory payload,
+ out string? endpoint)
+ {
+ payload = default;
+ endpoint = null;
+ ReadOnlySpan data = record.Data.Span;
+ if (record.LinkType != kLinkTypeEthernet)
+ {
+ payload = record.Data;
+ return data.Length > 0;
+ }
+
+ if (data.Length < 42 || BinaryPrimitives.ReadUInt16BigEndian(data[12..14]) != 0x0800)
+ {
+ return false;
+ }
+
+ int ipOffset = 14;
+ int headerLength = (data[ipOffset] & 0x0F) * 4;
+ if (headerLength < 20 || data.Length < ipOffset + headerLength + 8 || data[ipOffset + 9] != 17)
+ {
+ return false;
+ }
+
+ int udpOffset = ipOffset + headerLength;
+ ushort udpLength = BinaryPrimitives.ReadUInt16BigEndian(data[(udpOffset + 4)..(udpOffset + 6)]);
+ if (udpLength < 8 || data.Length < udpOffset + udpLength)
+ {
+ return false;
+ }
+
+ ushort destinationPort = BinaryPrimitives.ReadUInt16BigEndian(data[(udpOffset + 2)..(udpOffset + 4)]);
+ IPAddress destinationAddress = new(data.Slice(ipOffset + 16, 4));
+ endpoint = FormattableString.Invariant($"{destinationAddress}:{destinationPort}");
+ payload = record.Data.Slice(udpOffset + 8, udpLength - 8);
+ return payload.Length > 0;
+ }
+
+ private static IList CreateText(string text)
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(text);
+ if (bytes.LongLength > kMaxResponseBytes)
+ {
+ throw new PcapDiagnosticsException(
+ $"PubSub dissection output is {bytes.LongLength} bytes, which exceeds the 10 MB MCP " +
+ "response cap.");
+ }
+
+ return
+ [
+ new TextContentBlock
+ {
+ Text = text
+ }
+ ];
+ }
+
+ private static string GetPcapAllowedRoot(IServiceProvider services)
+ {
+ OpcUaMcpServerOptions? mcpOptions =
+ services.GetService(typeof(OpcUaMcpServerOptions)) as OpcUaMcpServerOptions;
+ if (mcpOptions is not null &&
+ !string.IsNullOrWhiteSpace(mcpOptions.PcapBaseFolder))
+ {
+ return Path.GetFullPath(mcpOptions.PcapBaseFolder!);
+ }
+
+ PcapOptions? options = services.GetService(typeof(PcapOptions)) as PcapOptions;
+ return options?.BaseFolder ??
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "OPCFoundation",
+ "opcua-pcap");
+ }
+
+ private static PubSubKeyMaterial CopyKeyMaterial(PubSubKeyMaterial key)
+ {
+ return new PubSubKeyMaterial(
+ key.SecurityGroupId,
+ key.TokenId,
+ key.SecurityPolicyUri,
+ key.SigningKey.ToArray(),
+ key.EncryptingKey.ToArray(),
+ key.KeyNonce.ToArray());
+ }
+
+ private static readonly SemaphoreSlim m_loadedKeyGate = new(1, 1);
+ private static IReadOnlyList m_loadedKeyMaterial = [];
+ }
+
+ ///
+ /// Status of a loaded PubSub key-log file.
+ ///
+ public sealed class PubSubKeyLogInfo
+ {
+ ///
+ /// Gets the key-log file path.
+ ///
+ public string FilePath { get; init; } = string.Empty;
+
+ ///
+ /// Gets the number of loaded keys.
+ ///
+ public int KeyCount { get; init; }
+ }
+}
+
+
+
+
+
+
+
diff --git a/Applications/McpServer/Tools/PubSubDiscoveryTools.cs b/Applications/McpServer/Tools/PubSubDiscoveryTools.cs
new file mode 100644
index 0000000000..fd396d2a6e
--- /dev/null
+++ b/Applications/McpServer/Tools/PubSubDiscoveryTools.cs
@@ -0,0 +1,178 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.ComponentModel;
+using System.Threading;
+using System.Threading.Tasks;
+using ModelContextProtocol.Server;
+using Opc.Ua.PubSub.Application;
+using Opc.Ua.PubSub.Encoding.Uadp;
+
+namespace Opc.Ua.Mcp.Tools
+{
+ ///
+ /// MCP tools that exercise OPC UA PubSub discovery (Part 14 §7.2.4.6):
+ /// they send a discovery request from the active in-process PubSub runtime
+ /// and collect the publisher responses.
+ ///
+ [McpServerToolType]
+ public sealed class PubSubDiscoveryTools
+ {
+ ///
+ /// Requests DataSetMetaData discovery from PubSub publishers.
+ ///
+ [McpServerTool(Name = "pubsub_discover_metadata")]
+ [Description("Send a PubSub DataSetMetaData discovery request and collect publisher responses.")]
+ public static async Task DiscoverMetaDataAsync(
+ PubSubRuntimeManager manager,
+ [Description("DataSetWriterIds to query; empty queries all.")] ushort[]? dataSetWriterIds = null,
+ [Description("Collection window in milliseconds.")] int timeoutMs = 2000,
+ CancellationToken ct = default)
+ {
+ PubSubDiscoveryResult result = await RequestAsync(
+ manager, UadpDiscoveryType.DataSetMetaData, dataSetWriterIds, timeoutMs, ct)
+ .ConfigureAwait(false);
+ return Summarize(result);
+ }
+
+ ///
+ /// Requests DataSetWriterConfiguration discovery from PubSub publishers.
+ ///
+ [McpServerTool(Name = "pubsub_discover_writer_config")]
+ [Description("Send a PubSub DataSetWriterConfiguration discovery request and collect publisher responses.")]
+ public static async Task DiscoverWriterConfigurationAsync(
+ PubSubRuntimeManager manager,
+ [Description("DataSetWriterIds to query; empty queries all.")] ushort[]? dataSetWriterIds = null,
+ [Description("Collection window in milliseconds.")] int timeoutMs = 2000,
+ CancellationToken ct = default)
+ {
+ PubSubDiscoveryResult result = await RequestAsync(
+ manager, UadpDiscoveryType.DataSetWriterConfiguration, dataSetWriterIds, timeoutMs, ct)
+ .ConfigureAwait(false);
+ return Summarize(result);
+ }
+
+ ///
+ /// Requests PublisherEndpoints discovery from PubSub publishers.
+ ///
+ [McpServerTool(Name = "pubsub_discover_publisher_endpoints")]
+ [Description("Send a PubSub PublisherEndpoints discovery request and collect publisher responses.")]
+ public static async Task DiscoverPublisherEndpointsAsync(
+ PubSubRuntimeManager manager,
+ [Description("Collection window in milliseconds.")] int timeoutMs = 2000,
+ CancellationToken ct = default)
+ {
+ PubSubDiscoveryResult result = await RequestAsync(
+ manager, UadpDiscoveryType.PublisherEndpoints, dataSetWriterIds: null, timeoutMs, ct)
+ .ConfigureAwait(false);
+ return Summarize(result);
+ }
+
+ private static async Task RequestAsync(
+ PubSubRuntimeManager manager,
+ UadpDiscoveryType discoveryType,
+ ushort[]? dataSetWriterIds,
+ int timeoutMs,
+ CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(manager);
+ var request = new PubSubDiscoveryRequest
+ {
+ DiscoveryType = discoveryType,
+ DataSetWriterIds = dataSetWriterIds is null ? [] : [.. dataSetWriterIds]
+ };
+ TimeSpan timeout = TimeSpan.FromMilliseconds(timeoutMs <= 0 ? 2000 : timeoutMs);
+ return await manager.RequestDiscoveryAsync(request, timeout, ct).ConfigureAwait(false);
+ }
+
+ private static PubSubDiscoverySummary Summarize(PubSubDiscoveryResult result)
+ {
+ var metaData = new PubSubDiscoveredMetaData[result.DataSetMetaDataEntries.Count];
+ for (int i = 0; i < result.DataSetMetaDataEntries.Count; i++)
+ {
+ PubSubDataSetMetaDataDiscoveryResult entry = result.DataSetMetaDataEntries[i];
+ metaData[i] = new PubSubDiscoveredMetaData(
+ entry.PublisherId.ToString(),
+ entry.WriterGroupId,
+ entry.DataSetWriterId,
+ entry.StatusCode.ToString(),
+ entry.DataSetMetaData?.Name,
+ entry.DataSetMetaData is null ? 0 : entry.DataSetMetaData.Fields.Count);
+ }
+
+ var writerConfigs = new PubSubDiscoveredWriterConfig[result.WriterConfigurations.Count];
+ for (int i = 0; i < result.WriterConfigurations.Count; i++)
+ {
+ PubSubDataSetWriterConfigurationDiscoveryResult entry = result.WriterConfigurations[i];
+ writerConfigs[i] = new PubSubDiscoveredWriterConfig(
+ entry.PublisherId.ToString(),
+ entry.WriterGroupId,
+ entry.DataSetWriterIds,
+ entry.StatusCode.ToString());
+ }
+
+ var endpoints = new string[result.PublisherEndpoints.Count];
+ for (int i = 0; i < result.PublisherEndpoints.Count; i++)
+ {
+ endpoints[i] = result.PublisherEndpoints[i].EndpointUrl ?? string.Empty;
+ }
+
+ return new PubSubDiscoverySummary([.. metaData], [.. writerConfigs], [.. endpoints]);
+ }
+ }
+
+ ///
+ /// One discovered DataSetMetaData entry.
+ ///
+ public sealed record PubSubDiscoveredMetaData(
+ string PublisherId,
+ ushort WriterGroupId,
+ ushort DataSetWriterId,
+ string StatusCode,
+ string? Name,
+ int FieldCount);
+
+ ///
+ /// One discovered DataSetWriterConfiguration entry.
+ ///
+ public sealed record PubSubDiscoveredWriterConfig(
+ string PublisherId,
+ ushort WriterGroupId,
+ ArrayOf DataSetWriterIds,
+ string StatusCode);
+
+ ///
+ /// Aggregated PubSub discovery result returned to MCP callers.
+ ///
+ public sealed record PubSubDiscoverySummary(
+ ArrayOf MetaData,
+ ArrayOf WriterConfigurations,
+ ArrayOf PublisherEndpointUrls);
+}
diff --git a/Applications/McpServer/Tools/PubSubRuntimeTools.cs b/Applications/McpServer/Tools/PubSubRuntimeTools.cs
new file mode 100644
index 0000000000..9bf6a5ee2b
--- /dev/null
+++ b/Applications/McpServer/Tools/PubSubRuntimeTools.cs
@@ -0,0 +1,135 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System.ComponentModel;
+using System.Threading;
+using System.Threading.Tasks;
+using ModelContextProtocol.Server;
+
+namespace Opc.Ua.Mcp.Tools
+{
+ ///
+ /// MCP tools for an in-process OPC UA PubSub publisher or subscriber.
+ ///
+ [McpServerToolType]
+ public sealed class PubSubRuntimeTools
+ {
+ ///
+ /// Starts an in-process UDP/UADP publisher.
+ ///
+ [McpServerTool(Name = "pubsub_runtime_start_publisher")]
+ [Description("Starts an in-process OPC UA PubSub UDP/UADP publisher. fieldSpec uses name:type pairs.")]
+ public static async Task StartPublisherAsync(
+ PubSubRuntimeManager manager,
+ [Description("UDP endpoint URL, for example opc.udp://239.0.0.1:4840")] string udpUrl,
+ [Description("PublisherId to place in NetworkMessage headers")] ushort publisherId,
+ [Description("WriterGroupId to place in NetworkMessage group headers")] ushort writerGroupId,
+ [Description("Optional DataSet field spec as name:type pairs separated by ';'")] string? fieldSpec = null,
+ CancellationToken ct = default)
+ {
+ return await manager.StartPublisherAsync(
+ udpUrl,
+ publisherId,
+ writerGroupId,
+ fieldSpec,
+ ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Starts an in-process UDP/UADP subscriber.
+ ///
+ [McpServerTool(Name = "pubsub_runtime_start_subscriber")]
+ [Description("Starts an in-process OPC UA PubSub UDP/UADP subscriber and buffers received DataSets.")]
+ public static async Task StartSubscriberAsync(
+ PubSubRuntimeManager manager,
+ [Description("UDP endpoint URL, for example opc.udp://239.0.0.1:4840")] string udpUrl,
+ [Description("PublisherId filter")] ushort publisherId,
+ [Description("WriterGroupId filter")] ushort writerGroupId,
+ [Description("Optional DataSet field spec as name:type pairs separated by ';'")] string? fieldSpec = null,
+ CancellationToken ct = default)
+ {
+ return await manager.StartSubscriberAsync(
+ udpUrl,
+ publisherId,
+ writerGroupId,
+ fieldSpec,
+ ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Updates the active publisher's DataSet fields.
+ ///
+ [McpServerTool(Name = "pubsub_runtime_publish")]
+ [Description("Updates active publisher fields. Use JSON object text or name=value pairs separated by ';'.")]
+ public static async Task PublishAsync(
+ PubSubRuntimeManager manager,
+ [Description("Field values as JSON object text or name=value pairs")] string fieldValues,
+ CancellationToken ct = default)
+ {
+ return await manager.PublishAsync(fieldValues, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Reads buffered DataSets from the active subscriber.
+ ///
+ [McpServerTool(Name = "pubsub_runtime_read_received")]
+ [Description("Returns the DataSets buffered by the active subscriber.")]
+ public static async Task> ReadReceivedAsync(
+ PubSubRuntimeManager manager,
+ [Description("Clear the receive buffer after reading")] bool clear = false,
+ CancellationToken ct = default)
+ {
+ return await manager.ReadReceivedAsync(clear, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Reports the in-process PubSub runtime status.
+ ///
+ [McpServerTool(Name = "pubsub_runtime_status")]
+ [Description("Reports whether the in-process PubSub runtime is running as a publisher or subscriber.")]
+ public static async Task StatusAsync(
+ PubSubRuntimeManager manager,
+ CancellationToken ct = default)
+ {
+ return await manager.StatusAsync(ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Stops the in-process PubSub runtime.
+ ///
+ [McpServerTool(Name = "pubsub_runtime_stop")]
+ [Description("Stops and disposes the active in-process PubSub publisher or subscriber.")]
+ public static async Task StopAsync(
+ PubSubRuntimeManager manager,
+ CancellationToken ct = default)
+ {
+ return await manager.StopAsync(ct).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1456cb6bfb..8dcf49f2d1 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -15,6 +15,7 @@
+
@@ -64,8 +65,10 @@
$(NoWarn);SYSLIB1100;SYSLIB1101
-
-
- true
-
@@ -50,6 +42,7 @@
+
diff --git a/Libraries/Opc.Ua.PubSub.Adapter/Actions/ActionMethodBinding.cs b/Libraries/Opc.Ua.PubSub.Adapter/Actions/ActionMethodBinding.cs
new file mode 100644
index 0000000000..d546ace17d
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub.Adapter/Actions/ActionMethodBinding.cs
@@ -0,0 +1,82 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+namespace Opc.Ua.PubSub.Adapter.Actions
+{
+ ///
+ /// Resolves a PubSub Action target to the external OPC UA object and
+ /// method that an calls when the
+ /// action is invoked. The optional output field names are applied, in
+ /// order, to the method's output arguments when the result is mapped back
+ /// to a PubSub Action response; positions without a configured name fall
+ /// back to a generated Output{i} name.
+ ///
+ public readonly record struct ActionMethodBinding
+ {
+ ///
+ /// Initializes a new .
+ ///
+ ///
+ /// The external object that provides the method to call.
+ ///
+ ///
+ /// The external method invoked for the action.
+ ///
+ ///
+ /// Optional output field names applied, in order, to the method's
+ /// output arguments. Defaults to empty, in which case generated names
+ /// are used.
+ ///
+ public ActionMethodBinding(
+ NodeId objectId,
+ NodeId methodId,
+ ArrayOf outputFieldNames = default)
+ {
+ ObjectId = objectId;
+ MethodId = methodId;
+ OutputFieldNames = outputFieldNames;
+ }
+
+ ///
+ /// The external object that provides the method to call.
+ ///
+ public NodeId ObjectId { get; init; }
+
+ ///
+ /// The external method invoked for the action.
+ ///
+ public NodeId MethodId { get; init; }
+
+ ///
+ /// Output field names applied, in order, to the method's output
+ /// arguments. Empty when generated names should be used.
+ ///
+ public ArrayOf OutputFieldNames { get; init; }
+ }
+}
diff --git a/Libraries/Opc.Ua.PubSub.Adapter/Actions/ActionMethodMap.cs b/Libraries/Opc.Ua.PubSub.Adapter/Actions/ActionMethodMap.cs
new file mode 100644
index 0000000000..27ced8cd3a
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub.Adapter/Actions/ActionMethodMap.cs
@@ -0,0 +1,198 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using Opc.Ua.PubSub.Adapter.Session;
+using Opc.Ua.PubSub.Application;
+
+namespace Opc.Ua.PubSub.Adapter.Actions
+{
+ ///
+ /// Maps a to the external OPC UA object
+ /// and method an invokes. A
+ /// target is identified by its
+ /// and pair, or by its
+ /// . Resolution prefers the
+ /// writer/target pair and falls back to the action name. The fluent
+ /// Add overloads return the same instance so multiple targets can be
+ /// registered in a single expression.
+ ///
+ public sealed class ActionMethodMap
+ {
+ private readonly Dictionary<(ushort, ushort), ActionMethodBinding> m_byTargetId
+ = [];
+ private readonly Dictionary m_byActionName
+ = new(StringComparer.Ordinal);
+
+ ///
+ /// Maps a DataSetWriter/ActionTarget pair to the external object and
+ /// method to call.
+ ///
+ ///
+ /// The DataSetWriterId that owns the action metadata.
+ ///
+ ///
+ /// The ActionTargetId unique within the action metadata.
+ ///
+ ///
+ /// The external object that provides the method to call.
+ ///
+ ///
+ /// The external method invoked for the action.
+ ///
+ ///
+ /// Optional output field names applied, in order, to the method's
+ /// output arguments.
+ ///
+ ///
+ /// This instance, to allow fluent registration of multiple targets.
+ ///
+ public ActionMethodMap Add(
+ ushort dataSetWriterId,
+ ushort actionTargetId,
+ NodeId objectId,
+ NodeId methodId,
+ ArrayOf outputFieldNames = default)
+ {
+ m_byTargetId[(dataSetWriterId, actionTargetId)] =
+ new ActionMethodBinding(objectId, methodId, outputFieldNames);
+ return this;
+ }
+
+ ///
+ /// Maps an action name to the external object and method to call.
+ ///
+ ///
+ /// The action name used to resolve the target.
+ ///
+ ///
+ /// The external object that provides the method to call.
+ ///
+ ///
+ /// The external method invoked for the action.
+ ///
+ ///
+ /// Optional output field names applied, in order, to the method's
+ /// output arguments.
+ ///
+ ///
+ /// This instance, to allow fluent registration of multiple targets.
+ ///
+ public ActionMethodMap Add(
+ string actionName,
+ NodeId objectId,
+ NodeId methodId,
+ ArrayOf outputFieldNames = default)
+ {
+ if (string.IsNullOrEmpty(actionName))
+ {
+ throw new ArgumentException(
+ "Action name must be specified.", nameof(actionName));
+ }
+ m_byActionName[actionName] =
+ new ActionMethodBinding(objectId, methodId, outputFieldNames);
+ return this;
+ }
+
+ ///
+ /// Maps an action name to an external object and method addressed by
+ /// relative browse paths (for example /2:Demo and
+ /// /2:Demo/2:ResetCounters) instead of concrete node ids. The
+ /// paths are resolved against the server the first time the action is
+ /// invoked. See for the supported syntax.
+ ///
+ ///
+ /// The action name used to resolve the target.
+ ///
+ ///
+ /// The relative browse path of the external object that provides the
+ /// method to call.
+ ///
+ ///
+ /// The relative browse path of the external method invoked for the
+ /// action.
+ ///
+ ///
+ /// Optional output field names applied, in order, to the method's
+ /// output arguments.
+ ///
+ ///
+ /// This instance, to allow fluent registration of multiple targets.
+ ///
+ public ActionMethodMap Add(
+ string actionName,
+ string objectBrowsePath,
+ string methodBrowsePath,
+ ArrayOf outputFieldNames = default)
+ {
+ return Add(
+ actionName,
+ NodeBrowsePath.ToNodeId(objectBrowsePath),
+ NodeBrowsePath.ToNodeId(methodBrowsePath),
+ outputFieldNames);
+ }
+
+ ///
+ /// Resolves the external object/method binding for the supplied action
+ /// target. The DataSetWriter/ActionTarget pair is tried first, then the
+ /// action name.
+ ///
+ ///
+ /// The action target to resolve.
+ ///
+ ///
+ /// When this method returns true, the resolved binding;
+ /// otherwise the default value.
+ ///
+ ///
+ /// true when a binding was found; otherwise false.
+ ///
+ public bool TryResolve(
+ PubSubActionTarget target,
+ out ActionMethodBinding binding)
+ {
+ if (target is not null)
+ {
+ if (m_byTargetId.TryGetValue(
+ (target.DataSetWriterId, target.ActionTargetId), out binding))
+ {
+ return true;
+ }
+ if (!string.IsNullOrEmpty(target.ActionName)
+ && m_byActionName.TryGetValue(target.ActionName, out binding))
+ {
+ return true;
+ }
+ }
+ binding = default;
+ return false;
+ }
+ }
+}
diff --git a/Libraries/Opc.Ua.PubSub.Adapter/Actions/ServerActionHandler.cs b/Libraries/Opc.Ua.PubSub.Adapter/Actions/ServerActionHandler.cs
new file mode 100644
index 0000000000..6d63f7cc43
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub.Adapter/Actions/ServerActionHandler.cs
@@ -0,0 +1,210 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Opc.Ua.PubSub.Adapter.Diagnostics;
+using Opc.Ua.PubSub.Adapter.Session;
+using Opc.Ua.PubSub.Application;
+using Opc.Ua.PubSub.Encoding;
+
+namespace Opc.Ua.PubSub.Adapter.Actions
+{
+ ///
+ /// that maps an inbound PubSub Action
+ /// request to an OPC UA method call on an external server. The action
+ /// target is resolved to an external object/method pair through the
+ /// supplied ; the action input fields
+ /// become the method's input arguments, in order, and the method's output
+ /// arguments are mapped back to named response fields.
+ ///
+ ///
+ /// Handling is fail-soft: an unmapped target, a connection failure, or a
+ /// call fault is mapped to a Bad with empty output
+ /// fields and logged. The handler never throws for such faults; only
+ /// cancellation is propagated.
+ ///
+ public sealed class ServerActionHandler : IPubSubActionHandler
+ {
+ private readonly IServerSession m_session;
+ private readonly ActionMethodMap m_methodMap;
+ private readonly AdapterMetrics? m_metrics;
+ private readonly ILogger m_logger;
+
+ ///
+ /// Creates a new external-server action handler.
+ ///
+ ///
+ /// The external server session used to issue the method call.
+ ///
+ ///
+ /// The map that resolves an action target to the external object and
+ /// method to call.
+ ///
+ ///
+ /// The telemetry context used to create the logger.
+ ///
+ ///
+ /// Optional metrics sink that records method-call activity.
+ ///
+ public ServerActionHandler(
+ IServerSession session,
+ ActionMethodMap methodMap,
+ ITelemetryContext telemetry,
+ AdapterMetrics? metrics = null)
+ {
+ m_session = session ?? throw new ArgumentNullException(nameof(session));
+ m_methodMap = methodMap ?? throw new ArgumentNullException(nameof(methodMap));
+ if (telemetry is null)
+ {
+ throw new ArgumentNullException(nameof(telemetry));
+ }
+ m_metrics = metrics;
+ m_logger = telemetry.CreateLogger();
+ }
+
+ ///
+ public async ValueTask HandleAsync(
+ PubSubActionInvocation invocation,
+ CancellationToken cancellationToken = default)
+ {
+ if (invocation is null)
+ {
+ throw new ArgumentNullException(nameof(invocation));
+ }
+
+ if (!m_methodMap.TryResolve(invocation.Target, out ActionMethodBinding binding))
+ {
+ m_logger.LogInformation(
+ "No external method mapping for action target " +
+ "(DataSetWriterId={DataSetWriterId}, ActionTargetId={ActionTargetId}, " +
+ "ActionName={ActionName}); returning BadNodeIdUnknown.",
+ invocation.Target.DataSetWriterId,
+ invocation.Target.ActionTargetId,
+ invocation.Target.ActionName);
+ return new PubSubActionHandlerResult
+ {
+ StatusCode = (StatusCode)StatusCodes.BadNodeIdUnknown
+ };
+ }
+
+ try
+ {
+ if (!m_session.IsConnected)
+ {
+ await m_session.ConnectAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ArrayOf inputArguments = MapInputArguments(invocation.InputFields);
+
+ NodeId objectId = await m_session
+ .ResolveNodeIdAsync(binding.ObjectId, cancellationToken)
+ .ConfigureAwait(false);
+ NodeId methodId = await m_session
+ .ResolveNodeIdAsync(binding.MethodId, cancellationToken)
+ .ConfigureAwait(false);
+
+ RemoteCallResult result = await m_session.CallAsync(
+ objectId,
+ methodId,
+ inputArguments,
+ cancellationToken).ConfigureAwait(false);
+
+ m_metrics?.RecordCall(StatusCode.IsGood(result.Status));
+ return new PubSubActionHandlerResult
+ {
+ StatusCode = result.Status,
+ OutputFields = MapOutputFields(result.OutputArguments, binding.OutputFieldNames)
+ };
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ m_metrics?.RecordCall(false);
+ m_logger.LogInformation(ex,
+ "External method call failed for action target " +
+ "(DataSetWriterId={DataSetWriterId}, ActionTargetId={ActionTargetId}); " +
+ "returning BadUnexpectedError.",
+ invocation.Target.DataSetWriterId,
+ invocation.Target.ActionTargetId);
+ return new PubSubActionHandlerResult
+ {
+ StatusCode = (StatusCode)StatusCodes.BadUnexpectedError
+ };
+ }
+ }
+
+ private static ArrayOf MapInputArguments(ArrayOf inputFields)
+ {
+ if (inputFields.IsNull || inputFields.Count == 0)
+ {
+ return [];
+ }
+ var arguments = new Variant[inputFields.Count];
+ for (int i = 0; i < inputFields.Count; i++)
+ {
+ DataSetField field = inputFields[i];
+ arguments[i] = field is null ? Variant.Null : field.Value;
+ }
+ return arguments;
+ }
+
+ private static ArrayOf MapOutputFields(
+ ArrayOf outputArguments,
+ ArrayOf outputFieldNames)
+ {
+ if (outputArguments.IsNull || outputArguments.Count == 0)
+ {
+ return [];
+ }
+ bool hasNames = !outputFieldNames.IsNull && outputFieldNames.Count > 0;
+ var fields = new DataSetField[outputArguments.Count];
+ for (int i = 0; i < outputArguments.Count; i++)
+ {
+ string name = hasNames
+ && i < outputFieldNames.Count
+ && !string.IsNullOrEmpty(outputFieldNames[i])
+ ? outputFieldNames[i]
+ : string.Concat("Output", i.ToString(CultureInfo.InvariantCulture));
+ fields[i] = new DataSetField
+ {
+ Name = name,
+ Value = outputArguments[i]
+ };
+ }
+ return fields;
+ }
+ }
+}
diff --git a/Libraries/Opc.Ua.PubSub.Adapter/DependencyInjection/IServerSessionFactory.cs b/Libraries/Opc.Ua.PubSub.Adapter/DependencyInjection/IServerSessionFactory.cs
new file mode 100644
index 0000000000..1b5e1a7fcb
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub.Adapter/DependencyInjection/IServerSessionFactory.cs
@@ -0,0 +1,54 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using Opc.Ua.PubSub.Adapter.Session;
+
+namespace Opc.Ua.PubSub.Adapter.DependencyInjection
+{
+ ///
+ /// Creates instances that connect to an
+ /// external OPC UA server. Implementations are registered with the adapter
+ /// dependency-injection container so that publisher, subscriber and action
+ /// components can resolve sessions without taking a direct dependency on the
+ /// concrete session type.
+ ///
+ public interface IServerSessionFactory
+ {
+ ///
+ /// Creates a new, not-yet-connected external server session for the
+ /// supplied connection options and telemetry context. The returned
+ /// session connects lazily on the first call to
+ /// or any service
+ /// method.
+ ///
+ IServerSession Create(
+ ServerConnectionOptions options,
+ ITelemetryContext telemetry);
+ }
+}
diff --git a/Libraries/Opc.Ua.PubSub.Adapter/DependencyInjection/OpcUaPubSubAdapterBuilderExtensions.cs b/Libraries/Opc.Ua.PubSub.Adapter/DependencyInjection/OpcUaPubSubAdapterBuilderExtensions.cs
new file mode 100644
index 0000000000..30757903b0
--- /dev/null
+++ b/Libraries/Opc.Ua.PubSub.Adapter/DependencyInjection/OpcUaPubSubAdapterBuilderExtensions.cs
@@ -0,0 +1,683 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Opc.Ua;
+using Opc.Ua.PubSub.Adapter;
+using Opc.Ua.PubSub.Adapter.Actions;
+using Opc.Ua.PubSub.Adapter.DependencyInjection;
+using Opc.Ua.PubSub.Adapter.Diagnostics;
+using Opc.Ua.PubSub.Adapter.Publisher;
+using Opc.Ua.PubSub.Adapter.Session;
+using Opc.Ua.PubSub.Adapter.Subscriber;
+using Opc.Ua.PubSub.Application;
+using Opc.Ua.PubSub.DataSets;
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ ///
+ /// Fluent extensions that wire the external
+ /// OPC UA server PubSub adapters into the dependency-injection container:
+ /// a publisher that reads an external server's nodes and publishes them as
+ /// PubSub DataSets, a subscriber that writes received DataSet values back to
+ /// an external server, and an action responder that maps inbound PubSub
+ /// Action requests to external server method calls.
+ ///
+ ///
+ /// Every extension shares a single per
+ /// registration whose lifetime is owned by a singleton
+ /// : subscription coordinators are
+ /// started on application start and every session is closed on shutdown.
+ /// The configured PubSub configuration must be supplied (via
+ /// ,
+ /// or
+ /// InlineConfiguration options) before the adapter is added so the
+ /// composition step can enumerate the configured datasets and readers.
+ ///
+ public static class OpcUaPubSubAdapterBuilderExtensions
+ {
+ ///
+ /// Adds an external-server PubSub publisher. A single managed session is
+ /// created for the configured endpoint and reused across the publisher's
+ /// PublishedDataSets. In mode
+ /// one is created for the
+ /// whole configuration and started on application start; in
+ /// mode a shared
+ /// issues Read calls each publish cycle.
+ ///
+ ///
+ /// The PubSub builder to add the publisher to.
+ ///
+ ///
+ /// Callback that configures the publisher options.
+ ///
+ ///
+ /// The same builder, to allow fluent composition.
+ ///
+ public static IPubSubBuilder AddServerAsPublisher(
+ this IPubSubBuilder builder,
+ Action configure)
+ {
+ return AddServerAsPublisher(
+ builder, Microsoft.Extensions.Options.Options.DefaultName, configure);
+ }
+
+ ///
+ /// Adds an external-server PubSub publisher whose options are bound from
+ /// configuration under the supplied name.
+ ///
+ ///
+ /// The PubSub builder to add the publisher to.
+ ///
+ ///
+ /// The named-options registration name.
+ ///
+ ///
+ /// The configuration section that binds the publisher options. Object-typed
+ /// option members are not configuration-bindable and must be supplied from
+ /// code, for example through a post-configure callback.
+ ///
+ ///
+ /// The same builder, to allow fluent composition.
+ ///
+ public static IPubSubBuilder AddServerAsPublisher(
+ this IPubSubBuilder builder,
+ string name,
+ IConfiguration configuration)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (name is null)
+ {
+ throw new ArgumentNullException(nameof(name));
+ }
+ if (configuration is null)
+ {
+ throw new ArgumentNullException(nameof(configuration));
+ }
+
+ RegisterConfigurationOptions(
+ builder.Services, name, configuration, BindPublisherOptions);
+
+ return AddServerAsPublisherCore(builder, name);
+ }
+
+ private static IPubSubBuilder AddServerAsPublisher(
+ IPubSubBuilder builder,
+ string name,
+ Action configure)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (name is null)
+ {
+ throw new ArgumentNullException(nameof(name));
+ }
+ if (configure is null)
+ {
+ throw new ArgumentNullException(nameof(configure));
+ }
+
+ builder.Services.Configure(name, configure);
+
+ return AddServerAsPublisherCore(builder, name);
+ }
+
+ private static IPubSubBuilder AddServerAsPublisherCore(IPubSubBuilder builder, string name)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ RegisterCoreServices(builder);
+
+ builder.ConfigureApplication((sp, pb) =>
+ {
+ PubSubConfigurationDataType configuration = pb.GetConfigurationOrDefault();
+ ServerAdapterReloadCoordinator coordinator =
+ sp.GetRequiredService();
+ coordinator.RegisterPublisherBinding(name);
+ coordinator.ApplyInitialConfiguration(configuration, pb);
+ });
+
+ return builder;
+ }
+
+ ///
+ /// Adds an external-server PubSub subscriber. A single managed session is
+ /// created for the configured endpoint and a
+ /// is registered for every
+ /// DataSetReader whose SubscribedDataSet is a
+ /// .
+ ///
+ ///
+ /// The PubSub builder to add the subscriber to.
+ ///
+ ///
+ /// Callback that configures the subscriber options.
+ ///
+ ///
+ /// The same builder, to allow fluent composition.
+ ///
+ public static IPubSubBuilder AddServerAsSubscriber(
+ this IPubSubBuilder builder,
+ Action configure)
+ {
+ return AddServerAsSubscriber(
+ builder, Microsoft.Extensions.Options.Options.DefaultName, configure);
+ }
+
+ ///
+ /// Adds an external-server PubSub subscriber whose options are bound from
+ /// configuration under the supplied name.
+ ///
+ ///
+ /// The PubSub builder to add the subscriber to.
+ ///
+ ///
+ /// The named-options registration name.
+ ///
+ ///
+ /// The configuration section that binds the subscriber options. Object-typed
+ /// option members are not configuration-bindable and must be supplied from
+ /// code, for example through a post-configure callback.
+ ///
+ ///
+ /// The same builder, to allow fluent composition.
+ ///
+ public static IPubSubBuilder AddServerAsSubscriber(
+ this IPubSubBuilder builder,
+ string name,
+ IConfiguration configuration)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (name is null)
+ {
+ throw new ArgumentNullException(nameof(name));
+ }
+ if (configuration is null)
+ {
+ throw new ArgumentNullException(nameof(configuration));
+ }
+
+ RegisterConfigurationOptions(
+ builder.Services, name, configuration, BindSubscriberOptions);
+
+ return AddServerAsSubscriberCore(builder, name);
+ }
+
+ private static IPubSubBuilder AddServerAsSubscriber(
+ IPubSubBuilder builder,
+ string name,
+ Action configure)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (name is null)
+ {
+ throw new ArgumentNullException(nameof(name));
+ }
+ if (configure is null)
+ {
+ throw new ArgumentNullException(nameof(configure));
+ }
+
+ builder.Services.Configure(name, configure);
+
+ return AddServerAsSubscriberCore(builder, name);
+ }
+
+ private static IPubSubBuilder AddServerAsSubscriberCore(IPubSubBuilder builder, string name)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ RegisterCoreServices(builder);
+
+ builder.ConfigureApplication((sp, pb) =>
+ {
+ PubSubConfigurationDataType configuration = pb.GetConfigurationOrDefault();
+ ServerAdapterReloadCoordinator coordinator =
+ sp.GetRequiredService();
+ coordinator.RegisterSubscriberBinding(name);
+ coordinator.ApplyInitialConfiguration(configuration, pb);
+ });
+
+ return builder;
+ }
+
+ ///
+ /// Adds an external-server PubSub action responder. A single managed
+ /// session is created for the configured endpoint and an
+ /// backed by the configured
+ /// is
+ /// registered for every configured target.
+ ///
+ ///
+ /// The PubSub builder to add the action responder to.
+ ///
+ ///
+ /// Callback that configures the action responder options.
+ ///
+ ///
+ /// The same builder, to allow fluent composition.
+ ///
+ public static IPubSubBuilder AddServerAsActionResponder(
+ this IPubSubBuilder builder,
+ Action configure)
+ {
+ return AddServerAsActionResponder(
+ builder, Microsoft.Extensions.Options.Options.DefaultName, configure);
+ }
+
+ ///
+ /// Adds an external-server PubSub action responder whose options are bound
+ /// from configuration under the supplied name.
+ ///
+ ///
+ /// The PubSub builder to add the action responder to.
+ ///
+ ///
+ /// The named-options registration name.
+ ///
+ ///
+ /// The configuration section that binds the action responder options.
+ /// Object-typed option members are not configuration-bindable and must be
+ /// supplied from code, for example through a post-configure callback.
+ ///
+ ///
+ /// The same builder, to allow fluent composition.
+ ///
+ public static IPubSubBuilder AddServerAsActionResponder(
+ this IPubSubBuilder builder,
+ string name,
+ IConfiguration configuration)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (name is null)
+ {
+ throw new ArgumentNullException(nameof(name));
+ }
+ if (configuration is null)
+ {
+ throw new ArgumentNullException(nameof(configuration));
+ }
+
+ RegisterConfigurationOptions(
+ builder.Services, name, configuration, BindActionResponderOptions);
+
+ return AddServerAsActionResponderCore(builder, name);
+ }
+
+ private static IPubSubBuilder AddServerAsActionResponder(
+ IPubSubBuilder builder,
+ string name,
+ Action configure)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (name is null)
+ {
+ throw new ArgumentNullException(nameof(name));
+ }
+ if (configure is null)
+ {
+ throw new ArgumentNullException(nameof(configure));
+ }
+
+ builder.Services.Configure(name, configure);
+
+ return AddServerAsActionResponderCore(builder, name);
+ }
+
+ private static IPubSubBuilder AddServerAsActionResponderCore(
+ IPubSubBuilder builder,
+ string name)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ RegisterCoreServices(builder);
+
+ builder.ConfigureApplication((sp, pb) =>
+ {
+ PubSubConfigurationDataType configuration = pb.GetConfigurationOrDefault();
+ ServerAdapterReloadCoordinator coordinator =
+ sp.GetRequiredService();
+ coordinator.RegisterActionResponderBinding(name);
+ coordinator.ApplyInitialConfiguration(configuration, pb);
+ });
+
+ return builder;
+ }
+
+ private static void RegisterCoreServices(IPubSubBuilder builder)
+ {
+ builder.Services.TryAddSingleton();
+ builder.Services.TryAddSingleton();
+ builder.Services.TryAddSingleton();
+ builder.Services.TryAddSingleton();
+ builder.Services.TryAddSingleton();
+ builder.Services.RemoveAll();
+ builder.Services.RemoveAll();
+ builder.Services.AddSingleton(
+ sp => sp.GetRequiredService());
+ builder.Services.AddSingleton(
+ sp => sp.GetRequiredService());
+ builder.Services.TryAddSingleton();
+ builder.Services.TryAddEnumerable(
+ ServiceDescriptor.Singleton());
+ }
+
+ private static void RegisterConfigurationOptions(
+ IServiceCollection services,
+ string name,
+ IConfiguration configuration,
+ Action bind)
+ where TOptions : class
+ {
+ services.AddSingleton>(
+ new ConfigurationChangeTokenSource(name, configuration));
+ services.AddSingleton>(
+ new ConfigureNamedOptions(
+ name, options => bind(options, configuration)));
+ }
+
+ private static void BindPublisherOptions(
+ ServerPublisherOptions options,
+ IConfiguration configuration)
+ {
+ options.Connection ??= new ServerConnectionOptions();
+ BindConnectionOptions(
+ options.Connection,
+ configuration.GetSection(nameof(ServerPublisherOptions.Connection)));
+ BindEnum(
+ configuration,
+ nameof(ServerPublisherOptions.ReadMode),
+ value => options.ReadMode = value);
+ BindEnum(
+ configuration,
+ nameof(ServerPublisherOptions.Affinity),
+ value => options.Affinity = value);
+ }
+
+ private static void BindSubscriberOptions(
+ ServerSubscriberOptions options,
+ IConfiguration configuration)
+ {
+ options.Connection ??= new ServerConnectionOptions();
+ BindConnectionOptions(
+ options.Connection,
+ configuration.GetSection(nameof(ServerSubscriberOptions.Connection)));
+ }
+
+ private static void BindActionResponderOptions(
+ ServerActionResponderOptions options,
+ IConfiguration configuration)
+ {
+ options.Connection ??= new ServerConnectionOptions();
+ BindConnectionOptions(
+ options.Connection,
+ configuration.GetSection(nameof(ServerActionResponderOptions.Connection)));
+ BindBoolean(
+ configuration,
+ nameof(ServerActionResponderOptions.AllowUnsecured),
+ value => options.AllowUnsecured = value);
+ }
+
+ private static void BindConnectionOptions(
+ ServerConnectionOptions options,
+ IConfiguration configuration)
+ {
+ BindString(
+ configuration,
+ nameof(ServerConnectionOptions.EndpointUrl),
+ value => options.EndpointUrl = value);
+ BindEnum(
+ configuration,
+ nameof(ServerConnectionOptions.SecurityMode),
+ value => options.SecurityMode = value);
+ BindString(
+ configuration,
+ nameof(ServerConnectionOptions.SecurityPolicyUri),
+ value => options.SecurityPolicyUri = value);
+ BindString(
+ configuration,
+ nameof(ServerConnectionOptions.UserName),
+ value => options.UserName = value);
+ BindString(
+ configuration,
+ nameof(ServerConnectionOptions.Password),
+ value => options.Password = value);
+ BindString(
+ configuration,
+ nameof(ServerConnectionOptions.SessionName),
+ value => options.SessionName = value);
+ BindUnsignedInteger(
+ configuration,
+ nameof(ServerConnectionOptions.SessionTimeout),
+ value => options.SessionTimeout = value);
+ BindString(
+ configuration,
+ nameof(ServerConnectionOptions.ApplicationName),
+ value => options.ApplicationName = value);
+ }
+
+ private static void BindString(
+ IConfiguration configuration,
+ string key,
+ Action assign)
+ {
+ string? value = configuration[key];
+ if (value is not null)
+ {
+ assign(value);
+ }
+ }
+
+ private static void BindEnum(
+ IConfiguration configuration,
+ string key,
+ Action assign)
+ where TEnum : struct, Enum
+ {
+ string? value = configuration[key];
+ if (value is null)
+ {
+ return;
+ }
+
+ if (!Enum.TryParse(value, ignoreCase: true, out TEnum parsed))
+ {
+ throw new InvalidOperationException(
+ $"Configuration value '{value}' for '{key}' is not a valid {typeof(TEnum).Name}.");
+ }
+
+ assign(parsed);
+ }
+
+ private static void BindUnsignedInteger(
+ IConfiguration configuration,
+ string key,
+ Action assign)
+ {
+ string? value = configuration[key];
+ if (value is null)
+ {
+ return;
+ }
+
+ if (!uint.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out uint parsed))
+ {
+ throw new InvalidOperationException(
+ $"Configuration value '{value}' for '{key}' is not a valid unsigned integer.");
+ }
+
+ assign(parsed);
+ }
+
+ private static void BindBoolean(
+ IConfiguration configuration,
+ string key,
+ Action assign)
+ {
+ string? value = configuration[key];
+ if (value is null)
+ {
+ return;
+ }
+
+ if (!bool.TryParse(value, out bool parsed))
+ {
+ throw new InvalidOperationException(
+ $"Configuration value '{value}' for '{key}' is not a valid boolean.");
+ }
+
+ assign(parsed);
+ }
+
+ private static IServerSession CreateSession(
+ IServiceProvider sp,
+ ServerConnectionOptions connection,
+ ITelemetryContext telemetry)
+ {
+ IServerSessionFactory factory =
+ sp.GetRequiredService();
+ return factory.Create(connection, telemetry);
+ }
+
+ private static List EnumeratePublishedDataSets(
+ PubSubConfigurationDataType configuration)
+ {
+ var dataSets = new List();
+ if (configuration.PublishedDataSets.IsNull)
+ {
+ return dataSets;
+ }
+ foreach (PublishedDataSetDataType dataSet in configuration.PublishedDataSets)
+ {
+ if (dataSet is not null)
+ {
+ dataSets.Add(dataSet);
+ }
+ }
+ return dataSets;
+ }
+
+ private static List EnumerateDataSetReaders(
+ PubSubConfigurationDataType configuration)
+ {
+ var readers = new List