diff --git a/docs/PubSub.md b/docs/PubSub.md
index d8f838acdb..a79eced732 100644
--- a/docs/PubSub.md
+++ b/docs/PubSub.md
@@ -1440,6 +1440,8 @@ What the server side adds:
`PubSubKeyPushTargetFolder`.
- One Object per `PubSubConnection`, `WriterGroup`, `ReaderGroup`,
`DataSetWriter`, `DataSetReader`, `PublishedDataSet`, and DataSet folder.
+ PublishedDataSets and DataSet folders are organized below the standard
+ `PublishSubscribe.PublishedDataSets` folder (`i=17371`).
- Per-instance `Status` / `State` and `ConfigurationVersion` Variables so a
client can observe the same runtime state the scheduler uses.
2. Method bindings ([§9.1.5](https://reference.opcfoundation.org/specs/OPC-10000-14/v1.05.06/9.1.5)):
@@ -1447,7 +1449,9 @@ What the server side adds:
`AddReaderGroup`, `AddDataSetWriter`, `AddDataSetReader`, `Remove*`,
`Enable`, and `Disable`, plus `AddPublishedDataItems`,
`AddPublishedEvents`, `AddPublishedDataItemsTemplate`, `AddVariables`,
- `RemoveVariables`, `AddDataSetFolder`, and `RemoveDataSetFolder`.
+ `RemoveVariables`, `AddDataSetFolder`, and `RemoveDataSetFolder`. The
+ folder methods are bound on the `PublishSubscribe.PublishedDataSets`
+ instance and on every DataSet folder created through `AddDataSetFolder`.
3. `PubSubConfigurationType` File import/export: clients can open/read the
current `PubSubConfigurationDataType` file or write a replacement file; the
server applies it through `ReplaceConfigurationAsync` and returns per-item
diff --git a/src/Opc.Ua.PubSub.Server/PubSubMethodHandlers.cs b/src/Opc.Ua.PubSub.Server/PubSubMethodHandlers.cs
index 431d58261a..d208a34464 100644
--- a/src/Opc.Ua.PubSub.Server/PubSubMethodHandlers.cs
+++ b/src/Opc.Ua.PubSub.Server/PubSubMethodHandlers.cs
@@ -1,2132 +1,2144 @@
-/* ========================================================================
- * 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.Linq;
-using System.Threading.Tasks;
-using Microsoft.Extensions.Logging;
-using Opc.Ua.PubSub.Application;
-using Opc.Ua.PubSub.Configuration;
-using Opc.Ua.PubSub.Security;
-using Opc.Ua.PubSub.Security.Sks;
-
-namespace Opc.Ua.PubSub.Server
-{
- ///
- /// Hosts the synchronous method-handler delegates the
- /// attaches to the standard
- /// PublishSubscribe Method nodes (Part 14 §9.1.3,
- /// §9.1.10 and §8.3.1).
- ///
- ///
- /// Implements the configuration-mutation entry-points
- /// via the mutable surface.
- /// All entry-points adhere to the legacy synchronous
- /// GenericMethodCalledEventHandler contract; every async
- /// call is forwarded via .AsTask().GetAwaiter().GetResult()
- /// — the single sanctioned sync-over-async bridge.
- ///
- internal sealed class PubSubMethodHandlers
- {
- private const string DefaultSecurityPolicyUri =
- "http://opcfoundation.org/UA/SecurityPolicy#PubSub-Aes256-CTR";
-
- private readonly IPubSubApplication m_application;
- private readonly IPubSubKeyServiceServer? m_keyService;
- private readonly PubSubServerOptions m_options;
- private readonly SksMethodHandler? m_sks;
- private readonly PushSecurityKeyProvider[] m_pushProviders;
- private readonly ILogger m_logger;
- private readonly Dictionary m_securityGroupNodeIds = [];
- private readonly System.Threading.Lock m_gate = new();
- private ushort m_securityGroupNamespaceIndex;
-
- ///
- /// Creates a new .
- ///
- /// Runtime application.
- ///
- /// SKS server, or when the host is
- /// not acting as an SKS.
- ///
- /// PubSub server options.
- /// Telemetry context.
- /// Optional SetSecurityKeys push providers.
- public PubSubMethodHandlers(
- IPubSubApplication application,
- IPubSubKeyServiceServer? keyService,
- PubSubServerOptions options,
- ITelemetryContext telemetry,
- IEnumerable? pushProviders = null)
- {
- if (application is null)
- {
- throw new ArgumentNullException(nameof(application));
- }
- if (options is null)
- {
- throw new ArgumentNullException(nameof(options));
- }
- if (telemetry is null)
- {
- throw new ArgumentNullException(nameof(telemetry));
- }
- m_application = application;
- m_keyService = keyService;
- m_options = options;
- m_sks = keyService is null ? null : new SksMethodHandler(keyService, telemetry);
- m_pushProviders = pushProviders?.ToArray() ?? [];
- m_logger = telemetry.CreateLogger();
- }
-
- ///
- /// Sets the namespace index used for SecurityGroup instance NodeIds.
- ///
- /// PubSub node-manager namespace index.
- public void SetSecurityGroupNamespaceIndex(ushort namespaceIndex)
- {
- lock (m_gate)
- {
- m_securityGroupNamespaceIndex = namespaceIndex;
- }
- }
-
- ///
- /// Registers a materialized SecurityGroup node.
- ///
- /// SecurityGroup identifier.
- /// Routable SecurityGroup node id.
- ///
- public void RegisterSecurityGroupNodeId(string securityGroupId, NodeId nodeId)
- {
- if (string.IsNullOrEmpty(securityGroupId))
- {
- throw new ArgumentException("SecurityGroupId must be non-empty.", nameof(securityGroupId));
- }
-
- lock (m_gate)
- {
- m_securityGroupNodeIds[nodeId] = securityGroupId;
- }
- }
-
- ///
- /// Implements Part 14 §9.1.10.2 Status.Enable.
- ///
- /// System context.
- /// Calling method node.
- /// Input arguments (none).
- /// Output arguments (none).
- public ServiceResult OnEnable(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- _ = inputArguments;
- _ = outputArguments;
- try
- {
- m_application.StartAsync().AsTask().GetAwaiter().GetResult();
- return ServiceResult.Good;
- }
- catch (Exception ex)
- {
- m_logger.PublishSubscribeEnableFailed(ex);
- return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message));
- }
- }
-
- ///
- /// Implements Part 14 §9.1.10.3 Status.Disable.
- ///
- /// System context.
- /// Calling method node.
- /// Input arguments (none).
- /// Output arguments (none).
- public ServiceResult OnDisable(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- _ = inputArguments;
- _ = outputArguments;
- try
- {
- m_application.StopAsync().AsTask().GetAwaiter().GetResult();
- return ServiceResult.Good;
- }
- catch (Exception ex)
- {
- m_logger.PublishSubscribeDisableFailed(ex);
- return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message));
- }
- }
-
- ///
- /// Implements Part 14 §9.1.3.4 AddConnection.
- /// Delegates to
- /// .
- ///
- public ServiceResult OnAddConnection(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 1)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddConnection expects 1 input argument."));
- }
- if (!inputArguments[0].TryGetValue(out ExtensionObject ext))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddConnection argument 0 is not an ExtensionObject."));
- }
- if (!ext.TryGetValue(out PubSubConnectionDataType? cfg) || cfg is null)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddConnection argument 0 body is not a PubSubConnectionDataType."));
- }
- try
- {
- NodeId id = m_application.AddConnectionAsync(cfg)
- .AsTask().GetAwaiter().GetResult();
- outputArguments.Add(Variant.From(id));
- return ServiceResult.Good;
- }
- catch (PubSubConfigurationException vex)
- {
- return new ServiceResult(
- StatusCodes.BadConfigurationError,
- new LocalizedText(vex.Message));
- }
- catch (Exception ex)
- {
- m_logger.AddConnectionFailed(ex);
- return new ServiceResult(
- StatusCodes.BadInvalidState,
- new LocalizedText(ex.Message));
- }
- }
-
- ///
- /// Implements Part 14 §9.1.3.5 RemoveConnection.
- /// Delegates to
- /// .
- ///
- public ServiceResult OnRemoveConnection(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- _ = outputArguments;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 1)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("RemoveConnection expects 1 input argument."));
- }
- if (!inputArguments[0].TryGetValue(out NodeId connectionId) ||
- connectionId.IsNull)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("RemoveConnection argument 0 is not a valid NodeId."));
- }
- try
- {
- m_application.RemoveConnectionAsync(connectionId)
- .AsTask().GetAwaiter().GetResult();
- return ServiceResult.Good;
- }
- catch (PubSubConfigurationException vex)
- {
- return new ServiceResult(
- StatusCodes.BadConfigurationError,
- new LocalizedText(vex.Message));
- }
- catch (ArgumentException aex)
- {
- return new ServiceResult(
- StatusCodes.BadNodeIdUnknown,
- new LocalizedText(aex.Message));
- }
- catch (Exception ex)
- {
- m_logger.RemoveConnectionFailed(ex);
- return new ServiceResult(
- StatusCodes.BadInvalidState,
- new LocalizedText(ex.Message));
- }
- }
-
- ///
- /// Implements Part 14 §9.1.6 SetConfiguration.
- ///
- public ServiceResult OnSetConfiguration(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 1)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("SetConfiguration expects 1 input argument."));
- }
- if (!inputArguments[0].TryGetValue(out ExtensionObject ext))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("SetConfiguration argument 0 is not an ExtensionObject."));
- }
- if (!ext.TryGetValue(out PubSubConfigurationDataType? cfg) || cfg is null)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "SetConfiguration argument 0 body is not a PubSubConfigurationDataType."));
- }
- try
- {
- ArrayOf results = m_application
- .ReplaceConfigurationAsync(cfg)
- .AsTask().GetAwaiter().GetResult();
- outputArguments.Add(Variant.From([.. results]));
- return ServiceResult.Good;
- }
- catch (PubSubConfigurationException vex)
- {
- return new ServiceResult(
- StatusCodes.BadConfigurationError,
- new LocalizedText(vex.Message));
- }
- catch (Exception ex)
- {
- m_logger.SetConfigurationFailed(ex);
- return new ServiceResult(
- StatusCodes.BadInvalidState,
- new LocalizedText(ex.Message));
- }
- }
-
- ///
- /// Implements Part 14 §9.1.6 GetConfiguration.
- ///
- public ServiceResult OnGetConfiguration(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- _ = inputArguments;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- try
- {
- PubSubConfigurationDataType config = m_application.GetConfiguration();
- outputArguments.Add(Variant.From(new ExtensionObject(config)));
- return ServiceResult.Good;
- }
- catch (Exception ex)
- {
- m_logger.GetConfigurationFailed(ex);
- return new ServiceResult(
- StatusCodes.BadInvalidState,
- new LocalizedText(ex.Message));
- }
- }
-
- ///
- /// Implements Part 14 §9.1.4.5 AddPublishedDataItems.
- ///
- public ServiceResult OnAddPublishedDataItems(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 4)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddPublishedDataItems expects 4 input arguments."));
- }
- if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddPublishedDataItems argument 0 (Name) is missing or empty."));
- }
- string[] aliases = TryGetStringArray(inputArguments[1]);
- if (!TryGetEncodeableArray(inputArguments[3], context, out PublishedVariableDataType[] variables))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddPublishedDataItems argument 3 is not a PublishedVariableDataType array."));
- }
- PublishedDataSetDataType dataSet = CreatePublishedDataItemsDataSet(name, aliases, variables, null);
- return AddPublishedDataSet(dataSet, variables.Length, outputArguments, includeConfigurationVersion: true);
- }
-
- ///
- /// Implements Part 14 §9.1.4.5 AddPublishedEvents.
- ///
- public ServiceResult OnAddPublishedEvents(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 6)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddPublishedEvents expects 6 input arguments."));
- }
- if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddPublishedEvents argument 0 (Name) is missing or empty."));
- }
- if (!inputArguments[1].TryGetValue(out NodeId eventNotifier) || eventNotifier.IsNull)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddPublishedEvents argument 1 is not a valid NodeId."));
- }
- string[] aliases = TryGetStringArray(inputArguments[2]);
- if (!TryGetEncodeableArray(inputArguments[4], context, out SimpleAttributeOperand[] selectedFields))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddPublishedEvents argument 4 is not a SimpleAttributeOperand array."));
- }
- ContentFilter filter = inputArguments[5].TryGetValue(out ExtensionObject filterObject) &&
- filterObject.TryGetValue(out ContentFilter? decodedFilter) &&
- decodedFilter is not null
- ? decodedFilter
- : new ContentFilter();
- PublishedDataSetDataType dataSet = CreatePublishedEventsDataSet(
- name,
- eventNotifier,
- aliases,
- selectedFields,
- filter,
- null);
- return AddPublishedDataSet(dataSet, selectedFields.Length, outputArguments, includeConfigurationVersion: true);
- }
-
- ///
- /// Implements Part 14 §9.1.4.5 AddPublishedDataItemsTemplate.
- ///
- public ServiceResult OnAddPublishedDataItemsTemplate(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 3)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddPublishedDataItemsTemplate expects 3 input arguments."));
- }
- if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddPublishedDataItemsTemplate argument 0 (Name) is missing or empty."));
- }
- if (!inputArguments[1].TryGetValue(out ExtensionObject metaDataObject) ||
- !metaDataObject.TryGetValue(out DataSetMetaDataType? metaData) ||
- metaData is null)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddPublishedDataItemsTemplate argument 1 is not DataSetMetaDataType."));
- }
- if (!TryGetEncodeableArray(inputArguments[2], context, out PublishedVariableDataType[] variables))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddPublishedDataItemsTemplate argument 2 is not a PublishedVariableDataType array."));
- }
- PublishedDataSetDataType dataSet = CreatePublishedDataItemsDataSet(
- name,
- [],
- variables,
- metaData);
- return AddPublishedDataSet(dataSet, variables.Length, outputArguments, includeConfigurationVersion: false);
- }
-
- ///
- /// Implements Part 14 §9.1.6 RemovePublishedDataSet.
- ///
- public ServiceResult OnRemovePublishedDataSet(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- _ = outputArguments;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 1)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("RemovePublishedDataSet expects 1 input argument."));
- }
- if (!inputArguments[0].TryGetValue(out NodeId dataSetId) ||
- dataSetId.IsNull)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "RemovePublishedDataSet argument 0 is not a valid NodeId."));
- }
- try
- {
- m_application.RemovePublishedDataSetAsync(dataSetId)
- .AsTask().GetAwaiter().GetResult();
- return ServiceResult.Good;
- }
- catch (ArgumentException aex)
- {
- return new ServiceResult(
- StatusCodes.BadNodeIdUnknown,
- new LocalizedText(aex.Message));
- }
- catch (PubSubConfigurationException vex)
- {
- return new ServiceResult(
- StatusCodes.BadConfigurationError,
- new LocalizedText(vex.Message));
- }
- catch (Exception ex)
- {
- m_logger.RemovePublishedDataSetFailed(ex);
- return new ServiceResult(
- StatusCodes.BadInvalidState,
- new LocalizedText(ex.Message));
- }
- }
-
- ///
- /// Implements Part 14 §9.1.5 AddDataSetFolder.
- /// The server NodeManager materializes the returned folder NodeId
- /// because folders are address-space objects, not configuration
- /// records.
- ///
- public ServiceResult OnAddDataSetFolder(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 1)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddDataSetFolder expects 1 input argument."));
- }
- if (!inputArguments[0].TryGetValue(out string folderName) ||
- string.IsNullOrEmpty(folderName))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "AddDataSetFolder argument 0 (FolderName) is missing or empty."));
- }
- outputArguments.Add(Variant.From(
- new NodeId($"pubsub:folder:{folderName}", 0)));
- return ServiceResult.Good;
- }
-
- ///
- /// Implements Part 14 §9.1.5 RemoveDataSetFolder.
- /// The server NodeManager owns address-space removal for folder nodes.
- ///
- public ServiceResult OnRemoveDataSetFolder(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- _ = outputArguments;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 1)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("RemoveDataSetFolder expects 1 input argument."));
- }
- return ServiceResult.Good;
- }
-
- ///
- /// Implements Part 14 §9.1.6 AddWriterGroup.
- ///
- public ServiceResult OnAddWriterGroup(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 2)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddWriterGroup expects 2 input arguments."));
- }
- if (!inputArguments[0].TryGetValue(out NodeId connectionId) ||
- connectionId.IsNull)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "AddWriterGroup argument 0 (ConnectionId) is not a valid NodeId."));
- }
- if (!inputArguments[1].TryGetValue(out ExtensionObject ext))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "AddWriterGroup argument 1 is not an ExtensionObject."));
- }
- if (!ext.TryGetValue(out WriterGroupDataType? cfg) || cfg is null)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "AddWriterGroup argument 1 body is not a WriterGroupDataType."));
- }
- try
- {
- NodeId id = m_application.AddWriterGroupAsync(connectionId, cfg)
- .AsTask().GetAwaiter().GetResult();
- outputArguments.Add(Variant.From(id));
- return ServiceResult.Good;
- }
- catch (ArgumentException aex)
- {
- return new ServiceResult(
- StatusCodes.BadNodeIdUnknown,
- new LocalizedText(aex.Message));
- }
- catch (PubSubConfigurationException vex)
- {
- return new ServiceResult(
- StatusCodes.BadConfigurationError,
- new LocalizedText(vex.Message));
- }
- catch (Exception ex)
- {
- m_logger.AddWriterGroupFailed(ex);
- return new ServiceResult(
- StatusCodes.BadInvalidState,
- new LocalizedText(ex.Message));
- }
- }
-
- ///
- /// Implements Part 14 §9.1.6 AddReaderGroup.
- ///
- public ServiceResult OnAddReaderGroup(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 2)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddReaderGroup expects 2 input arguments."));
- }
- if (!inputArguments[0].TryGetValue(out NodeId connectionId) ||
- connectionId.IsNull)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "AddReaderGroup argument 0 (ConnectionId) is not a valid NodeId."));
- }
- if (!inputArguments[1].TryGetValue(out ExtensionObject ext))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "AddReaderGroup argument 1 is not an ExtensionObject."));
- }
- if (!ext.TryGetValue(out ReaderGroupDataType? cfg) || cfg is null)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "AddReaderGroup argument 1 body is not a ReaderGroupDataType."));
- }
- try
- {
- NodeId id = m_application.AddReaderGroupAsync(connectionId, cfg)
- .AsTask().GetAwaiter().GetResult();
- outputArguments.Add(Variant.From(id));
- return ServiceResult.Good;
- }
- catch (ArgumentException aex)
- {
- return new ServiceResult(
- StatusCodes.BadNodeIdUnknown,
- new LocalizedText(aex.Message));
- }
- catch (PubSubConfigurationException vex)
- {
- return new ServiceResult(
- StatusCodes.BadConfigurationError,
- new LocalizedText(vex.Message));
- }
- catch (Exception ex)
- {
- m_logger.AddReaderGroupFailed(ex);
- return new ServiceResult(
- StatusCodes.BadInvalidState,
- new LocalizedText(ex.Message));
- }
- }
-
- ///
- /// Implements Part 14 §9.1.6 RemoveGroup.
- ///
- public ServiceResult OnRemoveGroup(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- _ = outputArguments;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 1)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("RemoveGroup expects 1 input argument."));
- }
- if (!inputArguments[0].TryGetValue(out NodeId groupId) ||
- groupId.IsNull)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "RemoveGroup argument 0 is not a valid NodeId."));
- }
- try
- {
- m_application.RemoveGroupAsync(groupId)
- .AsTask().GetAwaiter().GetResult();
- return ServiceResult.Good;
- }
- catch (ArgumentException aex)
- {
- return new ServiceResult(
- StatusCodes.BadNodeIdUnknown,
- new LocalizedText(aex.Message));
- }
- catch (PubSubConfigurationException vex)
- {
- return new ServiceResult(
- StatusCodes.BadConfigurationError,
- new LocalizedText(vex.Message));
- }
- catch (Exception ex)
- {
- m_logger.RemoveGroupFailed(ex);
- return new ServiceResult(
- StatusCodes.BadInvalidState,
- new LocalizedText(ex.Message));
- }
- }
-
- ///
- /// Implements Part 14 §9.1.7 AddDataSetWriter.
- ///
- public ServiceResult OnAddDataSetWriter(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 2)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddDataSetWriter expects 2 input arguments."));
- }
- if (!inputArguments[0].TryGetValue(out NodeId writerGroupId) ||
- writerGroupId.IsNull)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "AddDataSetWriter argument 0 (WriterGroupId) is not a valid NodeId."));
- }
- if (!inputArguments[1].TryGetValue(out ExtensionObject ext))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "AddDataSetWriter argument 1 is not an ExtensionObject."));
- }
- if (!ext.TryGetValue(out DataSetWriterDataType? cfg) || cfg is null)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "AddDataSetWriter argument 1 body is not a DataSetWriterDataType."));
- }
- try
- {
- NodeId id = m_application.AddDataSetWriterAsync(writerGroupId, cfg)
- .AsTask().GetAwaiter().GetResult();
- outputArguments.Add(Variant.From(id));
- return ServiceResult.Good;
- }
- catch (ArgumentException aex)
- {
- return new ServiceResult(
- StatusCodes.BadNodeIdUnknown,
- new LocalizedText(aex.Message));
- }
- catch (PubSubConfigurationException vex)
- {
- return new ServiceResult(
- StatusCodes.BadConfigurationError,
- new LocalizedText(vex.Message));
- }
- catch (Exception ex)
- {
- m_logger.AddDataSetWriterFailed(ex);
- return new ServiceResult(
- StatusCodes.BadInvalidState,
- new LocalizedText(ex.Message));
- }
- }
-
- ///
- /// Implements Part 14 §9.1.7 RemoveDataSetWriter.
- ///
- public ServiceResult OnRemoveDataSetWriter(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- _ = outputArguments;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 1)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("RemoveDataSetWriter expects 1 input argument."));
- }
- if (!inputArguments[0].TryGetValue(out NodeId writerId) ||
- writerId.IsNull)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "RemoveDataSetWriter argument 0 is not a valid NodeId."));
- }
- try
- {
- m_application.RemoveDataSetWriterAsync(writerId)
- .AsTask().GetAwaiter().GetResult();
- return ServiceResult.Good;
- }
- catch (ArgumentException aex)
- {
- return new ServiceResult(
- StatusCodes.BadNodeIdUnknown,
- new LocalizedText(aex.Message));
- }
- catch (PubSubConfigurationException vex)
- {
- return new ServiceResult(
- StatusCodes.BadConfigurationError,
- new LocalizedText(vex.Message));
- }
- catch (Exception ex)
- {
- m_logger.RemoveDataSetWriterFailed(ex);
- return new ServiceResult(
- StatusCodes.BadInvalidState,
- new LocalizedText(ex.Message));
- }
- }
-
- ///
- /// Implements Part 14 §9.1.8 AddDataSetReader.
- ///
- public ServiceResult OnAddDataSetReader(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 2)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddDataSetReader expects 2 input arguments."));
- }
- if (!inputArguments[0].TryGetValue(out NodeId readerGroupId) ||
- readerGroupId.IsNull)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "AddDataSetReader argument 0 (ReaderGroupId) is not a valid NodeId."));
- }
- if (!inputArguments[1].TryGetValue(out ExtensionObject ext))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "AddDataSetReader argument 1 is not an ExtensionObject."));
- }
- if (!ext.TryGetValue(out DataSetReaderDataType? cfg) || cfg is null)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "AddDataSetReader argument 1 body is not a DataSetReaderDataType."));
- }
- try
- {
- NodeId id = m_application.AddDataSetReaderAsync(readerGroupId, cfg)
- .AsTask().GetAwaiter().GetResult();
- outputArguments.Add(Variant.From(id));
- return ServiceResult.Good;
- }
- catch (ArgumentException aex)
- {
- return new ServiceResult(
- StatusCodes.BadNodeIdUnknown,
- new LocalizedText(aex.Message));
- }
- catch (PubSubConfigurationException vex)
- {
- return new ServiceResult(
- StatusCodes.BadConfigurationError,
- new LocalizedText(vex.Message));
- }
- catch (Exception ex)
- {
- m_logger.AddDataSetReaderFailed(ex);
- return new ServiceResult(
- StatusCodes.BadInvalidState,
- new LocalizedText(ex.Message));
- }
- }
-
- ///
- /// Implements Part 14 §9.1.8 RemoveDataSetReader.
- ///
- public ServiceResult OnRemoveDataSetReader(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- _ = outputArguments;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 1)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("RemoveDataSetReader expects 1 input argument."));
- }
- if (!inputArguments[0].TryGetValue(out NodeId readerId) ||
- readerId.IsNull)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText(
- "RemoveDataSetReader argument 0 is not a valid NodeId."));
- }
- try
- {
- m_application.RemoveDataSetReaderAsync(readerId)
- .AsTask().GetAwaiter().GetResult();
- return ServiceResult.Good;
- }
- catch (ArgumentException aex)
- {
- return new ServiceResult(
- StatusCodes.BadNodeIdUnknown,
- new LocalizedText(aex.Message));
- }
- catch (PubSubConfigurationException vex)
- {
- return new ServiceResult(
- StatusCodes.BadConfigurationError,
- new LocalizedText(vex.Message));
- }
- catch (Exception ex)
- {
- m_logger.RemoveDataSetReaderFailed(ex);
- return new ServiceResult(
- StatusCodes.BadInvalidState,
- new LocalizedText(ex.Message));
- }
- }
-
- ///
- /// Implements Part 14 §9.1.4.3 AddVariables.
- ///
- public ServiceResult OnAddVariables(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (!TryGetPublishedDataSetName(method, out string dataSetName))
- {
- return new ServiceResult(StatusCodes.BadNodeIdUnknown);
- }
- if (inputArguments.Count < 4)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddVariables expects 4 input arguments."));
- }
- string[] aliases = TryGetStringArray(inputArguments[1]);
- if (!TryGetEncodeableArray(inputArguments[3], context, out PublishedVariableDataType[] variables))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddVariables argument 3 is not a PublishedVariableDataType array."));
- }
-
- return MutatePublishedDataItems(
- dataSetName,
- (dataSet, items) =>
- {
- List published = ClonePublishedVariables(items);
- published.AddRange(variables);
- items.PublishedData = [.. published];
- AppendMetaDataFields(dataSet, aliases, variables.Length);
- return variables.Length;
- },
- outputArguments);
- }
-
- ///
- /// Implements Part 14 §9.1.4.3 RemoveVariables.
- ///
- public ServiceResult OnRemoveVariables(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (!TryGetPublishedDataSetName(method, out string dataSetName))
- {
- return new ServiceResult(StatusCodes.BadNodeIdUnknown);
- }
- if (inputArguments.Count < 2)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("RemoveVariables expects 2 input arguments."));
- }
- if (!TryGetUInt32Array(inputArguments[1], out uint[] variablesToRemove))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("RemoveVariables argument 1 is not a UInt32 array."));
- }
-
- return MutatePublishedDataItems(
- dataSetName,
- (dataSet, items) =>
- {
- List published = ClonePublishedVariables(items);
- List fields = CloneMetaDataFields(dataSet.DataSetMetaData);
- Array.Sort(variablesToRemove);
- int removed = 0;
- for (int i = variablesToRemove.Length - 1; i >= 0; i--)
- {
- int index = checked((int)variablesToRemove[i]);
- if (index < 0 || index >= published.Count)
- {
- continue;
- }
- published.RemoveAt(index);
- if (index < fields.Count)
- {
- fields.RemoveAt(index);
- }
- removed++;
- }
- items.PublishedData = [.. published];
- dataSet.DataSetMetaData ??= new DataSetMetaDataType();
- dataSet.DataSetMetaData.Fields = [.. fields];
- return removed;
- },
- outputArguments);
- }
-
- private ServiceResult AddPublishedDataSet(
- PublishedDataSetDataType dataSet,
- int resultCount,
- List outputArguments,
- bool includeConfigurationVersion)
- {
- try
- {
- NodeId dataSetId = m_application.AddPublishedDataSetAsync(dataSet)
- .AsTask().GetAwaiter().GetResult();
- PublishedDataSetDataType? added = FindPublishedDataSet(dataSet.Name ?? string.Empty);
- outputArguments.Add(Variant.From(dataSetId));
- if (includeConfigurationVersion)
- {
- outputArguments.Add(Variant.From(new ExtensionObject(
- added?.DataSetMetaData?.ConfigurationVersion ?? new ConfigurationVersionDataType())));
- }
- outputArguments.Add(Variant.From(CreateGoodResults(resultCount)));
- return ServiceResult.Good;
- }
- catch (PubSubConfigurationException vex)
- {
- return new ServiceResult(StatusCodes.BadConfigurationError, new LocalizedText(vex.Message));
- }
- catch (Exception ex)
- {
- m_logger.AddPublishedDataSetFailed(ex);
- return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message));
- }
- }
-
- private ServiceResult MutatePublishedDataItems(
- string dataSetName,
- Func mutator,
- List outputArguments)
- {
- try
- {
- PubSubConfigurationDataType configuration = m_application.GetConfiguration();
- var clone = (PubSubConfigurationDataType)configuration.Clone();
- if (clone.PublishedDataSets.IsNull)
- {
- return new ServiceResult(StatusCodes.BadNodeIdUnknown);
- }
- int index = FindIndexByName(clone.PublishedDataSets, dataSetName);
- if (index < 0)
- {
- return new ServiceResult(StatusCodes.BadNodeIdUnknown);
- }
- PublishedDataSetDataType dataSet = clone.PublishedDataSets[index];
- if (dataSet.DataSetSource.IsNull ||
- !dataSet.DataSetSource.TryGetValue(out PublishedDataItemsDataType? items) ||
- items is null)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidState,
- new LocalizedText("The PublishedDataSet is not a PublishedDataItemsType instance."));
- }
-
- int resultCount = mutator(dataSet, items);
- dataSet.DataSetSource = new ExtensionObject(items);
- _ = m_application.ReplaceConfigurationAsync(clone)
- .AsTask().GetAwaiter().GetResult();
- PublishedDataSetDataType? updated = FindPublishedDataSet(dataSetName);
- outputArguments.Add(Variant.From(new ExtensionObject(
- updated?.DataSetMetaData?.ConfigurationVersion ?? new ConfigurationVersionDataType())));
- outputArguments.Add(Variant.From(CreateGoodResults(resultCount)));
- return ServiceResult.Good;
- }
- catch (Exception ex)
- {
- m_logger.PublishedDataItemsMutationFailed(ex);
- return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message));
- }
- }
-
- private static PublishedDataSetDataType CreatePublishedDataItemsDataSet(
- string name,
- string[] aliases,
- PublishedVariableDataType[] variables,
- DataSetMetaDataType? templateMetaData)
- {
- DataSetMetaDataType metaData = templateMetaData is null
- ? CreateMetaData(name, aliases, variables.Length)
- : (DataSetMetaDataType)templateMetaData.Clone();
- if (templateMetaData is not null)
- {
- metaData.Fields = [.. CloneMetaDataFields(templateMetaData)];
- }
- if (metaData.Fields.IsNull || metaData.Fields.Count == 0)
- {
- metaData.Fields = CreateFields(aliases, variables.Length);
- }
- return new PublishedDataSetDataType
- {
- Name = name,
- DataSetMetaData = metaData,
- DataSetSource = new ExtensionObject(new PublishedDataItemsDataType
- {
- PublishedData = [.. variables]
- })
- };
- }
-
- private static PublishedDataSetDataType CreatePublishedEventsDataSet(
- string name,
- NodeId eventNotifier,
- string[] aliases,
- SimpleAttributeOperand[] selectedFields,
- ContentFilter filter,
- DataSetMetaDataType? templateMetaData)
- {
- DataSetMetaDataType metaData = templateMetaData is null
- ? CreateMetaData(name, aliases, selectedFields.Length)
- : (DataSetMetaDataType)templateMetaData.Clone();
- if (templateMetaData is not null)
- {
- metaData.Fields = [.. CloneMetaDataFields(templateMetaData)];
- }
- if (metaData.Fields.IsNull || metaData.Fields.Count == 0)
- {
- metaData.Fields = CreateFields(aliases, selectedFields.Length);
- }
- return new PublishedDataSetDataType
- {
- Name = name,
- DataSetMetaData = metaData,
- DataSetSource = new ExtensionObject(new PublishedEventsDataType
- {
- EventNotifier = eventNotifier,
- SelectedFields = [.. selectedFields],
- Filter = filter
- })
- };
- }
-
- private static DataSetMetaDataType CreateMetaData(
- string name,
- string[] aliases,
- int fieldCount)
- {
- return new DataSetMetaDataType
- {
- Name = name,
- Fields = CreateFields(aliases, fieldCount)
- };
- }
-
- private static ArrayOf CreateFields(string[] aliases, int fieldCount)
- {
- var fields = new FieldMetaData[fieldCount];
- for (int i = 0; i < fields.Length; i++)
- {
- string fieldName = i < aliases.Length && !string.IsNullOrEmpty(aliases[i])
- ? aliases[i]
- : $"Field{i + 1}";
- fields[i] = new FieldMetaData
- {
- Name = fieldName,
- DataType = DataTypeIds.BaseDataType,
- ValueRank = ValueRanks.Scalar,
- Properties = []
- };
- }
- return [.. fields];
- }
-
- private static void AppendMetaDataFields(
- PublishedDataSetDataType dataSet,
- string[] aliases,
- int fieldCount)
- {
- dataSet.DataSetMetaData ??= new DataSetMetaDataType();
- List fields = CloneMetaDataFields(dataSet.DataSetMetaData);
- ArrayOf newFields = CreateFields(aliases, fieldCount);
- for (int i = 0; i < newFields.Count; i++)
- {
- fields.Add(newFields[i]);
- }
- dataSet.DataSetMetaData.Fields = [.. fields];
- }
-
- private PublishedDataSetDataType? FindPublishedDataSet(string name)
- {
- PubSubConfigurationDataType configuration = m_application.GetConfiguration();
- if (configuration.PublishedDataSets.IsNull)
- {
- return null;
- }
- int index = FindIndexByName(configuration.PublishedDataSets, name);
- return index < 0 ? null : configuration.PublishedDataSets[index];
- }
-
- private static int FindIndexByName(
- ArrayOf dataSets,
- string name)
- {
- for (int i = 0; i < dataSets.Count; i++)
- {
- if (StringComparer.Ordinal.Equals(dataSets[i].Name, name))
- {
- return i;
- }
- }
- return -1;
- }
-
- private static List ClonePublishedVariables(
- PublishedDataItemsDataType items)
- {
- var published = new List();
- if (items.PublishedData.IsNull)
- {
- return published;
- }
- foreach (PublishedVariableDataType item in items.PublishedData)
- {
- published.Add((PublishedVariableDataType)item.Clone());
- }
- return published;
- }
-
- private static List CloneMetaDataFields(DataSetMetaDataType? metaData)
- {
- var fields = new List();
- if (metaData is null || metaData.Fields.IsNull)
- {
- return fields;
- }
- foreach (FieldMetaData field in metaData.Fields)
- {
- fields.Add((FieldMetaData)field.Clone());
- }
- return fields;
- }
-
- private static StatusCode[] CreateGoodResults(int count)
- {
- var results = new StatusCode[count];
- for (int i = 0; i < results.Length; i++)
- {
- results[i] = StatusCodes.Good;
- }
- return results;
- }
-
- private static string[] TryGetStringArray(Variant value)
- {
- if (!value.TryGetValue(out ArrayOf values) || values.IsNull)
- {
- return [];
- }
- string[] result = new string[values.Count];
- for (int i = 0; i < values.Count; i++)
- {
- result[i] = values[i];
- }
- return result;
- }
-
- private static bool TryGetUInt32Array(Variant value, out uint[] result)
- {
- result = [];
- if (!value.TryGetValue(out ArrayOf values) || values.IsNull)
- {
- return false;
- }
- result = new uint[values.Count];
- for (int i = 0; i < values.Count; i++)
- {
- result[i] = values[i];
- }
- return true;
- }
-
- private static bool TryGetEncodeableArray(
- Variant value,
- ISystemContext context,
- out T[] result)
- where T : class, IEncodeable
- {
- result = [];
- IServiceMessageContext? messageContext = context as IServiceMessageContext
- ?? AmbientMessageContext.CurrentContext;
- if (!value.TryGetValue(out ArrayOf values, messageContext) || values.IsNull)
- {
- return false;
- }
- result = new T[values.Count];
- for (int i = 0; i < values.Count; i++)
- {
- result[i] = values[i];
- }
- return true;
- }
-
- private static bool TryGetPublishedDataSetName(MethodState method, out string dataSetName)
- {
- dataSetName = string.Empty;
- string nodeId;
- if (method?.Parent is BaseObjectState parent)
- {
- nodeId = parent.NodeId.IdentifierAsString;
- }
- else if (method?.NodeId is not null)
- {
- nodeId = method.NodeId.IdentifierAsString;
- }
- else
- {
- return false;
- }
- const string prefix = "pubsub:published-data-set:";
- if (!nodeId.StartsWith(prefix, StringComparison.Ordinal))
- {
- return false;
- }
- dataSetName = nodeId[prefix.Length..];
- int separator = dataSetName.IndexOf(':', StringComparison.Ordinal);
- if (separator >= 0)
- {
- dataSetName = dataSetName[..separator];
- }
- return dataSetName.Length > 0;
- }
-
- ///
- /// Implements Part 14 §8.3.4 AddSecurityGroup.
- /// Delegates to
- /// .
- ///
- /// System context.
- /// Calling method node.
- /// Input arguments.
- /// Output arguments.
- public ServiceResult OnAddSecurityGroup(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- if (m_keyService is null)
- {
- return new ServiceResult(StatusCodes.BadServiceUnsupported);
- }
- if (inputArguments.Count < 5)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText($"AddSecurityGroup expects 5 input arguments; got {inputArguments.Count}."));
- }
- if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddSecurityGroup argument 0 (SecurityGroupName) is missing or empty."));
- }
- if (!inputArguments[1].TryGetValue(out double keyLifetimeMs) || keyLifetimeMs <= 0)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddSecurityGroup argument 1 (KeyLifetime) must be a positive Duration."));
- }
- if (!inputArguments[2].TryGetValue(out string? policyUri) || string.IsNullOrEmpty(policyUri))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddSecurityGroup argument 2 (SecurityPolicyUri) is missing or empty."));
- }
- if (!inputArguments[3].TryGetValue(out uint maxFuture))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddSecurityGroup argument 3 (MaxFutureKeyCount) is not a UInt32."));
- }
- if (!inputArguments[4].TryGetValue(out uint maxPast))
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("AddSecurityGroup argument 4 (MaxPastKeyCount) is not a UInt32."));
- }
-
- var group = new SksSecurityGroup(
- securityGroupId: name,
- securityPolicyUri: policyUri,
- keyLifetime: TimeSpan.FromMilliseconds(keyLifetimeMs),
- maxFutureKeyCount: (int)Math.Min(maxFuture, int.MaxValue),
- maxPastKeyCount: (int)Math.Min(maxPast, int.MaxValue),
- keys: Array.Empty(),
- authorizedCallerIdentities: TryReadAuthorizedCallers(inputArguments, 6),
- rolePermissions: TryReadRolePermissions(inputArguments, 5));
-
- try
- {
- m_keyService
- .AddSecurityGroupAsync(group)
- .AsTask()
- .GetAwaiter()
- .GetResult();
- }
- catch (OpcUaSksException ex)
- {
- m_logger.AddSecurityGroupRejected(ex, name, ex.Status);
- return new ServiceResult(ex.Status, new LocalizedText(ex.Message));
- }
- catch (Exception ex)
- {
- m_logger.AddSecurityGroupThrewUnexpectedly(ex, name);
- return new ServiceResult(StatusCodes.BadInternalError, new LocalizedText(ex.Message));
- }
-
- NodeId groupNodeId = GetOrAllocateSecurityGroupNodeId(name);
- outputArguments.Add(Variant.From(name));
- outputArguments.Add(Variant.From(groupNodeId));
- return ServiceResult.Good;
- }
-
- ///
- /// Implements Part 14 §8.3.5 RemoveSecurityGroup.
- ///
- /// System context.
- /// Calling method node.
- /// Input arguments.
- /// Output arguments (none).
- public ServiceResult OnRemoveSecurityGroup(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- _ = outputArguments;
- if (m_keyService is null)
- {
- return new ServiceResult(StatusCodes.BadServiceUnsupported);
- }
- if (inputArguments.Count < 1)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("RemoveSecurityGroup expects 1 input argument."));
- }
- if (!inputArguments[0].TryGetValue(out NodeId groupNodeId) || groupNodeId.IsNull)
- {
- return new ServiceResult(
- StatusCodes.BadInvalidArgument,
- new LocalizedText("RemoveSecurityGroup argument 0 (SecurityGroupNodeId) is missing or not a NodeId."));
- }
- string? id = LookupSecurityGroupId(groupNodeId);
- if (id is null)
- {
- return new ServiceResult(StatusCodes.BadNodeIdUnknown);
- }
- try
- {
- m_keyService
- .RemoveSecurityGroupAsync(id)
- .AsTask()
- .GetAwaiter()
- .GetResult();
- }
- catch (OpcUaSksException ex)
- {
- m_logger.RemoveSecurityGroupRejected(ex, id, ex.Status);
- return new ServiceResult(ex.Status, new LocalizedText(ex.Message));
- }
- catch (Exception ex)
- {
- m_logger.RemoveSecurityGroupThrewUnexpectedly(ex, id);
- return new ServiceResult(StatusCodes.BadInternalError, new LocalizedText(ex.Message));
- }
- lock (m_gate)
- {
- m_securityGroupNodeIds.Remove(groupNodeId);
- }
- return ServiceResult.Good;
- }
-
- ///
- /// Implements Part 14 §8.3.2 GetSecurityKeys.
- /// Delegates to .
- ///
- /// System context.
- /// Calling method node.
- /// Object the method is called on.
- /// Input arguments.
- /// Output arguments.
- public ServiceResult OnGetSecurityKeys(
- ISystemContext context,
- MethodState method,
- NodeId objectId,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = method;
- if (m_sks is null)
- {
- return new ServiceResult(StatusCodes.BadServiceUnsupported);
- }
- return m_sks.HandleGetSecurityKeys(context, objectId, inputArguments.ToList(), outputArguments);
- }
-
- ///
- /// Implements Part 14 §8.3.3 GetSecurityGroup.
- ///
- public ServiceResult OnGetSecurityGroup(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- if (m_keyService is null)
- {
- return new ServiceResult(StatusCodes.BadServiceUnsupported);
- }
- if (inputArguments.Count < 1 ||
- !inputArguments[0].TryGetValue(out string? securityGroupId) ||
- string.IsNullOrEmpty(securityGroupId))
- {
- return new ServiceResult(StatusCodes.BadInvalidArgument);
- }
-
- SksSecurityGroup? group = m_keyService
- .GetSecurityGroupAsync(securityGroupId)
- .AsTask()
- .GetAwaiter()
- .GetResult();
- if (group is null)
- {
- return new ServiceResult(StatusCodes.BadNoMatch);
- }
-
- outputArguments.Add(Variant.From(GetOrAllocateSecurityGroupNodeId(securityGroupId)));
- return ServiceResult.Good;
- }
-
- ///
- /// Implements Part 14 §9.1.3.3 SetSecurityKeys.
- ///
- public ServiceResult OnSetSecurityKeys(
- ISystemContext context,
- MethodState method,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = method;
- _ = outputArguments;
- if (!m_options.ExposeConfigurationMethods)
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (!IsSecurityKeyPushAuthorized(context))
- {
- return new ServiceResult(StatusCodes.BadUserAccessDenied);
- }
- if (inputArguments.Count < 7)
- {
- return new ServiceResult(StatusCodes.BadInvalidArgument);
- }
- if (!inputArguments[0].TryGetValue(out string? securityGroupId) ||
- string.IsNullOrEmpty(securityGroupId) ||
- !inputArguments[1].TryGetValue(out string? policyUri) ||
- string.IsNullOrEmpty(policyUri) ||
- !inputArguments[2].TryGetValue(out uint currentTokenId) ||
- !inputArguments[3].TryGetValue(out ByteString currentKey) ||
- !inputArguments[4].TryGetValue(out ArrayOf futureKeys) ||
- !inputArguments[5].TryGetValue(out double timeToNextKeyMs) ||
- !inputArguments[6].TryGetValue(out double keyLifetimeMs))
- {
- return new ServiceResult(StatusCodes.BadInvalidArgument);
- }
-
- PushSecurityKeyProvider? provider = FindPushProvider(securityGroupId);
- if (provider is null)
- {
- return new ServiceResult(StatusCodes.BadNotFound);
- }
-
- try
- {
- provider.SetSecurityKeysAsync(
- policyUri,
- currentTokenId,
- currentKey,
- futureKeys,
- TimeSpan.FromMilliseconds(timeToNextKeyMs),
- TimeSpan.FromMilliseconds(keyLifetimeMs))
- .AsTask()
- .GetAwaiter()
- .GetResult();
- return ServiceResult.Good;
- }
- catch (OpcUaSksException ex)
- {
- return new ServiceResult(ex.Status, new LocalizedText(ex.Message));
- }
- }
-
- private static bool IsSecurityKeyPushAuthorized(ISystemContext context)
- {
- if (StringComparer.Ordinal.Equals(context.UserId, "sks"))
- {
- return true;
- }
-
- if (context is not ISessionOperationContext sessionContext)
- {
- return false;
- }
-
- ArrayOf grantedRoleIds = sessionContext.UserIdentity?.GrantedRoleIds ?? [];
- return grantedRoleIds.Contains(ObjectIds.WellKnownRole_SecurityAdmin);
- }
-
- ///
- /// Implements Part 14 §8.4.2 InvalidateKeys.
- ///
- public ServiceResult OnInvalidateKeys(
- ISystemContext context,
- MethodState method,
- NodeId objectId,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- _ = inputArguments;
- _ = outputArguments;
- return RotateOrInvalidateKeys(objectId, invalidate: true);
- }
-
- ///
- /// Implements Part 14 §8.4.3 ForceKeyRotation.
- ///
- public ServiceResult OnForceKeyRotation(
- ISystemContext context,
- MethodState method,
- NodeId objectId,
- ArrayOf inputArguments,
- List outputArguments)
- {
- _ = context;
- _ = method;
- _ = inputArguments;
- _ = outputArguments;
- return RotateOrInvalidateKeys(objectId, invalidate: false);
- }
-
- ///
- /// Returns the NodeId previously allocated for the
- /// SecurityGroup identified by ,
- /// or when the id is unknown to this
- /// handler.
- ///
- /// SecurityGroup identifier.
- public NodeId? TryGetSecurityGroupNodeId(string securityGroupId)
- {
- if (string.IsNullOrEmpty(securityGroupId))
- {
- return null;
- }
- lock (m_gate)
- {
- foreach (KeyValuePair kvp in m_securityGroupNodeIds)
- {
- if (string.Equals(kvp.Value, securityGroupId, StringComparison.Ordinal))
- {
- return kvp.Key;
- }
- }
- return null;
- }
- }
-
- ///
- /// Resolves a SecurityGroup node id to its SecurityGroupId.
- ///
- /// SecurityGroup node id.
- public string? LookupSecurityGroupIdForNode(NodeId groupNodeId)
- {
- return LookupSecurityGroupId(groupNodeId);
- }
-
- private ServiceResult RotateOrInvalidateKeys(NodeId groupNodeId, bool invalidate)
- {
- if (m_keyService is null)
- {
- return new ServiceResult(StatusCodes.BadServiceUnsupported);
- }
- string? id = LookupSecurityGroupId(groupNodeId);
- if (id is null)
- {
- return new ServiceResult(StatusCodes.BadNodeIdUnknown);
- }
- try
- {
- ValueTask task = invalidate
- ? m_keyService.InvalidateKeysAsync(id)
- : m_keyService.ForceKeyRotationAsync(id);
- task.AsTask().GetAwaiter().GetResult();
- return ServiceResult.Good;
- }
- catch (OpcUaSksException ex)
- {
- return new ServiceResult(ex.Status, new LocalizedText(ex.Message));
- }
- }
-
- private PushSecurityKeyProvider? FindPushProvider(string securityGroupId)
- {
- for (int i = 0; i < m_pushProviders.Length; i++)
- {
- if (string.Equals(m_pushProviders[i].SecurityGroupId, securityGroupId, StringComparison.Ordinal))
- {
- return m_pushProviders[i];
- }
- }
- return null;
- }
-
- private NodeId GetOrAllocateSecurityGroupNodeId(string securityGroupId)
- {
- NodeId? existing = TryGetSecurityGroupNodeId(securityGroupId);
- return existing ?? CreateSecurityGroupNodeId(securityGroupId);
- }
-
- private static ArrayOf TryReadRolePermissions(
- ArrayOf inputArguments,
- int index)
- {
- if (inputArguments.Count <= index)
- {
- return [];
- }
- if (!inputArguments[index].TryGetValue(out ArrayOf rolePermissionsArray))
- {
- return [];
- }
-
- var rolePermissions = new List(rolePermissionsArray.Count);
- for (int i = 0; i < rolePermissionsArray.Count; i++)
- {
- if (rolePermissionsArray[i].TryGetValue(out RolePermissionType? rolePermission) &&
- rolePermission is not null)
- {
- rolePermissions.Add(rolePermission);
- }
- }
- return [.. rolePermissions];
- }
-
- private static ArrayOf TryReadAuthorizedCallers(ArrayOf inputArguments, int index)
- {
- if (inputArguments.Count <= index)
- {
- return [];
- }
- return inputArguments[index].TryGetValue(out ArrayOf callers) ? callers : [];
- }
-
- private string? LookupSecurityGroupId(NodeId groupNodeId)
- {
- lock (m_gate)
- {
- if (m_securityGroupNodeIds.TryGetValue(groupNodeId, out string? id))
- {
- return id;
- }
- }
- if (groupNodeId.IdType == IdType.String &&
- groupNodeId.TryGetValue(out string identifier) &&
- !string.IsNullOrEmpty(identifier))
- {
- const string prefix = "pubsub:security-group:";
- return identifier.StartsWith(prefix, StringComparison.Ordinal)
- ? identifier[prefix.Length..]
- : identifier;
- }
- return null;
- }
-
- private NodeId CreateSecurityGroupNodeId(string securityGroupId)
- {
- ushort namespaceIndex;
- lock (m_gate)
- {
- namespaceIndex = m_securityGroupNamespaceIndex;
- }
- var nodeId = new NodeId($"pubsub:security-group:{securityGroupId}", namespaceIndex);
- lock (m_gate)
- {
- m_securityGroupNodeIds[nodeId] = securityGroupId;
- }
- return nodeId;
- }
-
- ///
- /// Returns the default SecurityPolicyUri for the SKS host.
- ///
- public string DefaultPolicyUri => m_options.DefaultSecurityPolicyUri ?? DefaultSecurityPolicyUri;
- }
-
- ///
- /// Source-generated log messages for PubSubMethodHandlers.
- ///
- internal static partial class PubSubMethodHandlersLog
- {
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 0, Level = LogLevel.Warning,
- Message = "PublishSubscribe Enable failed.")]
- public static partial void PublishSubscribeEnableFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 1, Level = LogLevel.Warning,
- Message = "PublishSubscribe Disable failed.")]
- public static partial void PublishSubscribeDisableFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 2, Level = LogLevel.Warning,
- Message = "AddConnection failed.")]
- public static partial void AddConnectionFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 3, Level = LogLevel.Warning,
- Message = "RemoveConnection failed.")]
- public static partial void RemoveConnectionFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 4, Level = LogLevel.Warning,
- Message = "SetConfiguration failed.")]
- public static partial void SetConfigurationFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 5, Level = LogLevel.Warning,
- Message = "GetConfiguration failed.")]
- public static partial void GetConfigurationFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 6, Level = LogLevel.Warning,
- Message = "RemovePublishedDataSet failed.")]
- public static partial void RemovePublishedDataSetFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 7, Level = LogLevel.Warning,
- Message = "AddWriterGroup failed.")]
- public static partial void AddWriterGroupFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 8, Level = LogLevel.Warning,
- Message = "AddReaderGroup failed.")]
- public static partial void AddReaderGroupFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 9, Level = LogLevel.Warning,
- Message = "RemoveGroup failed.")]
- public static partial void RemoveGroupFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 10, Level = LogLevel.Warning,
- Message = "AddDataSetWriter failed.")]
- public static partial void AddDataSetWriterFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 11, Level = LogLevel.Warning,
- Message = "RemoveDataSetWriter failed.")]
- public static partial void RemoveDataSetWriterFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 12, Level = LogLevel.Warning,
- Message = "AddDataSetReader failed.")]
- public static partial void AddDataSetReaderFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 13, Level = LogLevel.Warning,
- Message = "RemoveDataSetReader failed.")]
- public static partial void RemoveDataSetReaderFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 14, Level = LogLevel.Warning,
- Message = "AddPublishedDataSet failed.")]
- public static partial void AddPublishedDataSetFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 15, Level = LogLevel.Warning,
- Message = "PublishedDataItems mutation failed.")]
- public static partial void PublishedDataItemsMutationFailed(this ILogger logger, Exception exception);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 16, Level = LogLevel.Debug,
- Message = "AddSecurityGroup {Name} rejected with {Status}.")]
- public static partial void AddSecurityGroupRejected(
- this ILogger logger,
- Exception exception,
- string name,
- StatusCode status);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 17, Level = LogLevel.Error,
- Message = "AddSecurityGroup {Name} threw unexpectedly.")]
- public static partial void AddSecurityGroupThrewUnexpectedly(
- this ILogger logger,
- Exception exception,
- string name);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 18, Level = LogLevel.Debug,
- Message = "RemoveSecurityGroup {Id} rejected with {Status}.")]
- public static partial void RemoveSecurityGroupRejected(
- this ILogger logger,
- Exception exception,
- string id,
- StatusCode status);
-
- [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 19, Level = LogLevel.Error,
- Message = "RemoveSecurityGroup {Id} threw unexpectedly.")]
- public static partial void RemoveSecurityGroupThrewUnexpectedly(
- this ILogger logger,
- Exception exception,
- string id);
-
- }
-
-}
+/* ========================================================================
+ * 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.Linq;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Opc.Ua.PubSub.Application;
+using Opc.Ua.PubSub.Configuration;
+using Opc.Ua.PubSub.Security;
+using Opc.Ua.PubSub.Security.Sks;
+
+namespace Opc.Ua.PubSub.Server
+{
+ ///
+ /// Hosts the synchronous method-handler delegates the
+ /// attaches to the standard
+ /// PublishSubscribe Method nodes (Part 14 §9.1.3,
+ /// §9.1.10 and §8.3.1).
+ ///
+ ///
+ /// Implements the configuration-mutation entry-points
+ /// via the mutable surface.
+ /// All entry-points adhere to the legacy synchronous
+ /// GenericMethodCalledEventHandler contract; every async
+ /// call is forwarded via .AsTask().GetAwaiter().GetResult()
+ /// — the single sanctioned sync-over-async bridge.
+ ///
+ internal sealed class PubSubMethodHandlers
+ {
+ private const string DefaultSecurityPolicyUri =
+ "http://opcfoundation.org/UA/SecurityPolicy#PubSub-Aes256-CTR";
+
+ private readonly IPubSubApplication m_application;
+ private readonly IPubSubKeyServiceServer? m_keyService;
+ private readonly PubSubServerOptions m_options;
+ private readonly SksMethodHandler? m_sks;
+ private readonly PushSecurityKeyProvider[] m_pushProviders;
+ private readonly ILogger m_logger;
+ private readonly Dictionary m_securityGroupNodeIds = [];
+ private readonly System.Threading.Lock m_gate = new();
+ private ushort m_securityGroupNamespaceIndex;
+
+ ///
+ /// Creates a new .
+ ///
+ /// Runtime application.
+ ///
+ /// SKS server, or when the host is
+ /// not acting as an SKS.
+ ///
+ /// PubSub server options.
+ /// Telemetry context.
+ /// Optional SetSecurityKeys push providers.
+ public PubSubMethodHandlers(
+ IPubSubApplication application,
+ IPubSubKeyServiceServer? keyService,
+ PubSubServerOptions options,
+ ITelemetryContext telemetry,
+ IEnumerable? pushProviders = null)
+ {
+ if (application is null)
+ {
+ throw new ArgumentNullException(nameof(application));
+ }
+ if (options is null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+ if (telemetry is null)
+ {
+ throw new ArgumentNullException(nameof(telemetry));
+ }
+ m_application = application;
+ m_keyService = keyService;
+ m_options = options;
+ m_sks = keyService is null ? null : new SksMethodHandler(keyService, telemetry);
+ m_pushProviders = pushProviders?.ToArray() ?? [];
+ m_logger = telemetry.CreateLogger();
+ }
+
+ ///
+ /// Sets the namespace index used for SecurityGroup instance NodeIds.
+ ///
+ /// PubSub node-manager namespace index.
+ public void SetSecurityGroupNamespaceIndex(ushort namespaceIndex)
+ {
+ lock (m_gate)
+ {
+ m_securityGroupNamespaceIndex = namespaceIndex;
+ }
+ }
+
+ ///
+ /// Registers a materialized SecurityGroup node.
+ ///
+ /// SecurityGroup identifier.
+ /// Routable SecurityGroup node id.
+ ///
+ public void RegisterSecurityGroupNodeId(string securityGroupId, NodeId nodeId)
+ {
+ if (string.IsNullOrEmpty(securityGroupId))
+ {
+ throw new ArgumentException("SecurityGroupId must be non-empty.", nameof(securityGroupId));
+ }
+
+ lock (m_gate)
+ {
+ m_securityGroupNodeIds[nodeId] = securityGroupId;
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.10.2 Status.Enable.
+ ///
+ /// System context.
+ /// Calling method node.
+ /// Input arguments (none).
+ /// Output arguments (none).
+ public ServiceResult OnEnable(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ _ = inputArguments;
+ _ = outputArguments;
+ try
+ {
+ m_application.StartAsync().AsTask().GetAwaiter().GetResult();
+ return ServiceResult.Good;
+ }
+ catch (Exception ex)
+ {
+ m_logger.PublishSubscribeEnableFailed(ex);
+ return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message));
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.10.3 Status.Disable.
+ ///
+ /// System context.
+ /// Calling method node.
+ /// Input arguments (none).
+ /// Output arguments (none).
+ public ServiceResult OnDisable(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ _ = inputArguments;
+ _ = outputArguments;
+ try
+ {
+ m_application.StopAsync().AsTask().GetAwaiter().GetResult();
+ return ServiceResult.Good;
+ }
+ catch (Exception ex)
+ {
+ m_logger.PublishSubscribeDisableFailed(ex);
+ return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message));
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.3.4 AddConnection.
+ /// Delegates to
+ /// .
+ ///
+ public ServiceResult OnAddConnection(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddConnection expects 1 input argument."));
+ }
+ if (!inputArguments[0].TryGetValue(out ExtensionObject ext))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddConnection argument 0 is not an ExtensionObject."));
+ }
+ if (!ext.TryGetValue(out PubSubConnectionDataType? cfg) || cfg is null)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddConnection argument 0 body is not a PubSubConnectionDataType."));
+ }
+ try
+ {
+ NodeId id = m_application.AddConnectionAsync(cfg)
+ .AsTask().GetAwaiter().GetResult();
+ outputArguments.Add(Variant.From(id));
+ return ServiceResult.Good;
+ }
+ catch (PubSubConfigurationException vex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadConfigurationError,
+ new LocalizedText(vex.Message));
+ }
+ catch (Exception ex)
+ {
+ m_logger.AddConnectionFailed(ex);
+ return new ServiceResult(
+ StatusCodes.BadInvalidState,
+ new LocalizedText(ex.Message));
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.3.5 RemoveConnection.
+ /// Delegates to
+ /// .
+ ///
+ public ServiceResult OnRemoveConnection(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ _ = outputArguments;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("RemoveConnection expects 1 input argument."));
+ }
+ if (!inputArguments[0].TryGetValue(out NodeId connectionId) ||
+ connectionId.IsNull)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("RemoveConnection argument 0 is not a valid NodeId."));
+ }
+ try
+ {
+ m_application.RemoveConnectionAsync(connectionId)
+ .AsTask().GetAwaiter().GetResult();
+ return ServiceResult.Good;
+ }
+ catch (PubSubConfigurationException vex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadConfigurationError,
+ new LocalizedText(vex.Message));
+ }
+ catch (ArgumentException aex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadNodeIdUnknown,
+ new LocalizedText(aex.Message));
+ }
+ catch (Exception ex)
+ {
+ m_logger.RemoveConnectionFailed(ex);
+ return new ServiceResult(
+ StatusCodes.BadInvalidState,
+ new LocalizedText(ex.Message));
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.6 SetConfiguration.
+ ///
+ public ServiceResult OnSetConfiguration(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("SetConfiguration expects 1 input argument."));
+ }
+ if (!inputArguments[0].TryGetValue(out ExtensionObject ext))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("SetConfiguration argument 0 is not an ExtensionObject."));
+ }
+ if (!ext.TryGetValue(out PubSubConfigurationDataType? cfg) || cfg is null)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "SetConfiguration argument 0 body is not a PubSubConfigurationDataType."));
+ }
+ try
+ {
+ ArrayOf results = m_application
+ .ReplaceConfigurationAsync(cfg)
+ .AsTask().GetAwaiter().GetResult();
+ outputArguments.Add(Variant.From([.. results]));
+ return ServiceResult.Good;
+ }
+ catch (PubSubConfigurationException vex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadConfigurationError,
+ new LocalizedText(vex.Message));
+ }
+ catch (Exception ex)
+ {
+ m_logger.SetConfigurationFailed(ex);
+ return new ServiceResult(
+ StatusCodes.BadInvalidState,
+ new LocalizedText(ex.Message));
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.6 GetConfiguration.
+ ///
+ public ServiceResult OnGetConfiguration(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ _ = inputArguments;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ try
+ {
+ PubSubConfigurationDataType config = m_application.GetConfiguration();
+ outputArguments.Add(Variant.From(new ExtensionObject(config)));
+ return ServiceResult.Good;
+ }
+ catch (Exception ex)
+ {
+ m_logger.GetConfigurationFailed(ex);
+ return new ServiceResult(
+ StatusCodes.BadInvalidState,
+ new LocalizedText(ex.Message));
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.4.5 AddPublishedDataItems.
+ ///
+ public ServiceResult OnAddPublishedDataItems(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 4)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddPublishedDataItems expects 4 input arguments."));
+ }
+ if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddPublishedDataItems argument 0 (Name) is missing or empty."));
+ }
+ string[] aliases = TryGetStringArray(inputArguments[1]);
+ if (!TryGetEncodeableArray(inputArguments[3], context, out PublishedVariableDataType[] variables))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddPublishedDataItems argument 3 is not a PublishedVariableDataType array."));
+ }
+ PublishedDataSetDataType dataSet = CreatePublishedDataItemsDataSet(name, aliases, variables, null);
+ return AddPublishedDataSet(dataSet, variables.Length, outputArguments, includeConfigurationVersion: true);
+ }
+
+ ///
+ /// Implements Part 14 §9.1.4.5 AddPublishedEvents.
+ ///
+ public ServiceResult OnAddPublishedEvents(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 6)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddPublishedEvents expects 6 input arguments."));
+ }
+ if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddPublishedEvents argument 0 (Name) is missing or empty."));
+ }
+ if (!inputArguments[1].TryGetValue(out NodeId eventNotifier) || eventNotifier.IsNull)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddPublishedEvents argument 1 is not a valid NodeId."));
+ }
+ string[] aliases = TryGetStringArray(inputArguments[2]);
+ if (!TryGetEncodeableArray(inputArguments[4], context, out SimpleAttributeOperand[] selectedFields))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddPublishedEvents argument 4 is not a SimpleAttributeOperand array."));
+ }
+ ContentFilter filter = inputArguments[5].TryGetValue(out ExtensionObject filterObject) &&
+ filterObject.TryGetValue(out ContentFilter? decodedFilter) &&
+ decodedFilter is not null
+ ? decodedFilter
+ : new ContentFilter();
+ PublishedDataSetDataType dataSet = CreatePublishedEventsDataSet(
+ name,
+ eventNotifier,
+ aliases,
+ selectedFields,
+ filter,
+ null);
+ var dataSetOutputs = new List();
+ ServiceResult result = AddPublishedDataSet(
+ dataSet,
+ selectedFields.Length,
+ dataSetOutputs,
+ includeConfigurationVersion: true);
+ if (StatusCode.IsBad(result.StatusCode))
+ {
+ return result;
+ }
+ outputArguments.Add(dataSetOutputs[1]);
+ outputArguments.Add(dataSetOutputs[0]);
+ return result;
+ }
+
+ ///
+ /// Implements Part 14 §9.1.4.5 AddPublishedDataItemsTemplate.
+ ///
+ public ServiceResult OnAddPublishedDataItemsTemplate(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 3)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddPublishedDataItemsTemplate expects 3 input arguments."));
+ }
+ if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddPublishedDataItemsTemplate argument 0 (Name) is missing or empty."));
+ }
+ if (!inputArguments[1].TryGetValue(out ExtensionObject metaDataObject) ||
+ !metaDataObject.TryGetValue(out DataSetMetaDataType? metaData) ||
+ metaData is null)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddPublishedDataItemsTemplate argument 1 is not DataSetMetaDataType."));
+ }
+ if (!TryGetEncodeableArray(inputArguments[2], context, out PublishedVariableDataType[] variables))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddPublishedDataItemsTemplate argument 2 is not a PublishedVariableDataType array."));
+ }
+ PublishedDataSetDataType dataSet = CreatePublishedDataItemsDataSet(
+ name,
+ [],
+ variables,
+ metaData);
+ return AddPublishedDataSet(dataSet, variables.Length, outputArguments, includeConfigurationVersion: false);
+ }
+
+ ///
+ /// Implements Part 14 §9.1.6 RemovePublishedDataSet.
+ ///
+ public ServiceResult OnRemovePublishedDataSet(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ _ = outputArguments;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("RemovePublishedDataSet expects 1 input argument."));
+ }
+ if (!inputArguments[0].TryGetValue(out NodeId dataSetId) ||
+ dataSetId.IsNull)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "RemovePublishedDataSet argument 0 is not a valid NodeId."));
+ }
+ try
+ {
+ m_application.RemovePublishedDataSetAsync(dataSetId)
+ .AsTask().GetAwaiter().GetResult();
+ return ServiceResult.Good;
+ }
+ catch (ArgumentException aex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadNodeIdUnknown,
+ new LocalizedText(aex.Message));
+ }
+ catch (PubSubConfigurationException vex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadConfigurationError,
+ new LocalizedText(vex.Message));
+ }
+ catch (Exception ex)
+ {
+ m_logger.RemovePublishedDataSetFailed(ex);
+ return new ServiceResult(
+ StatusCodes.BadInvalidState,
+ new LocalizedText(ex.Message));
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.5 AddDataSetFolder.
+ /// The server NodeManager materializes the returned folder NodeId
+ /// because folders are address-space objects, not configuration
+ /// records.
+ ///
+ public ServiceResult OnAddDataSetFolder(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddDataSetFolder expects 1 input argument."));
+ }
+ if (!inputArguments[0].TryGetValue(out string folderName) ||
+ string.IsNullOrEmpty(folderName))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "AddDataSetFolder argument 0 (FolderName) is missing or empty."));
+ }
+ outputArguments.Add(Variant.From(
+ new NodeId($"pubsub:folder:{folderName}", 0)));
+ return ServiceResult.Good;
+ }
+
+ ///
+ /// Implements Part 14 §9.1.5 RemoveDataSetFolder.
+ /// The server NodeManager owns address-space removal for folder nodes.
+ ///
+ public ServiceResult OnRemoveDataSetFolder(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ _ = outputArguments;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("RemoveDataSetFolder expects 1 input argument."));
+ }
+ return ServiceResult.Good;
+ }
+
+ ///
+ /// Implements Part 14 §9.1.6 AddWriterGroup.
+ ///
+ public ServiceResult OnAddWriterGroup(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 2)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddWriterGroup expects 2 input arguments."));
+ }
+ if (!inputArguments[0].TryGetValue(out NodeId connectionId) ||
+ connectionId.IsNull)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "AddWriterGroup argument 0 (ConnectionId) is not a valid NodeId."));
+ }
+ if (!inputArguments[1].TryGetValue(out ExtensionObject ext))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "AddWriterGroup argument 1 is not an ExtensionObject."));
+ }
+ if (!ext.TryGetValue(out WriterGroupDataType? cfg) || cfg is null)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "AddWriterGroup argument 1 body is not a WriterGroupDataType."));
+ }
+ try
+ {
+ NodeId id = m_application.AddWriterGroupAsync(connectionId, cfg)
+ .AsTask().GetAwaiter().GetResult();
+ outputArguments.Add(Variant.From(id));
+ return ServiceResult.Good;
+ }
+ catch (ArgumentException aex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadNodeIdUnknown,
+ new LocalizedText(aex.Message));
+ }
+ catch (PubSubConfigurationException vex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadConfigurationError,
+ new LocalizedText(vex.Message));
+ }
+ catch (Exception ex)
+ {
+ m_logger.AddWriterGroupFailed(ex);
+ return new ServiceResult(
+ StatusCodes.BadInvalidState,
+ new LocalizedText(ex.Message));
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.6 AddReaderGroup.
+ ///
+ public ServiceResult OnAddReaderGroup(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 2)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddReaderGroup expects 2 input arguments."));
+ }
+ if (!inputArguments[0].TryGetValue(out NodeId connectionId) ||
+ connectionId.IsNull)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "AddReaderGroup argument 0 (ConnectionId) is not a valid NodeId."));
+ }
+ if (!inputArguments[1].TryGetValue(out ExtensionObject ext))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "AddReaderGroup argument 1 is not an ExtensionObject."));
+ }
+ if (!ext.TryGetValue(out ReaderGroupDataType? cfg) || cfg is null)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "AddReaderGroup argument 1 body is not a ReaderGroupDataType."));
+ }
+ try
+ {
+ NodeId id = m_application.AddReaderGroupAsync(connectionId, cfg)
+ .AsTask().GetAwaiter().GetResult();
+ outputArguments.Add(Variant.From(id));
+ return ServiceResult.Good;
+ }
+ catch (ArgumentException aex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadNodeIdUnknown,
+ new LocalizedText(aex.Message));
+ }
+ catch (PubSubConfigurationException vex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadConfigurationError,
+ new LocalizedText(vex.Message));
+ }
+ catch (Exception ex)
+ {
+ m_logger.AddReaderGroupFailed(ex);
+ return new ServiceResult(
+ StatusCodes.BadInvalidState,
+ new LocalizedText(ex.Message));
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.6 RemoveGroup.
+ ///
+ public ServiceResult OnRemoveGroup(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ _ = outputArguments;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("RemoveGroup expects 1 input argument."));
+ }
+ if (!inputArguments[0].TryGetValue(out NodeId groupId) ||
+ groupId.IsNull)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "RemoveGroup argument 0 is not a valid NodeId."));
+ }
+ try
+ {
+ m_application.RemoveGroupAsync(groupId)
+ .AsTask().GetAwaiter().GetResult();
+ return ServiceResult.Good;
+ }
+ catch (ArgumentException aex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadNodeIdUnknown,
+ new LocalizedText(aex.Message));
+ }
+ catch (PubSubConfigurationException vex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadConfigurationError,
+ new LocalizedText(vex.Message));
+ }
+ catch (Exception ex)
+ {
+ m_logger.RemoveGroupFailed(ex);
+ return new ServiceResult(
+ StatusCodes.BadInvalidState,
+ new LocalizedText(ex.Message));
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.7 AddDataSetWriter.
+ ///
+ public ServiceResult OnAddDataSetWriter(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 2)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddDataSetWriter expects 2 input arguments."));
+ }
+ if (!inputArguments[0].TryGetValue(out NodeId writerGroupId) ||
+ writerGroupId.IsNull)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "AddDataSetWriter argument 0 (WriterGroupId) is not a valid NodeId."));
+ }
+ if (!inputArguments[1].TryGetValue(out ExtensionObject ext))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "AddDataSetWriter argument 1 is not an ExtensionObject."));
+ }
+ if (!ext.TryGetValue(out DataSetWriterDataType? cfg) || cfg is null)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "AddDataSetWriter argument 1 body is not a DataSetWriterDataType."));
+ }
+ try
+ {
+ NodeId id = m_application.AddDataSetWriterAsync(writerGroupId, cfg)
+ .AsTask().GetAwaiter().GetResult();
+ outputArguments.Add(Variant.From(id));
+ return ServiceResult.Good;
+ }
+ catch (ArgumentException aex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadNodeIdUnknown,
+ new LocalizedText(aex.Message));
+ }
+ catch (PubSubConfigurationException vex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadConfigurationError,
+ new LocalizedText(vex.Message));
+ }
+ catch (Exception ex)
+ {
+ m_logger.AddDataSetWriterFailed(ex);
+ return new ServiceResult(
+ StatusCodes.BadInvalidState,
+ new LocalizedText(ex.Message));
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.7 RemoveDataSetWriter.
+ ///
+ public ServiceResult OnRemoveDataSetWriter(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ _ = outputArguments;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("RemoveDataSetWriter expects 1 input argument."));
+ }
+ if (!inputArguments[0].TryGetValue(out NodeId writerId) ||
+ writerId.IsNull)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "RemoveDataSetWriter argument 0 is not a valid NodeId."));
+ }
+ try
+ {
+ m_application.RemoveDataSetWriterAsync(writerId)
+ .AsTask().GetAwaiter().GetResult();
+ return ServiceResult.Good;
+ }
+ catch (ArgumentException aex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadNodeIdUnknown,
+ new LocalizedText(aex.Message));
+ }
+ catch (PubSubConfigurationException vex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadConfigurationError,
+ new LocalizedText(vex.Message));
+ }
+ catch (Exception ex)
+ {
+ m_logger.RemoveDataSetWriterFailed(ex);
+ return new ServiceResult(
+ StatusCodes.BadInvalidState,
+ new LocalizedText(ex.Message));
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.8 AddDataSetReader.
+ ///
+ public ServiceResult OnAddDataSetReader(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 2)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddDataSetReader expects 2 input arguments."));
+ }
+ if (!inputArguments[0].TryGetValue(out NodeId readerGroupId) ||
+ readerGroupId.IsNull)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "AddDataSetReader argument 0 (ReaderGroupId) is not a valid NodeId."));
+ }
+ if (!inputArguments[1].TryGetValue(out ExtensionObject ext))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "AddDataSetReader argument 1 is not an ExtensionObject."));
+ }
+ if (!ext.TryGetValue(out DataSetReaderDataType? cfg) || cfg is null)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "AddDataSetReader argument 1 body is not a DataSetReaderDataType."));
+ }
+ try
+ {
+ NodeId id = m_application.AddDataSetReaderAsync(readerGroupId, cfg)
+ .AsTask().GetAwaiter().GetResult();
+ outputArguments.Add(Variant.From(id));
+ return ServiceResult.Good;
+ }
+ catch (ArgumentException aex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadNodeIdUnknown,
+ new LocalizedText(aex.Message));
+ }
+ catch (PubSubConfigurationException vex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadConfigurationError,
+ new LocalizedText(vex.Message));
+ }
+ catch (Exception ex)
+ {
+ m_logger.AddDataSetReaderFailed(ex);
+ return new ServiceResult(
+ StatusCodes.BadInvalidState,
+ new LocalizedText(ex.Message));
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.8 RemoveDataSetReader.
+ ///
+ public ServiceResult OnRemoveDataSetReader(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ _ = outputArguments;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("RemoveDataSetReader expects 1 input argument."));
+ }
+ if (!inputArguments[0].TryGetValue(out NodeId readerId) ||
+ readerId.IsNull)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText(
+ "RemoveDataSetReader argument 0 is not a valid NodeId."));
+ }
+ try
+ {
+ m_application.RemoveDataSetReaderAsync(readerId)
+ .AsTask().GetAwaiter().GetResult();
+ return ServiceResult.Good;
+ }
+ catch (ArgumentException aex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadNodeIdUnknown,
+ new LocalizedText(aex.Message));
+ }
+ catch (PubSubConfigurationException vex)
+ {
+ return new ServiceResult(
+ StatusCodes.BadConfigurationError,
+ new LocalizedText(vex.Message));
+ }
+ catch (Exception ex)
+ {
+ m_logger.RemoveDataSetReaderFailed(ex);
+ return new ServiceResult(
+ StatusCodes.BadInvalidState,
+ new LocalizedText(ex.Message));
+ }
+ }
+
+ ///
+ /// Implements Part 14 §9.1.4.3 AddVariables.
+ ///
+ public ServiceResult OnAddVariables(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (!TryGetPublishedDataSetName(method, out string dataSetName))
+ {
+ return new ServiceResult(StatusCodes.BadNodeIdUnknown);
+ }
+ if (inputArguments.Count < 4)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddVariables expects 4 input arguments."));
+ }
+ string[] aliases = TryGetStringArray(inputArguments[1]);
+ if (!TryGetEncodeableArray(inputArguments[3], context, out PublishedVariableDataType[] variables))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddVariables argument 3 is not a PublishedVariableDataType array."));
+ }
+
+ return MutatePublishedDataItems(
+ dataSetName,
+ (dataSet, items) =>
+ {
+ List published = ClonePublishedVariables(items);
+ published.AddRange(variables);
+ items.PublishedData = [.. published];
+ AppendMetaDataFields(dataSet, aliases, variables.Length);
+ return variables.Length;
+ },
+ outputArguments);
+ }
+
+ ///
+ /// Implements Part 14 §9.1.4.3 RemoveVariables.
+ ///
+ public ServiceResult OnRemoveVariables(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (!TryGetPublishedDataSetName(method, out string dataSetName))
+ {
+ return new ServiceResult(StatusCodes.BadNodeIdUnknown);
+ }
+ if (inputArguments.Count < 2)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("RemoveVariables expects 2 input arguments."));
+ }
+ if (!TryGetUInt32Array(inputArguments[1], out uint[] variablesToRemove))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("RemoveVariables argument 1 is not a UInt32 array."));
+ }
+
+ return MutatePublishedDataItems(
+ dataSetName,
+ (dataSet, items) =>
+ {
+ List published = ClonePublishedVariables(items);
+ List fields = CloneMetaDataFields(dataSet.DataSetMetaData);
+ Array.Sort(variablesToRemove);
+ int removed = 0;
+ for (int i = variablesToRemove.Length - 1; i >= 0; i--)
+ {
+ int index = checked((int)variablesToRemove[i]);
+ if (index < 0 || index >= published.Count)
+ {
+ continue;
+ }
+ published.RemoveAt(index);
+ if (index < fields.Count)
+ {
+ fields.RemoveAt(index);
+ }
+ removed++;
+ }
+ items.PublishedData = [.. published];
+ dataSet.DataSetMetaData ??= new DataSetMetaDataType();
+ dataSet.DataSetMetaData.Fields = [.. fields];
+ return removed;
+ },
+ outputArguments);
+ }
+
+ private ServiceResult AddPublishedDataSet(
+ PublishedDataSetDataType dataSet,
+ int resultCount,
+ List outputArguments,
+ bool includeConfigurationVersion)
+ {
+ try
+ {
+ NodeId dataSetId = m_application.AddPublishedDataSetAsync(dataSet)
+ .AsTask().GetAwaiter().GetResult();
+ PublishedDataSetDataType? added = FindPublishedDataSet(dataSet.Name ?? string.Empty);
+ outputArguments.Add(Variant.From(dataSetId));
+ if (includeConfigurationVersion)
+ {
+ outputArguments.Add(Variant.From(new ExtensionObject(
+ added?.DataSetMetaData?.ConfigurationVersion ?? new ConfigurationVersionDataType())));
+ }
+ outputArguments.Add(Variant.From(CreateGoodResults(resultCount)));
+ return ServiceResult.Good;
+ }
+ catch (PubSubConfigurationException vex)
+ {
+ return new ServiceResult(StatusCodes.BadConfigurationError, new LocalizedText(vex.Message));
+ }
+ catch (Exception ex)
+ {
+ m_logger.AddPublishedDataSetFailed(ex);
+ return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message));
+ }
+ }
+
+ private ServiceResult MutatePublishedDataItems(
+ string dataSetName,
+ Func mutator,
+ List outputArguments)
+ {
+ try
+ {
+ PubSubConfigurationDataType configuration = m_application.GetConfiguration();
+ var clone = (PubSubConfigurationDataType)configuration.Clone();
+ if (clone.PublishedDataSets.IsNull)
+ {
+ return new ServiceResult(StatusCodes.BadNodeIdUnknown);
+ }
+ int index = FindIndexByName(clone.PublishedDataSets, dataSetName);
+ if (index < 0)
+ {
+ return new ServiceResult(StatusCodes.BadNodeIdUnknown);
+ }
+ PublishedDataSetDataType dataSet = clone.PublishedDataSets[index];
+ if (dataSet.DataSetSource.IsNull ||
+ !dataSet.DataSetSource.TryGetValue(out PublishedDataItemsDataType? items) ||
+ items is null)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidState,
+ new LocalizedText("The PublishedDataSet is not a PublishedDataItemsType instance."));
+ }
+
+ int resultCount = mutator(dataSet, items);
+ dataSet.DataSetSource = new ExtensionObject(items);
+ _ = m_application.ReplaceConfigurationAsync(clone)
+ .AsTask().GetAwaiter().GetResult();
+ PublishedDataSetDataType? updated = FindPublishedDataSet(dataSetName);
+ outputArguments.Add(Variant.From(new ExtensionObject(
+ updated?.DataSetMetaData?.ConfigurationVersion ?? new ConfigurationVersionDataType())));
+ outputArguments.Add(Variant.From(CreateGoodResults(resultCount)));
+ return ServiceResult.Good;
+ }
+ catch (Exception ex)
+ {
+ m_logger.PublishedDataItemsMutationFailed(ex);
+ return new ServiceResult(StatusCodes.BadInvalidState, new LocalizedText(ex.Message));
+ }
+ }
+
+ private static PublishedDataSetDataType CreatePublishedDataItemsDataSet(
+ string name,
+ string[] aliases,
+ PublishedVariableDataType[] variables,
+ DataSetMetaDataType? templateMetaData)
+ {
+ DataSetMetaDataType metaData = templateMetaData is null
+ ? CreateMetaData(name, aliases, variables.Length)
+ : (DataSetMetaDataType)templateMetaData.Clone();
+ if (templateMetaData is not null)
+ {
+ metaData.Fields = [.. CloneMetaDataFields(templateMetaData)];
+ }
+ if (metaData.Fields.IsNull || metaData.Fields.Count == 0)
+ {
+ metaData.Fields = CreateFields(aliases, variables.Length);
+ }
+ return new PublishedDataSetDataType
+ {
+ Name = name,
+ DataSetMetaData = metaData,
+ DataSetSource = new ExtensionObject(new PublishedDataItemsDataType
+ {
+ PublishedData = [.. variables]
+ })
+ };
+ }
+
+ private static PublishedDataSetDataType CreatePublishedEventsDataSet(
+ string name,
+ NodeId eventNotifier,
+ string[] aliases,
+ SimpleAttributeOperand[] selectedFields,
+ ContentFilter filter,
+ DataSetMetaDataType? templateMetaData)
+ {
+ DataSetMetaDataType metaData = templateMetaData is null
+ ? CreateMetaData(name, aliases, selectedFields.Length)
+ : (DataSetMetaDataType)templateMetaData.Clone();
+ if (templateMetaData is not null)
+ {
+ metaData.Fields = [.. CloneMetaDataFields(templateMetaData)];
+ }
+ if (metaData.Fields.IsNull || metaData.Fields.Count == 0)
+ {
+ metaData.Fields = CreateFields(aliases, selectedFields.Length);
+ }
+ return new PublishedDataSetDataType
+ {
+ Name = name,
+ DataSetMetaData = metaData,
+ DataSetSource = new ExtensionObject(new PublishedEventsDataType
+ {
+ EventNotifier = eventNotifier,
+ SelectedFields = [.. selectedFields],
+ Filter = filter
+ })
+ };
+ }
+
+ private static DataSetMetaDataType CreateMetaData(
+ string name,
+ string[] aliases,
+ int fieldCount)
+ {
+ return new DataSetMetaDataType
+ {
+ Name = name,
+ Fields = CreateFields(aliases, fieldCount)
+ };
+ }
+
+ private static ArrayOf CreateFields(string[] aliases, int fieldCount)
+ {
+ var fields = new FieldMetaData[fieldCount];
+ for (int i = 0; i < fields.Length; i++)
+ {
+ string fieldName = i < aliases.Length && !string.IsNullOrEmpty(aliases[i])
+ ? aliases[i]
+ : $"Field{i + 1}";
+ fields[i] = new FieldMetaData
+ {
+ Name = fieldName,
+ DataType = DataTypeIds.BaseDataType,
+ ValueRank = ValueRanks.Scalar,
+ Properties = []
+ };
+ }
+ return [.. fields];
+ }
+
+ private static void AppendMetaDataFields(
+ PublishedDataSetDataType dataSet,
+ string[] aliases,
+ int fieldCount)
+ {
+ dataSet.DataSetMetaData ??= new DataSetMetaDataType();
+ List fields = CloneMetaDataFields(dataSet.DataSetMetaData);
+ ArrayOf newFields = CreateFields(aliases, fieldCount);
+ for (int i = 0; i < newFields.Count; i++)
+ {
+ fields.Add(newFields[i]);
+ }
+ dataSet.DataSetMetaData.Fields = [.. fields];
+ }
+
+ private PublishedDataSetDataType? FindPublishedDataSet(string name)
+ {
+ PubSubConfigurationDataType configuration = m_application.GetConfiguration();
+ if (configuration.PublishedDataSets.IsNull)
+ {
+ return null;
+ }
+ int index = FindIndexByName(configuration.PublishedDataSets, name);
+ return index < 0 ? null : configuration.PublishedDataSets[index];
+ }
+
+ private static int FindIndexByName(
+ ArrayOf dataSets,
+ string name)
+ {
+ for (int i = 0; i < dataSets.Count; i++)
+ {
+ if (StringComparer.Ordinal.Equals(dataSets[i].Name, name))
+ {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ private static List ClonePublishedVariables(
+ PublishedDataItemsDataType items)
+ {
+ var published = new List();
+ if (items.PublishedData.IsNull)
+ {
+ return published;
+ }
+ foreach (PublishedVariableDataType item in items.PublishedData)
+ {
+ published.Add((PublishedVariableDataType)item.Clone());
+ }
+ return published;
+ }
+
+ private static List CloneMetaDataFields(DataSetMetaDataType? metaData)
+ {
+ var fields = new List();
+ if (metaData is null || metaData.Fields.IsNull)
+ {
+ return fields;
+ }
+ foreach (FieldMetaData field in metaData.Fields)
+ {
+ fields.Add((FieldMetaData)field.Clone());
+ }
+ return fields;
+ }
+
+ private static StatusCode[] CreateGoodResults(int count)
+ {
+ var results = new StatusCode[count];
+ for (int i = 0; i < results.Length; i++)
+ {
+ results[i] = StatusCodes.Good;
+ }
+ return results;
+ }
+
+ private static string[] TryGetStringArray(Variant value)
+ {
+ if (!value.TryGetValue(out ArrayOf values) || values.IsNull)
+ {
+ return [];
+ }
+ string[] result = new string[values.Count];
+ for (int i = 0; i < values.Count; i++)
+ {
+ result[i] = values[i];
+ }
+ return result;
+ }
+
+ private static bool TryGetUInt32Array(Variant value, out uint[] result)
+ {
+ result = [];
+ if (!value.TryGetValue(out ArrayOf values) || values.IsNull)
+ {
+ return false;
+ }
+ result = new uint[values.Count];
+ for (int i = 0; i < values.Count; i++)
+ {
+ result[i] = values[i];
+ }
+ return true;
+ }
+
+ private static bool TryGetEncodeableArray(
+ Variant value,
+ ISystemContext context,
+ out T[] result)
+ where T : class, IEncodeable
+ {
+ result = [];
+ IServiceMessageContext? messageContext = context as IServiceMessageContext
+ ?? AmbientMessageContext.CurrentContext;
+ if (!value.TryGetValue(out ArrayOf values, messageContext) || values.IsNull)
+ {
+ return false;
+ }
+ result = new T[values.Count];
+ for (int i = 0; i < values.Count; i++)
+ {
+ result[i] = values[i];
+ }
+ return true;
+ }
+
+ private static bool TryGetPublishedDataSetName(MethodState method, out string dataSetName)
+ {
+ dataSetName = string.Empty;
+ string nodeId;
+ if (method?.Parent is BaseObjectState parent)
+ {
+ nodeId = parent.NodeId.IdentifierAsString;
+ }
+ else if (method?.NodeId is not null)
+ {
+ nodeId = method.NodeId.IdentifierAsString;
+ }
+ else
+ {
+ return false;
+ }
+ const string prefix = "pubsub:published-data-set:";
+ if (!nodeId.StartsWith(prefix, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ dataSetName = nodeId[prefix.Length..];
+ int separator = dataSetName.IndexOf(':', StringComparison.Ordinal);
+ if (separator >= 0)
+ {
+ dataSetName = dataSetName[..separator];
+ }
+ return dataSetName.Length > 0;
+ }
+
+ ///
+ /// Implements Part 14 §8.3.4 AddSecurityGroup.
+ /// Delegates to
+ /// .
+ ///
+ /// System context.
+ /// Calling method node.
+ /// Input arguments.
+ /// Output arguments.
+ public ServiceResult OnAddSecurityGroup(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ if (m_keyService is null)
+ {
+ return new ServiceResult(StatusCodes.BadServiceUnsupported);
+ }
+ if (inputArguments.Count < 5)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText($"AddSecurityGroup expects 5 input arguments; got {inputArguments.Count}."));
+ }
+ if (!inputArguments[0].TryGetValue(out string? name) || string.IsNullOrEmpty(name))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddSecurityGroup argument 0 (SecurityGroupName) is missing or empty."));
+ }
+ if (!inputArguments[1].TryGetValue(out double keyLifetimeMs) || keyLifetimeMs <= 0)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddSecurityGroup argument 1 (KeyLifetime) must be a positive Duration."));
+ }
+ if (!inputArguments[2].TryGetValue(out string? policyUri) || string.IsNullOrEmpty(policyUri))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddSecurityGroup argument 2 (SecurityPolicyUri) is missing or empty."));
+ }
+ if (!inputArguments[3].TryGetValue(out uint maxFuture))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddSecurityGroup argument 3 (MaxFutureKeyCount) is not a UInt32."));
+ }
+ if (!inputArguments[4].TryGetValue(out uint maxPast))
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("AddSecurityGroup argument 4 (MaxPastKeyCount) is not a UInt32."));
+ }
+
+ var group = new SksSecurityGroup(
+ securityGroupId: name,
+ securityPolicyUri: policyUri,
+ keyLifetime: TimeSpan.FromMilliseconds(keyLifetimeMs),
+ maxFutureKeyCount: (int)Math.Min(maxFuture, int.MaxValue),
+ maxPastKeyCount: (int)Math.Min(maxPast, int.MaxValue),
+ keys: Array.Empty(),
+ authorizedCallerIdentities: TryReadAuthorizedCallers(inputArguments, 6),
+ rolePermissions: TryReadRolePermissions(inputArguments, 5));
+
+ try
+ {
+ m_keyService
+ .AddSecurityGroupAsync(group)
+ .AsTask()
+ .GetAwaiter()
+ .GetResult();
+ }
+ catch (OpcUaSksException ex)
+ {
+ m_logger.AddSecurityGroupRejected(ex, name, ex.Status);
+ return new ServiceResult(ex.Status, new LocalizedText(ex.Message));
+ }
+ catch (Exception ex)
+ {
+ m_logger.AddSecurityGroupThrewUnexpectedly(ex, name);
+ return new ServiceResult(StatusCodes.BadInternalError, new LocalizedText(ex.Message));
+ }
+
+ NodeId groupNodeId = GetOrAllocateSecurityGroupNodeId(name);
+ outputArguments.Add(Variant.From(name));
+ outputArguments.Add(Variant.From(groupNodeId));
+ return ServiceResult.Good;
+ }
+
+ ///
+ /// Implements Part 14 §8.3.5 RemoveSecurityGroup.
+ ///
+ /// System context.
+ /// Calling method node.
+ /// Input arguments.
+ /// Output arguments (none).
+ public ServiceResult OnRemoveSecurityGroup(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ _ = outputArguments;
+ if (m_keyService is null)
+ {
+ return new ServiceResult(StatusCodes.BadServiceUnsupported);
+ }
+ if (inputArguments.Count < 1)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("RemoveSecurityGroup expects 1 input argument."));
+ }
+ if (!inputArguments[0].TryGetValue(out NodeId groupNodeId) || groupNodeId.IsNull)
+ {
+ return new ServiceResult(
+ StatusCodes.BadInvalidArgument,
+ new LocalizedText("RemoveSecurityGroup argument 0 (SecurityGroupNodeId) is missing or not a NodeId."));
+ }
+ string? id = LookupSecurityGroupId(groupNodeId);
+ if (id is null)
+ {
+ return new ServiceResult(StatusCodes.BadNodeIdUnknown);
+ }
+ try
+ {
+ m_keyService
+ .RemoveSecurityGroupAsync(id)
+ .AsTask()
+ .GetAwaiter()
+ .GetResult();
+ }
+ catch (OpcUaSksException ex)
+ {
+ m_logger.RemoveSecurityGroupRejected(ex, id, ex.Status);
+ return new ServiceResult(ex.Status, new LocalizedText(ex.Message));
+ }
+ catch (Exception ex)
+ {
+ m_logger.RemoveSecurityGroupThrewUnexpectedly(ex, id);
+ return new ServiceResult(StatusCodes.BadInternalError, new LocalizedText(ex.Message));
+ }
+ lock (m_gate)
+ {
+ m_securityGroupNodeIds.Remove(groupNodeId);
+ }
+ return ServiceResult.Good;
+ }
+
+ ///
+ /// Implements Part 14 §8.3.2 GetSecurityKeys.
+ /// Delegates to .
+ ///
+ /// System context.
+ /// Calling method node.
+ /// Object the method is called on.
+ /// Input arguments.
+ /// Output arguments.
+ public ServiceResult OnGetSecurityKeys(
+ ISystemContext context,
+ MethodState method,
+ NodeId objectId,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = method;
+ if (m_sks is null)
+ {
+ return new ServiceResult(StatusCodes.BadServiceUnsupported);
+ }
+ return m_sks.HandleGetSecurityKeys(context, objectId, inputArguments.ToList(), outputArguments);
+ }
+
+ ///
+ /// Implements Part 14 §8.3.3 GetSecurityGroup.
+ ///
+ public ServiceResult OnGetSecurityGroup(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ if (m_keyService is null)
+ {
+ return new ServiceResult(StatusCodes.BadServiceUnsupported);
+ }
+ if (inputArguments.Count < 1 ||
+ !inputArguments[0].TryGetValue(out string? securityGroupId) ||
+ string.IsNullOrEmpty(securityGroupId))
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument);
+ }
+
+ SksSecurityGroup? group = m_keyService
+ .GetSecurityGroupAsync(securityGroupId)
+ .AsTask()
+ .GetAwaiter()
+ .GetResult();
+ if (group is null)
+ {
+ return new ServiceResult(StatusCodes.BadNoMatch);
+ }
+
+ outputArguments.Add(Variant.From(GetOrAllocateSecurityGroupNodeId(securityGroupId)));
+ return ServiceResult.Good;
+ }
+
+ ///
+ /// Implements Part 14 §9.1.3.3 SetSecurityKeys.
+ ///
+ public ServiceResult OnSetSecurityKeys(
+ ISystemContext context,
+ MethodState method,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = method;
+ _ = outputArguments;
+ if (!m_options.ExposeConfigurationMethods)
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (!IsSecurityKeyPushAuthorized(context))
+ {
+ return new ServiceResult(StatusCodes.BadUserAccessDenied);
+ }
+ if (inputArguments.Count < 7)
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument);
+ }
+ if (!inputArguments[0].TryGetValue(out string? securityGroupId) ||
+ string.IsNullOrEmpty(securityGroupId) ||
+ !inputArguments[1].TryGetValue(out string? policyUri) ||
+ string.IsNullOrEmpty(policyUri) ||
+ !inputArguments[2].TryGetValue(out uint currentTokenId) ||
+ !inputArguments[3].TryGetValue(out ByteString currentKey) ||
+ !inputArguments[4].TryGetValue(out ArrayOf futureKeys) ||
+ !inputArguments[5].TryGetValue(out double timeToNextKeyMs) ||
+ !inputArguments[6].TryGetValue(out double keyLifetimeMs))
+ {
+ return new ServiceResult(StatusCodes.BadInvalidArgument);
+ }
+
+ PushSecurityKeyProvider? provider = FindPushProvider(securityGroupId);
+ if (provider is null)
+ {
+ return new ServiceResult(StatusCodes.BadNotFound);
+ }
+
+ try
+ {
+ provider.SetSecurityKeysAsync(
+ policyUri,
+ currentTokenId,
+ currentKey,
+ futureKeys,
+ TimeSpan.FromMilliseconds(timeToNextKeyMs),
+ TimeSpan.FromMilliseconds(keyLifetimeMs))
+ .AsTask()
+ .GetAwaiter()
+ .GetResult();
+ return ServiceResult.Good;
+ }
+ catch (OpcUaSksException ex)
+ {
+ return new ServiceResult(ex.Status, new LocalizedText(ex.Message));
+ }
+ }
+
+ private static bool IsSecurityKeyPushAuthorized(ISystemContext context)
+ {
+ if (StringComparer.Ordinal.Equals(context.UserId, "sks"))
+ {
+ return true;
+ }
+
+ if (context is not ISessionOperationContext sessionContext)
+ {
+ return false;
+ }
+
+ ArrayOf grantedRoleIds = sessionContext.UserIdentity?.GrantedRoleIds ?? [];
+ return grantedRoleIds.Contains(ObjectIds.WellKnownRole_SecurityAdmin);
+ }
+
+ ///
+ /// Implements Part 14 §8.4.2 InvalidateKeys.
+ ///
+ public ServiceResult OnInvalidateKeys(
+ ISystemContext context,
+ MethodState method,
+ NodeId objectId,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ _ = inputArguments;
+ _ = outputArguments;
+ return RotateOrInvalidateKeys(objectId, invalidate: true);
+ }
+
+ ///
+ /// Implements Part 14 §8.4.3 ForceKeyRotation.
+ ///
+ public ServiceResult OnForceKeyRotation(
+ ISystemContext context,
+ MethodState method,
+ NodeId objectId,
+ ArrayOf inputArguments,
+ List outputArguments)
+ {
+ _ = context;
+ _ = method;
+ _ = inputArguments;
+ _ = outputArguments;
+ return RotateOrInvalidateKeys(objectId, invalidate: false);
+ }
+
+ ///
+ /// Returns the NodeId previously allocated for the
+ /// SecurityGroup identified by ,
+ /// or when the id is unknown to this
+ /// handler.
+ ///
+ /// SecurityGroup identifier.
+ public NodeId? TryGetSecurityGroupNodeId(string securityGroupId)
+ {
+ if (string.IsNullOrEmpty(securityGroupId))
+ {
+ return null;
+ }
+ lock (m_gate)
+ {
+ foreach (KeyValuePair kvp in m_securityGroupNodeIds)
+ {
+ if (string.Equals(kvp.Value, securityGroupId, StringComparison.Ordinal))
+ {
+ return kvp.Key;
+ }
+ }
+ return null;
+ }
+ }
+
+ ///
+ /// Resolves a SecurityGroup node id to its SecurityGroupId.
+ ///
+ /// SecurityGroup node id.
+ public string? LookupSecurityGroupIdForNode(NodeId groupNodeId)
+ {
+ return LookupSecurityGroupId(groupNodeId);
+ }
+
+ private ServiceResult RotateOrInvalidateKeys(NodeId groupNodeId, bool invalidate)
+ {
+ if (m_keyService is null)
+ {
+ return new ServiceResult(StatusCodes.BadServiceUnsupported);
+ }
+ string? id = LookupSecurityGroupId(groupNodeId);
+ if (id is null)
+ {
+ return new ServiceResult(StatusCodes.BadNodeIdUnknown);
+ }
+ try
+ {
+ ValueTask task = invalidate
+ ? m_keyService.InvalidateKeysAsync(id)
+ : m_keyService.ForceKeyRotationAsync(id);
+ task.AsTask().GetAwaiter().GetResult();
+ return ServiceResult.Good;
+ }
+ catch (OpcUaSksException ex)
+ {
+ return new ServiceResult(ex.Status, new LocalizedText(ex.Message));
+ }
+ }
+
+ private PushSecurityKeyProvider? FindPushProvider(string securityGroupId)
+ {
+ for (int i = 0; i < m_pushProviders.Length; i++)
+ {
+ if (string.Equals(m_pushProviders[i].SecurityGroupId, securityGroupId, StringComparison.Ordinal))
+ {
+ return m_pushProviders[i];
+ }
+ }
+ return null;
+ }
+
+ private NodeId GetOrAllocateSecurityGroupNodeId(string securityGroupId)
+ {
+ NodeId? existing = TryGetSecurityGroupNodeId(securityGroupId);
+ return existing ?? CreateSecurityGroupNodeId(securityGroupId);
+ }
+
+ private static ArrayOf TryReadRolePermissions(
+ ArrayOf inputArguments,
+ int index)
+ {
+ if (inputArguments.Count <= index)
+ {
+ return [];
+ }
+ if (!inputArguments[index].TryGetValue(out ArrayOf rolePermissionsArray))
+ {
+ return [];
+ }
+
+ var rolePermissions = new List(rolePermissionsArray.Count);
+ for (int i = 0; i < rolePermissionsArray.Count; i++)
+ {
+ if (rolePermissionsArray[i].TryGetValue(out RolePermissionType? rolePermission) &&
+ rolePermission is not null)
+ {
+ rolePermissions.Add(rolePermission);
+ }
+ }
+ return [.. rolePermissions];
+ }
+
+ private static ArrayOf TryReadAuthorizedCallers(ArrayOf inputArguments, int index)
+ {
+ if (inputArguments.Count <= index)
+ {
+ return [];
+ }
+ return inputArguments[index].TryGetValue(out ArrayOf callers) ? callers : [];
+ }
+
+ private string? LookupSecurityGroupId(NodeId groupNodeId)
+ {
+ lock (m_gate)
+ {
+ if (m_securityGroupNodeIds.TryGetValue(groupNodeId, out string? id))
+ {
+ return id;
+ }
+ }
+ if (groupNodeId.IdType == IdType.String &&
+ groupNodeId.TryGetValue(out string identifier) &&
+ !string.IsNullOrEmpty(identifier))
+ {
+ const string prefix = "pubsub:security-group:";
+ return identifier.StartsWith(prefix, StringComparison.Ordinal)
+ ? identifier[prefix.Length..]
+ : identifier;
+ }
+ return null;
+ }
+
+ private NodeId CreateSecurityGroupNodeId(string securityGroupId)
+ {
+ ushort namespaceIndex;
+ lock (m_gate)
+ {
+ namespaceIndex = m_securityGroupNamespaceIndex;
+ }
+ var nodeId = new NodeId($"pubsub:security-group:{securityGroupId}", namespaceIndex);
+ lock (m_gate)
+ {
+ m_securityGroupNodeIds[nodeId] = securityGroupId;
+ }
+ return nodeId;
+ }
+
+ ///
+ /// Returns the default SecurityPolicyUri for the SKS host.
+ ///
+ public string DefaultPolicyUri => m_options.DefaultSecurityPolicyUri ?? DefaultSecurityPolicyUri;
+ }
+
+ ///
+ /// Source-generated log messages for PubSubMethodHandlers.
+ ///
+ internal static partial class PubSubMethodHandlersLog
+ {
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 0, Level = LogLevel.Warning,
+ Message = "PublishSubscribe Enable failed.")]
+ public static partial void PublishSubscribeEnableFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 1, Level = LogLevel.Warning,
+ Message = "PublishSubscribe Disable failed.")]
+ public static partial void PublishSubscribeDisableFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 2, Level = LogLevel.Warning,
+ Message = "AddConnection failed.")]
+ public static partial void AddConnectionFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 3, Level = LogLevel.Warning,
+ Message = "RemoveConnection failed.")]
+ public static partial void RemoveConnectionFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 4, Level = LogLevel.Warning,
+ Message = "SetConfiguration failed.")]
+ public static partial void SetConfigurationFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 5, Level = LogLevel.Warning,
+ Message = "GetConfiguration failed.")]
+ public static partial void GetConfigurationFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 6, Level = LogLevel.Warning,
+ Message = "RemovePublishedDataSet failed.")]
+ public static partial void RemovePublishedDataSetFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 7, Level = LogLevel.Warning,
+ Message = "AddWriterGroup failed.")]
+ public static partial void AddWriterGroupFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 8, Level = LogLevel.Warning,
+ Message = "AddReaderGroup failed.")]
+ public static partial void AddReaderGroupFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 9, Level = LogLevel.Warning,
+ Message = "RemoveGroup failed.")]
+ public static partial void RemoveGroupFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 10, Level = LogLevel.Warning,
+ Message = "AddDataSetWriter failed.")]
+ public static partial void AddDataSetWriterFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 11, Level = LogLevel.Warning,
+ Message = "RemoveDataSetWriter failed.")]
+ public static partial void RemoveDataSetWriterFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 12, Level = LogLevel.Warning,
+ Message = "AddDataSetReader failed.")]
+ public static partial void AddDataSetReaderFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 13, Level = LogLevel.Warning,
+ Message = "RemoveDataSetReader failed.")]
+ public static partial void RemoveDataSetReaderFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 14, Level = LogLevel.Warning,
+ Message = "AddPublishedDataSet failed.")]
+ public static partial void AddPublishedDataSetFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 15, Level = LogLevel.Warning,
+ Message = "PublishedDataItems mutation failed.")]
+ public static partial void PublishedDataItemsMutationFailed(this ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 16, Level = LogLevel.Debug,
+ Message = "AddSecurityGroup {Name} rejected with {Status}.")]
+ public static partial void AddSecurityGroupRejected(
+ this ILogger logger,
+ Exception exception,
+ string name,
+ StatusCode status);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 17, Level = LogLevel.Error,
+ Message = "AddSecurityGroup {Name} threw unexpectedly.")]
+ public static partial void AddSecurityGroupThrewUnexpectedly(
+ this ILogger logger,
+ Exception exception,
+ string name);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 18, Level = LogLevel.Debug,
+ Message = "RemoveSecurityGroup {Id} rejected with {Status}.")]
+ public static partial void RemoveSecurityGroupRejected(
+ this ILogger logger,
+ Exception exception,
+ string id,
+ StatusCode status);
+
+ [LoggerMessage(EventId = PubSubServerEventIds.PubSubMethodHandlers + 19, Level = LogLevel.Error,
+ Message = "RemoveSecurityGroup {Id} threw unexpectedly.")]
+ public static partial void RemoveSecurityGroupThrewUnexpectedly(
+ this ILogger logger,
+ Exception exception,
+ string id);
+
+ }
+
+}
diff --git a/src/Opc.Ua.PubSub.Server/PubSubNodeManager.cs b/src/Opc.Ua.PubSub.Server/PubSubNodeManager.cs
index 5e093184a1..06e1bf133a 100644
--- a/src/Opc.Ua.PubSub.Server/PubSubNodeManager.cs
+++ b/src/Opc.Ua.PubSub.Server/PubSubNodeManager.cs
@@ -87,13 +87,7 @@ namespace Opc.Ua.PubSub.Server
private const uint RemoveSecurityGroupNodeId = 15447;
private const uint AddPushTargetNodeId = 25441;
private const uint RemovePushTargetNodeId = 25444;
- private const uint AddPublishedDataItemsNodeId = 14479;
- private const uint AddPublishedEventsNodeId = 14482;
- private const uint AddPublishedDataItemsTemplateNodeId = 16842;
- private const uint RemovePublishedDataSetNodeId = 14485;
- private const uint AddDataSetFolderNodeId = 16884;
- private const uint RemoveDataSetFolderNodeId = 16923;
- private static readonly NodeId s_publishedDataSetsNodeId = new(14478u);
+ private static readonly NodeId s_publishedDataSetsNodeId = ObjectIds.PublishSubscribe_PublishedDataSets;
private static readonly NodeId s_securityGroupsNodeId = new(15443u);
private static readonly NodeId s_keyPushTargetsNodeId = new(25440u);
@@ -312,6 +306,118 @@ namespace Opc.Ua.PubSub.Server
BindStandardMethod(file.ReserveIds, OnReservePubSubConfigurationIds);
BindStandardMethod(file.CloseAndUpdate, OnCloseAndUpdatePubSubConfigurationFile);
diagnosticsNodeManager.AddNode(file);
+
+ // The DataSetFolderType methods of PublishSubscribe.PublishedDataSets
+ // are Optional, so the standard address space does not contain them.
+ if (publishSubscribe.PublishedDataSets is { } publishedDataSets)
+ {
+ foreach (MethodState method in AddPublishedDataSetsMethods(SystemContext, publishedDataSets))
+ {
+ diagnosticsNodeManager.AddNode(method);
+ }
+ }
+ }
+
+ ///
+ /// Materializes the implemented DataSetFolderType methods on the standard
+ /// PublishSubscribe.PublishedDataSets instance with their well-known
+ /// instance NodeIds. The generated Add* extensions create them with the
+ /// NodeIds of the DataSetFolderType declarations, which the address space
+ /// already holds.
+ ///
+ internal static ArrayOf AddPublishedDataSetsMethods(
+ ISystemContext context,
+ DataSetFolderState publishedDataSets)
+ {
+ publishedDataSets.AddAddPublishedDataItems(context);
+ publishedDataSets.AddAddPublishedEvents(context);
+ publishedDataSets.AddAddPublishedDataItemsTemplate(context);
+ publishedDataSets.AddRemovePublishedDataSet(context);
+ publishedDataSets.AddAddDataSetFolder(context);
+ publishedDataSets.AddRemoveDataSetFolder(context);
+
+ return
+ [
+ AssignInstanceIds(
+ publishedDataSets.AddPublishedDataItems,
+ nameof(publishedDataSets.AddPublishedDataItems),
+ PublishedDataSetsIds.AddPublishedDataItems,
+ PublishedDataSetsIds.AddPublishedDataItemsInputArguments,
+ PublishedDataSetsIds.AddPublishedDataItemsOutputArguments),
+ AssignInstanceIds(
+ publishedDataSets.AddPublishedEvents,
+ nameof(publishedDataSets.AddPublishedEvents),
+ PublishedDataSetsIds.AddPublishedEvents,
+ PublishedDataSetsIds.AddPublishedEventsInputArguments,
+ PublishedDataSetsIds.AddPublishedEventsOutputArguments),
+ AssignInstanceIds(
+ publishedDataSets.AddPublishedDataItemsTemplate,
+ nameof(publishedDataSets.AddPublishedDataItemsTemplate),
+ PublishedDataSetsIds.AddPublishedDataItemsTemplate,
+ PublishedDataSetsIds.AddPublishedDataItemsTemplateInputArguments,
+ PublishedDataSetsIds.AddPublishedDataItemsTemplateOutputArguments),
+ AssignInstanceIds(
+ publishedDataSets.RemovePublishedDataSet,
+ nameof(publishedDataSets.RemovePublishedDataSet),
+ PublishedDataSetsIds.RemovePublishedDataSet,
+ PublishedDataSetsIds.RemovePublishedDataSetInputArguments,
+ outputArgumentsId: null),
+ AssignInstanceIds(
+ publishedDataSets.AddDataSetFolder,
+ nameof(publishedDataSets.AddDataSetFolder),
+ PublishedDataSetsIds.AddDataSetFolder,
+ PublishedDataSetsIds.AddDataSetFolderInputArguments,
+ PublishedDataSetsIds.AddDataSetFolderOutputArguments),
+ AssignInstanceIds(
+ publishedDataSets.RemoveDataSetFolder,
+ nameof(publishedDataSets.RemoveDataSetFolder),
+ PublishedDataSetsIds.RemoveDataSetFolder,
+ PublishedDataSetsIds.RemoveDataSetFolderInputArguments,
+ outputArgumentsId: null)
+ ];
+ }
+
+ ///
+ /// Gives a materialized method and its argument variables their instance
+ /// NodeIds. A method without output arguments passes a null
+ /// ; an argument variable the generated
+ /// method does not match is a configuration error.
+ ///
+ internal static MethodState AssignInstanceIds(
+ MethodState? method,
+ string name,
+ uint methodId,
+ uint inputArgumentsId,
+ uint? outputArgumentsId)
+ {
+ if (method == null)
+ {
+ throw new ServiceResultException(StatusCodes.BadConfigurationError,
+ $"The generated PublishedDataSets method {name} (i={methodId}) was not created.");
+ }
+ if (method.InputArguments == null)
+ {
+ throw new ServiceResultException(StatusCodes.BadConfigurationError,
+ $"The generated PublishedDataSets method {name} (i={methodId}) has no InputArguments.");
+ }
+ if (method.OutputArguments != null && outputArgumentsId == null)
+ {
+ throw new ServiceResultException(StatusCodes.BadConfigurationError,
+ $"The generated PublishedDataSets method {name} (i={methodId}) has OutputArguments " +
+ "that its standard declaration does not have.");
+ }
+ if (method.OutputArguments == null && outputArgumentsId != null)
+ {
+ throw new ServiceResultException(StatusCodes.BadConfigurationError,
+ $"The generated PublishedDataSets method {name} (i={methodId}) has no OutputArguments.");
+ }
+ method.NodeId = new NodeId(methodId);
+ method.InputArguments.NodeId = new NodeId(inputArgumentsId);
+ if (method.OutputArguments != null && outputArgumentsId is uint outputId)
+ {
+ method.OutputArguments.NodeId = new NodeId(outputId);
+ }
+ return method;
}
private void BindMethods(IDiagnosticsNodeManager diagnosticsNodeManager)
@@ -373,28 +479,44 @@ namespace Opc.Ua.PubSub.Server
m_methodsBound = enable is not null || disable is not null;
}
+ ///
+ /// Binds the methods of the standard PublishSubscribe.PublishedDataSets
+ /// instance, the folder that holds the PublishedDataSets and DataSetFolders.
+ ///
private void BindPublishedDataSetFolderMethods(IDiagnosticsNodeManager diagnosticsNodeManager)
{
BindStandardMethod(
- diagnosticsNodeManager, AddPublishedDataItemsNodeId, MethodHandlers.OnAddPublishedDataItems);
- BindStandardMethod(diagnosticsNodeManager, AddPublishedEventsNodeId, MethodHandlers.OnAddPublishedEvents);
+ diagnosticsNodeManager,
+ new NodeId(PublishedDataSetsIds.AddPublishedDataItems),
+ MethodHandlers.OnAddPublishedDataItems);
BindStandardMethod(
diagnosticsNodeManager,
- AddPublishedDataItemsTemplateNodeId,
+ new NodeId(PublishedDataSetsIds.AddPublishedEvents),
+ MethodHandlers.OnAddPublishedEvents);
+ BindStandardMethod(
+ diagnosticsNodeManager,
+ new NodeId(PublishedDataSetsIds.AddPublishedDataItemsTemplate),
MethodHandlers.OnAddPublishedDataItemsTemplate);
BindStandardMethod(
- diagnosticsNodeManager, RemovePublishedDataSetNodeId, MethodHandlers.OnRemovePublishedDataSet);
- BindStandardMethod(diagnosticsNodeManager, AddDataSetFolderNodeId, OnAddDataSetFolder);
- BindStandardMethod(diagnosticsNodeManager, RemoveDataSetFolderNodeId, OnRemoveDataSetFolder);
+ diagnosticsNodeManager,
+ new NodeId(PublishedDataSetsIds.RemovePublishedDataSet),
+ MethodHandlers.OnRemovePublishedDataSet);
+ BindStandardMethod(
+ diagnosticsNodeManager,
+ new NodeId(PublishedDataSetsIds.AddDataSetFolder),
+ OnAddDataSetFolder);
+ BindStandardMethod(
+ diagnosticsNodeManager,
+ new NodeId(PublishedDataSetsIds.RemoveDataSetFolder),
+ OnRemoveDataSetFolder);
}
private static void BindStandardMethod(
IDiagnosticsNodeManager diagnosticsNodeManager,
- uint nodeId,
+ NodeId nodeId,
GenericMethodCalledEventHandler handler)
{
- MethodState? method = diagnosticsNodeManager
- .FindPredefinedNode(new NodeId(nodeId));
+ MethodState? method = diagnosticsNodeManager.FindPredefinedNode(nodeId);
BindStandardMethod(method, handler);
}
@@ -1907,6 +2029,31 @@ namespace Opc.Ua.PubSub.Server
}
}
+ ///
+ /// The NodeIds of the methods of the PublishSubscribe.PublishedDataSets
+ /// instance (i=17371) and of their argument variables, not those of the
+ /// DataSetFolderType declarations.
+ ///
+ private static class PublishedDataSetsIds
+ {
+ public const uint AddPublishedDataItems = 17372;
+ public const uint AddPublishedDataItemsInputArguments = 17373;
+ public const uint AddPublishedDataItemsOutputArguments = 17374;
+ public const uint AddPublishedEvents = 17375;
+ public const uint AddPublishedEventsInputArguments = 17376;
+ public const uint AddPublishedEventsOutputArguments = 17377;
+ public const uint AddPublishedDataItemsTemplate = 17378;
+ public const uint AddPublishedDataItemsTemplateInputArguments = 17379;
+ public const uint AddPublishedDataItemsTemplateOutputArguments = 17380;
+ public const uint RemovePublishedDataSet = 17384;
+ public const uint RemovePublishedDataSetInputArguments = 17385;
+ public const uint AddDataSetFolder = 17398;
+ public const uint AddDataSetFolderInputArguments = 17401;
+ public const uint AddDataSetFolderOutputArguments = 17402;
+ public const uint RemoveDataSetFolder = 17403;
+ public const uint RemoveDataSetFolderInputArguments = 17404;
+ }
+
private sealed class PubSubKeyPushTargetRegistration
{
public PubSubKeyPushTargetRegistration(
diff --git a/tests/Opc.Ua.PubSub.Server.Tests/PubSubMethodHandlersMutationTests.cs b/tests/Opc.Ua.PubSub.Server.Tests/PubSubMethodHandlersMutationTests.cs
index 913bf5375d..1bb70807e6 100644
--- a/tests/Opc.Ua.PubSub.Server.Tests/PubSubMethodHandlersMutationTests.cs
+++ b/tests/Opc.Ua.PubSub.Server.Tests/PubSubMethodHandlersMutationTests.cs
@@ -1,464 +1,470 @@
-/* ========================================================================
- * 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 NUnit.Framework;
-using Opc.Ua.PubSub.Application;
-using Opc.Ua.PubSub.Tests;
-using Opc.Ua.PubSub.Transports;
-using Opc.Ua.Tests;
-
-namespace Opc.Ua.PubSub.Server.Tests
-{
- [TestFixture]
- [TestSpec("9.1.3.4", Summary = "AddConnection handler")]
- [TestSpec("9.1.3.5", Summary = "RemoveConnection handler")]
- [TestSpec("9.1.6", Summary = "Configuration methods")]
- [TestSpec("9.1.4.3", Summary = "PublishedDataItemsType variable methods")]
- [TestSpec("9.1.4.5", Summary = "DataSetFolderType dataset methods")]
- public class PubSubMethodHandlersMutationTests
- {
- [Test]
- [TestSpec("9.1.3.4")]
- public void OnAddConnection_ValidInput_ReturnsGoodAndNodeId()
- {
- PubSubMethodHandlers handlers = CreateHandlers();
- var connCfg = new PubSubConnectionDataType
- {
- Name = "handler-conn",
- TransportProfileUri =
- "http://opcfoundation.org/UA-Profile/Transport/pubsub-udp-uadp",
- Address = new ExtensionObject(
- new NetworkAddressUrlDataType { Url = "opc.udp://224.0.0.22:4840" })
- };
- ArrayOf inputs = BuildArray(Variant.From(new ExtensionObject(connCfg)));
- var outputs = new List();
- ServiceResult result = handlers.OnAddConnection(
- BuildContext(), method: null!, inputArguments: inputs, outputArguments: outputs);
- Assert.Multiple(() =>
- {
- Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
- Assert.That(outputs, Has.Count.EqualTo(1));
- Assert.That(outputs[0].TryGetValue(out NodeId id), Is.True);
- Assert.That(id.IsNull, Is.False);
- });
- }
-
- [Test]
- [TestSpec("9.1.3.5")]
- public void OnRemoveConnection_ValidInput_ReturnsGood()
- {
- PubSubMethodHandlers handlers = CreateHandlers();
- var connCfg = new PubSubConnectionDataType
- {
- Name = "to-remove",
- TransportProfileUri =
- "http://opcfoundation.org/UA-Profile/Transport/pubsub-udp-uadp",
- Address = new ExtensionObject(
- new NetworkAddressUrlDataType { Url = "opc.udp://224.0.0.22:4840" })
- };
- ArrayOf addInputs = BuildArray(Variant.From(new ExtensionObject(connCfg)));
- var addOutputs = new List();
- handlers.OnAddConnection(
- BuildContext(), method: null!, inputArguments: addInputs, outputArguments: addOutputs);
- Assert.That(addOutputs[0].TryGetValue(out NodeId connId), Is.True);
-
- ArrayOf removeInputs = BuildArray(Variant.From(connId));
- var removeOutputs = new List();
- ServiceResult result = handlers.OnRemoveConnection(
- BuildContext(), method: null!, inputArguments: removeInputs,
- outputArguments: removeOutputs);
- Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
- }
-
- [Test]
- [TestSpec("9.1.6")]
- public void OnGetConfiguration_ReturnsGood()
- {
- PubSubMethodHandlers handlers = CreateHandlers();
- var outputs = new List();
- ServiceResult result = handlers.OnGetConfiguration(
- BuildContext(), method: null!, inputArguments: default, outputArguments: outputs);
- Assert.Multiple(() =>
- {
- Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
- Assert.That(outputs, Has.Count.EqualTo(1));
- });
- }
-
- [Test]
- [TestSpec("9.1.6")]
- public void OnSetConfiguration_ValidInput_ReturnsGood()
- {
- PubSubMethodHandlers handlers = CreateHandlers();
- var cfg = new PubSubConfigurationDataType
- {
- Connections = [],
- PublishedDataSets = []
- };
- ArrayOf inputs = BuildArray(Variant.From(new ExtensionObject(cfg)));
- var outputs = new List();
- ServiceResult result = handlers.OnSetConfiguration(
- BuildContext(), method: null!, inputArguments: inputs, outputArguments: outputs);
- Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
- }
-
- [Test]
- [TestSpec("9.1.4.5")]
- public void OnAddPublishedDataItems_RegistersPublishedDataItemsDataSet()
- {
- IPubSubApplication application = CreateApplication();
- PubSubMethodHandlers handlers = CreateHandlers(application);
- var outputs = new List();
- var variable = new PublishedVariableDataType
- {
- PublishedVariable = new NodeId(Variables.Server_ServerStatus_CurrentTime),
- AttributeId = Attributes.Value
- };
-
- ServiceResult result = handlers.OnAddPublishedDataItems(
- BuildContext(),
- method: null!,
- inputArguments: BuildArray(
- Variant.From("items"),
- Variant.From(new ArrayOf(s_currentTimeAlias)),
- Variant.From(Array.Empty()),
- Variant.FromStructure(new ArrayOf(
- new[] { variable }))),
- outputArguments: outputs);
-
- PubSubConfigurationDataType configuration = application.GetConfiguration();
- Assert.Multiple(() =>
- {
- Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
- Assert.That(outputs, Has.Count.EqualTo(3));
- Assert.That(configuration.PublishedDataSets, Has.Count.EqualTo(1));
- Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue(
- out PublishedDataItemsDataType? _), Is.True);
- });
- }
-
- [Test]
- [TestSpec("9.1.4.5")]
- public void OnAddPublishedDataItemsTemplate_UsesProvidedMetaData()
- {
- IPubSubApplication application = CreateApplication();
- PubSubMethodHandlers handlers = CreateHandlers(application);
- var outputs = new List();
- var variable = new PublishedVariableDataType
- {
- PublishedVariable = new NodeId(Variables.Server_ServerStatus_CurrentTime),
- AttributeId = Attributes.Value
- };
- var metaData = new DataSetMetaDataType
- {
- Name = "template",
- Fields = new ArrayOf(new[]
- {
- new FieldMetaData
- {
- Name = "FromTemplate",
- DataType = DataTypeIds.DateTime,
- ValueRank = ValueRanks.Scalar
- }
- })
- };
-
- ServiceResult result = handlers.OnAddPublishedDataItemsTemplate(
- BuildContext(),
- method: null!,
- inputArguments: BuildArray(
- Variant.From("template"),
- Variant.From(new ExtensionObject(metaData)),
- Variant.FromStructure(new ArrayOf(
- new[] { variable }))),
- outputArguments: outputs);
-
- PubSubConfigurationDataType configuration = application.GetConfiguration();
- Assert.Multiple(() =>
- {
- Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
- Assert.That(outputs, Has.Count.EqualTo(2));
- Assert.That(configuration.PublishedDataSets[0].Name, Is.EqualTo("template"));
- Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue(
- out PublishedDataItemsDataType? _), Is.True);
- });
- }
-
- [Test]
- [TestSpec("9.1.4.5")]
- public void OnAddPublishedEvents_RegistersPublishedEventsDataSet()
- {
- IPubSubApplication application = CreateApplication();
- PubSubMethodHandlers handlers = CreateHandlers(application);
- var outputs = new List();
- var selectedField = new SimpleAttributeOperand
- {
- TypeDefinitionId = ObjectTypeIds.BaseEventType,
- BrowsePath = new ArrayOf(new[] { new QualifiedName(BrowseNames.EventId) }),
- AttributeId = Attributes.Value
- };
-
- ServiceResult result = handlers.OnAddPublishedEvents(
- BuildContext(),
- method: NewPublishedDataItemsMethod("items", "AddVariables"),
- inputArguments: BuildArray(
- Variant.From("events"),
- Variant.From(ObjectIds.Server),
- Variant.From(new ArrayOf(s_eventIdAlias)),
- Variant.From(Array.Empty()),
- Variant.FromStructure(new ArrayOf(
- new[] { selectedField })),
- Variant.From(new ExtensionObject(new ContentFilter()))),
- outputArguments: outputs);
-
- PubSubConfigurationDataType configuration = application.GetConfiguration();
- Assert.Multiple(() =>
- {
- Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
- Assert.That(outputs, Has.Count.EqualTo(3));
- Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue(
- out PublishedEventsDataType? _), Is.True);
- });
- }
-
- [Test]
- [TestSpec("9.1.4.3")]
- public void OnAddVariablesAndRemoveVariables_MutateFieldsAndBumpConfigurationVersion()
- {
- IPubSubApplication application = CreateApplication();
- PubSubMethodHandlers handlers = CreateHandlers(application);
- var addDataSetOutputs = new List();
- var variable = new PublishedVariableDataType
- {
- PublishedVariable = new NodeId(Variables.Server_ServerStatus_CurrentTime),
- AttributeId = Attributes.Value
- };
- handlers.OnAddPublishedDataItems(
- BuildContext(),
- method: NewPublishedDataItemsMethod("items", "RemoveVariables"),
- inputArguments: BuildArray(
- Variant.From("items"),
- Variant.From(new ArrayOf(s_currentTimeAlias)),
- Variant.From(Array.Empty()),
- Variant.FromStructure(new ArrayOf(
- new[] { variable }))),
- outputArguments: addDataSetOutputs);
-
- var addedVariable = new PublishedVariableDataType
- {
- PublishedVariable = new NodeId(Variables.Server_ServerStatus_StartTime),
- AttributeId = Attributes.Value
- };
- var addVariableOutputs = new List();
- ServiceResult addResult = handlers.OnAddVariables(
- BuildContext(),
- method: NewPublishedDataItemsMethod("items", "AddVariables"),
- inputArguments: BuildArray(
- addDataSetOutputs[1],
- Variant.From(new ArrayOf(s_startTimeAlias)),
- Variant.From(s_notPromotedField),
- Variant.FromStructure(new ArrayOf(
- new[] { addedVariable }))),
- outputArguments: addVariableOutputs);
- Assert.That(StatusCode.IsGood(addResult.StatusCode), Is.True);
- Assert.That(addVariableOutputs, Has.Count.EqualTo(2));
- var removeVariableOutputs = new List();
- ServiceResult removeResult = handlers.OnRemoveVariables(
- BuildContext(),
- method: NewPublishedDataItemsMethod("items", "RemoveVariables"),
- inputArguments: BuildArray(addVariableOutputs[0], Variant.From(new ArrayOf(s_firstVariableIndex))),
- outputArguments: removeVariableOutputs);
-
- PubSubConfigurationDataType configuration = application.GetConfiguration();
- Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue(
- out PublishedDataItemsDataType? items), Is.True);
- Assert.Multiple(() =>
- {
- Assert.That(StatusCode.IsGood(addResult.StatusCode), Is.True);
- Assert.That(StatusCode.IsGood(removeResult.StatusCode), Is.True);
- Assert.That(items!.PublishedData, Has.Count.EqualTo(1));
- Assert.That(configuration.PublishedDataSets[0].DataSetMetaData.ConfigurationVersion.MinorVersion,
- Is.GreaterThan(1u));
- });
- }
-
- [Test]
- [TestSpec("9.1.5")]
- public void OnAddDataSetFolder_ReturnsGoodWithNodeId()
- {
- PubSubMethodHandlers handlers = CreateHandlers();
- ArrayOf inputs = BuildArray(Variant.From("my-folder"));
- var outputs = new List();
- ServiceResult result = handlers.OnAddDataSetFolder(
- BuildContext(), method: null!, inputArguments: inputs, outputArguments: outputs);
- Assert.Multiple(() =>
- {
- Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
- Assert.That(outputs, Has.Count.EqualTo(1));
- });
- }
-
- private static PubSubMethodHandlers CreateHandlers()
- {
- return CreateHandlers(CreateApplication());
- }
-
- private static PubSubMethodHandlers CreateHandlers(IPubSubApplication app)
- {
- var options = new PubSubServerOptions
- {
- ExposeConfigurationMethods = true
- };
- return new PubSubMethodHandlers(
- app, null, options, NUnitTelemetryContext.Create());
- }
-
- private static IPubSubApplication CreateApplication()
- {
- return new PubSubApplicationBuilder(
- NUnitTelemetryContext.Create())
- .WithApplicationId("handler-mutation-tests")
- .UseConfiguration(new PubSubConfigurationDataType
- {
- Connections = [],
- PublishedDataSets = []
- })
- .UseAllStandardEncoders()
- .AddTransportFactory(new StubTransportFactory())
- .Build();
- }
-
- private static SystemContext BuildContext()
- {
- return new SystemContext(NUnitTelemetryContext.Create());
- }
-
- private static ArrayOf BuildArray(params Variant[] values)
- {
- return new ArrayOf(values);
- }
-
- private static MethodState NewPublishedDataItemsMethod(string dataSetName, string methodName)
- {
- var parent = new BaseObjectState(null)
- {
- NodeId = new NodeId($"pubsub:published-data-set:{dataSetName}", 0),
- BrowseName = new QualifiedName(dataSetName)
- };
- var method = new MethodState(parent)
- {
- NodeId = new NodeId($"pubsub:published-data-set:{dataSetName}:{methodName}", 0),
- BrowseName = new QualifiedName(methodName)
- };
- parent.AddChild(method);
- return method;
- }
-
- private static readonly string[] s_currentTimeAlias = ["CurrentTime"];
- private static readonly string[] s_eventIdAlias = ["EventId"];
- private static readonly string[] s_startTimeAlias = ["StartTime"];
- private static readonly bool[] s_notPromotedField = [false];
- private static readonly uint[] s_firstVariableIndex = [0u];
-
- private sealed class StubTransportFactory : IPubSubTransportFactory
- {
- public string TransportProfileUri => Profiles.PubSubUdpUadpTransport;
-
- public IPubSubTransport Create(
- PubSubConnectionDataType connection,
- ITelemetryContext telemetry,
- TimeProvider timeProvider)
- {
- _ = connection;
- _ = telemetry;
- _ = timeProvider;
- return new StubTransport();
- }
- }
-
- private sealed class StubTransport : IPubSubTransport
- {
- private bool m_isConnected;
-
- public string TransportProfileUri => Profiles.PubSubUdpUadpTransport;
-
- public PubSubTransportDirection Direction => PubSubTransportDirection.SendReceive;
-
- public bool IsConnected => m_isConnected;
-
- public event EventHandler? StateChanged
- {
- add { }
- remove { }
- }
-
- public ValueTask OpenAsync(CancellationToken cancellationToken = default)
- {
- _ = cancellationToken;
- m_isConnected = true;
- return default;
- }
-
- public ValueTask CloseAsync(CancellationToken cancellationToken = default)
- {
- _ = cancellationToken;
- m_isConnected = false;
- return default;
- }
-
- public ValueTask SendAsync(
- ReadOnlyMemory payload,
- string? topic = null,
- CancellationToken cancellationToken = default)
- {
- _ = payload;
- _ = topic;
- _ = cancellationToken;
- return default;
- }
-
- public IAsyncEnumerable ReceiveAsync(
- CancellationToken cancellationToken = default)
- {
- _ = cancellationToken;
- return TestAsyncEnumerable.Empty();
- }
-
- public ValueTask DisposeAsync()
- {
- m_isConnected = false;
- return default;
- }
- }
- }
-}
+/* ========================================================================
+ * 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 NUnit.Framework;
+using Opc.Ua.PubSub.Application;
+using Opc.Ua.PubSub.Tests;
+using Opc.Ua.PubSub.Transports;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.PubSub.Server.Tests
+{
+ [TestFixture]
+ [TestSpec("9.1.3.4", Summary = "AddConnection handler")]
+ [TestSpec("9.1.3.5", Summary = "RemoveConnection handler")]
+ [TestSpec("9.1.6", Summary = "Configuration methods")]
+ [TestSpec("9.1.4.3", Summary = "PublishedDataItemsType variable methods")]
+ [TestSpec("9.1.4.5", Summary = "DataSetFolderType dataset methods")]
+ public class PubSubMethodHandlersMutationTests
+ {
+ [Test]
+ [TestSpec("9.1.3.4")]
+ public void OnAddConnection_ValidInput_ReturnsGoodAndNodeId()
+ {
+ PubSubMethodHandlers handlers = CreateHandlers();
+ var connCfg = new PubSubConnectionDataType
+ {
+ Name = "handler-conn",
+ TransportProfileUri =
+ "http://opcfoundation.org/UA-Profile/Transport/pubsub-udp-uadp",
+ Address = new ExtensionObject(
+ new NetworkAddressUrlDataType { Url = "opc.udp://224.0.0.22:4840" })
+ };
+ ArrayOf inputs = BuildArray(Variant.From(new ExtensionObject(connCfg)));
+ var outputs = new List();
+ ServiceResult result = handlers.OnAddConnection(
+ BuildContext(), method: null!, inputArguments: inputs, outputArguments: outputs);
+ Assert.Multiple(() =>
+ {
+ Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
+ Assert.That(outputs, Has.Count.EqualTo(1));
+ Assert.That(outputs[0].TryGetValue(out NodeId id), Is.True);
+ Assert.That(id.IsNull, Is.False);
+ });
+ }
+
+ [Test]
+ [TestSpec("9.1.3.5")]
+ public void OnRemoveConnection_ValidInput_ReturnsGood()
+ {
+ PubSubMethodHandlers handlers = CreateHandlers();
+ var connCfg = new PubSubConnectionDataType
+ {
+ Name = "to-remove",
+ TransportProfileUri =
+ "http://opcfoundation.org/UA-Profile/Transport/pubsub-udp-uadp",
+ Address = new ExtensionObject(
+ new NetworkAddressUrlDataType { Url = "opc.udp://224.0.0.22:4840" })
+ };
+ ArrayOf addInputs = BuildArray(Variant.From(new ExtensionObject(connCfg)));
+ var addOutputs = new List();
+ handlers.OnAddConnection(
+ BuildContext(), method: null!, inputArguments: addInputs, outputArguments: addOutputs);
+ Assert.That(addOutputs[0].TryGetValue(out NodeId connId), Is.True);
+
+ ArrayOf removeInputs = BuildArray(Variant.From(connId));
+ var removeOutputs = new List();
+ ServiceResult result = handlers.OnRemoveConnection(
+ BuildContext(), method: null!, inputArguments: removeInputs,
+ outputArguments: removeOutputs);
+ Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
+ }
+
+ [Test]
+ [TestSpec("9.1.6")]
+ public void OnGetConfiguration_ReturnsGood()
+ {
+ PubSubMethodHandlers handlers = CreateHandlers();
+ var outputs = new List();
+ ServiceResult result = handlers.OnGetConfiguration(
+ BuildContext(), method: null!, inputArguments: default, outputArguments: outputs);
+ Assert.Multiple(() =>
+ {
+ Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
+ Assert.That(outputs, Has.Count.EqualTo(1));
+ });
+ }
+
+ [Test]
+ [TestSpec("9.1.6")]
+ public void OnSetConfiguration_ValidInput_ReturnsGood()
+ {
+ PubSubMethodHandlers handlers = CreateHandlers();
+ var cfg = new PubSubConfigurationDataType
+ {
+ Connections = [],
+ PublishedDataSets = []
+ };
+ ArrayOf inputs = BuildArray(Variant.From(new ExtensionObject(cfg)));
+ var outputs = new List();
+ ServiceResult result = handlers.OnSetConfiguration(
+ BuildContext(), method: null!, inputArguments: inputs, outputArguments: outputs);
+ Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
+ }
+
+ [Test]
+ [TestSpec("9.1.4.5")]
+ public void OnAddPublishedDataItems_RegistersPublishedDataItemsDataSet()
+ {
+ IPubSubApplication application = CreateApplication();
+ PubSubMethodHandlers handlers = CreateHandlers(application);
+ var outputs = new List();
+ var variable = new PublishedVariableDataType
+ {
+ PublishedVariable = new NodeId(Variables.Server_ServerStatus_CurrentTime),
+ AttributeId = Attributes.Value
+ };
+
+ ServiceResult result = handlers.OnAddPublishedDataItems(
+ BuildContext(),
+ method: null!,
+ inputArguments: BuildArray(
+ Variant.From("items"),
+ Variant.From(new ArrayOf(s_currentTimeAlias)),
+ Variant.From(Array.Empty()),
+ Variant.FromStructure(new ArrayOf(
+ new[] { variable }))),
+ outputArguments: outputs);
+
+ PubSubConfigurationDataType configuration = application.GetConfiguration();
+ Assert.Multiple(() =>
+ {
+ Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
+ Assert.That(outputs, Has.Count.EqualTo(3));
+ Assert.That(configuration.PublishedDataSets, Has.Count.EqualTo(1));
+ Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue(
+ out PublishedDataItemsDataType? _), Is.True);
+ });
+ }
+
+ [Test]
+ [TestSpec("9.1.4.5")]
+ public void OnAddPublishedDataItemsTemplate_UsesProvidedMetaData()
+ {
+ IPubSubApplication application = CreateApplication();
+ PubSubMethodHandlers handlers = CreateHandlers(application);
+ var outputs = new List();
+ var variable = new PublishedVariableDataType
+ {
+ PublishedVariable = new NodeId(Variables.Server_ServerStatus_CurrentTime),
+ AttributeId = Attributes.Value
+ };
+ var metaData = new DataSetMetaDataType
+ {
+ Name = "template",
+ Fields = new ArrayOf(new[]
+ {
+ new FieldMetaData
+ {
+ Name = "FromTemplate",
+ DataType = DataTypeIds.DateTime,
+ ValueRank = ValueRanks.Scalar
+ }
+ })
+ };
+
+ ServiceResult result = handlers.OnAddPublishedDataItemsTemplate(
+ BuildContext(),
+ method: null!,
+ inputArguments: BuildArray(
+ Variant.From("template"),
+ Variant.From(new ExtensionObject(metaData)),
+ Variant.FromStructure(new ArrayOf(
+ new[] { variable }))),
+ outputArguments: outputs);
+
+ PubSubConfigurationDataType configuration = application.GetConfiguration();
+ Assert.Multiple(() =>
+ {
+ Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
+ Assert.That(outputs, Has.Count.EqualTo(2));
+ Assert.That(configuration.PublishedDataSets[0].Name, Is.EqualTo("template"));
+ Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue(
+ out PublishedDataItemsDataType? _), Is.True);
+ });
+ }
+
+ [Test]
+ [TestSpec("9.1.4.5")]
+ public void OnAddPublishedEvents_RegistersPublishedEventsDataSet()
+ {
+ IPubSubApplication application = CreateApplication();
+ PubSubMethodHandlers handlers = CreateHandlers(application);
+ var outputs = new List();
+ var selectedField = new SimpleAttributeOperand
+ {
+ TypeDefinitionId = ObjectTypeIds.BaseEventType,
+ BrowsePath = new ArrayOf(new[] { new QualifiedName(BrowseNames.EventId) }),
+ AttributeId = Attributes.Value
+ };
+
+ ServiceResult result = handlers.OnAddPublishedEvents(
+ BuildContext(),
+ method: NewPublishedDataItemsMethod("items", "AddVariables"),
+ inputArguments: BuildArray(
+ Variant.From("events"),
+ Variant.From(ObjectIds.Server),
+ Variant.From(new ArrayOf(s_eventIdAlias)),
+ Variant.From(Array.Empty()),
+ Variant.FromStructure(new ArrayOf(
+ new[] { selectedField })),
+ Variant.From(new ExtensionObject(new ContentFilter()))),
+ outputArguments: outputs);
+
+ PubSubConfigurationDataType configuration = application.GetConfiguration();
+ Assert.Multiple(() =>
+ {
+ Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
+ Assert.That(outputs, Has.Count.EqualTo(2));
+ Assert.That(outputs[0].TryGetValue(out ExtensionObject configurationVersion), Is.True);
+ Assert.That(
+ configurationVersion.TryGetValue(out ConfigurationVersionDataType? _),
+ Is.True);
+ Assert.That(outputs[1].TryGetValue(out NodeId dataSetId), Is.True);
+ Assert.That(dataSetId.IsNull, Is.False);
+ Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue(
+ out PublishedEventsDataType? _), Is.True);
+ });
+ }
+
+ [Test]
+ [TestSpec("9.1.4.3")]
+ public void OnAddVariablesAndRemoveVariables_MutateFieldsAndBumpConfigurationVersion()
+ {
+ IPubSubApplication application = CreateApplication();
+ PubSubMethodHandlers handlers = CreateHandlers(application);
+ var addDataSetOutputs = new List();
+ var variable = new PublishedVariableDataType
+ {
+ PublishedVariable = new NodeId(Variables.Server_ServerStatus_CurrentTime),
+ AttributeId = Attributes.Value
+ };
+ handlers.OnAddPublishedDataItems(
+ BuildContext(),
+ method: NewPublishedDataItemsMethod("items", "RemoveVariables"),
+ inputArguments: BuildArray(
+ Variant.From("items"),
+ Variant.From(new ArrayOf(s_currentTimeAlias)),
+ Variant.From(Array.Empty()),
+ Variant.FromStructure(new ArrayOf(
+ new[] { variable }))),
+ outputArguments: addDataSetOutputs);
+
+ var addedVariable = new PublishedVariableDataType
+ {
+ PublishedVariable = new NodeId(Variables.Server_ServerStatus_StartTime),
+ AttributeId = Attributes.Value
+ };
+ var addVariableOutputs = new List();
+ ServiceResult addResult = handlers.OnAddVariables(
+ BuildContext(),
+ method: NewPublishedDataItemsMethod("items", "AddVariables"),
+ inputArguments: BuildArray(
+ addDataSetOutputs[1],
+ Variant.From(new ArrayOf(s_startTimeAlias)),
+ Variant.From(s_notPromotedField),
+ Variant.FromStructure(new ArrayOf(
+ new[] { addedVariable }))),
+ outputArguments: addVariableOutputs);
+ Assert.That(StatusCode.IsGood(addResult.StatusCode), Is.True);
+ Assert.That(addVariableOutputs, Has.Count.EqualTo(2));
+ var removeVariableOutputs = new List();
+ ServiceResult removeResult = handlers.OnRemoveVariables(
+ BuildContext(),
+ method: NewPublishedDataItemsMethod("items", "RemoveVariables"),
+ inputArguments: BuildArray(addVariableOutputs[0], Variant.From(new ArrayOf(s_firstVariableIndex))),
+ outputArguments: removeVariableOutputs);
+
+ PubSubConfigurationDataType configuration = application.GetConfiguration();
+ Assert.That(configuration.PublishedDataSets[0].DataSetSource.TryGetValue(
+ out PublishedDataItemsDataType? items), Is.True);
+ Assert.Multiple(() =>
+ {
+ Assert.That(StatusCode.IsGood(addResult.StatusCode), Is.True);
+ Assert.That(StatusCode.IsGood(removeResult.StatusCode), Is.True);
+ Assert.That(items!.PublishedData, Has.Count.EqualTo(1));
+ Assert.That(configuration.PublishedDataSets[0].DataSetMetaData.ConfigurationVersion.MinorVersion,
+ Is.GreaterThan(1u));
+ });
+ }
+
+ [Test]
+ [TestSpec("9.1.5")]
+ public void OnAddDataSetFolder_ReturnsGoodWithNodeId()
+ {
+ PubSubMethodHandlers handlers = CreateHandlers();
+ ArrayOf inputs = BuildArray(Variant.From("my-folder"));
+ var outputs = new List();
+ ServiceResult result = handlers.OnAddDataSetFolder(
+ BuildContext(), method: null!, inputArguments: inputs, outputArguments: outputs);
+ Assert.Multiple(() =>
+ {
+ Assert.That(StatusCode.IsGood(result.StatusCode), Is.True);
+ Assert.That(outputs, Has.Count.EqualTo(1));
+ });
+ }
+
+ private static PubSubMethodHandlers CreateHandlers()
+ {
+ return CreateHandlers(CreateApplication());
+ }
+
+ private static PubSubMethodHandlers CreateHandlers(IPubSubApplication app)
+ {
+ var options = new PubSubServerOptions
+ {
+ ExposeConfigurationMethods = true
+ };
+ return new PubSubMethodHandlers(
+ app, null, options, NUnitTelemetryContext.Create());
+ }
+
+ private static IPubSubApplication CreateApplication()
+ {
+ return new PubSubApplicationBuilder(
+ NUnitTelemetryContext.Create())
+ .WithApplicationId("handler-mutation-tests")
+ .UseConfiguration(new PubSubConfigurationDataType
+ {
+ Connections = [],
+ PublishedDataSets = []
+ })
+ .UseAllStandardEncoders()
+ .AddTransportFactory(new StubTransportFactory())
+ .Build();
+ }
+
+ private static SystemContext BuildContext()
+ {
+ return new SystemContext(NUnitTelemetryContext.Create());
+ }
+
+ private static ArrayOf BuildArray(params Variant[] values)
+ {
+ return new ArrayOf(values);
+ }
+
+ private static MethodState NewPublishedDataItemsMethod(string dataSetName, string methodName)
+ {
+ var parent = new BaseObjectState(null)
+ {
+ NodeId = new NodeId($"pubsub:published-data-set:{dataSetName}", 0),
+ BrowseName = new QualifiedName(dataSetName)
+ };
+ var method = new MethodState(parent)
+ {
+ NodeId = new NodeId($"pubsub:published-data-set:{dataSetName}:{methodName}", 0),
+ BrowseName = new QualifiedName(methodName)
+ };
+ parent.AddChild(method);
+ return method;
+ }
+
+ private static readonly string[] s_currentTimeAlias = ["CurrentTime"];
+ private static readonly string[] s_eventIdAlias = ["EventId"];
+ private static readonly string[] s_startTimeAlias = ["StartTime"];
+ private static readonly bool[] s_notPromotedField = [false];
+ private static readonly uint[] s_firstVariableIndex = [0u];
+
+ private sealed class StubTransportFactory : IPubSubTransportFactory
+ {
+ public string TransportProfileUri => Profiles.PubSubUdpUadpTransport;
+
+ public IPubSubTransport Create(
+ PubSubConnectionDataType connection,
+ ITelemetryContext telemetry,
+ TimeProvider timeProvider)
+ {
+ _ = connection;
+ _ = telemetry;
+ _ = timeProvider;
+ return new StubTransport();
+ }
+ }
+
+ private sealed class StubTransport : IPubSubTransport
+ {
+ private bool m_isConnected;
+
+ public string TransportProfileUri => Profiles.PubSubUdpUadpTransport;
+
+ public PubSubTransportDirection Direction => PubSubTransportDirection.SendReceive;
+
+ public bool IsConnected => m_isConnected;
+
+ public event EventHandler