/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Xml; using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Client.ComplexTypes; namespace Quickstarts { /// /// A client interface which holds an active session. /// The client handler may reconnect and the Session /// property may be updated during operation. /// public interface IUAClient { /// /// The session to use. /// ISession Session { get; } } /// /// Sample Session calls based on the reference server node model. /// public class ClientSamples { private const int kMaxSearchDepth = 128; public ClientSamples( ITelemetryContext telemetry, Action? validateResponse, ManualResetEvent? quitEvent = null, bool verbose = false) { m_telemetry = telemetry; m_logger = telemetry.CreateLogger(); m_validate = validateResponse; m_quitEvent = quitEvent; m_verbose = verbose; m_desiredEventFields = []; int eventIndexCounter = 0; m_desiredEventFields.Add( eventIndexCounter++, [QualifiedName.From(BrowseNames.Time)]); m_desiredEventFields.Add( eventIndexCounter++, [QualifiedName.From(BrowseNames.ActiveState)]); m_desiredEventFields.Add( eventIndexCounter++, [QualifiedName.From(BrowseNames.Message)]); m_desiredEventFields.Add( eventIndexCounter++, [QualifiedName.From(BrowseNames.LimitState), QualifiedName.From(BrowseNames.CurrentState)]); m_desiredEventFields.Add( eventIndexCounter++, [QualifiedName.From(BrowseNames.LimitState), QualifiedName.From(BrowseNames.LastTransition)]); } /// /// Read a list of nodes from Server /// public async Task ReadNodesAsync(ISession session, CancellationToken ct = default) { if (session == null || !session.Connected) { Console.WriteLine("Session not connected!"); return; } try { // build a list of nodes to be read ArrayOf nodesToRead = [ // Value of ServerStatus new ReadValueId { NodeId = VariableIds.Server_ServerStatus, AttributeId = Attributes.Value }, // BrowseName of ServerStatus_StartTime new ReadValueId { NodeId = VariableIds.Server_ServerStatus_StartTime, AttributeId = Attributes.BrowseName }, // Value of ServerStatus_StartTime new ReadValueId { NodeId = VariableIds.Server_ServerStatus_StartTime, AttributeId = Attributes.Value } ]; // Read the node attributes Console.WriteLine("Reading nodes..."); // Call Read Service ReadResponse response = await session.ReadAsync( null, 0, TimestampsToReturn.Both, nodesToRead, ct).ConfigureAwait(false); ArrayOf resultsValues = response.Results; ArrayOf diagnosticInfos = response.DiagnosticInfos; // Validate the results ValidateResponse(resultsValues, nodesToRead); // Display the results. foreach (DataValue result in resultsValues) { Console.WriteLine($"Read Value = {result.WrappedValue} , StatusCode = {result.StatusCode}"); } // Read Server NamespaceArray Console.WriteLine("Reading Value of NamespaceArray node..."); DataValue namespaceArray = await session.ReadValueAsync(VariableIds.Server_NamespaceArray, ct) .ConfigureAwait(false); // Display the result Console.WriteLine($"NamespaceArray Value = {namespaceArray}"); } catch (Exception ex) { // Log Error m_logger.LogError(ex, "Read Nodes Error."); } } /// /// Write a list of nodes to the Server. /// public async Task WriteNodesAsync(ISession session, CancellationToken ct = default) { if (session == null || !session.Connected) { Console.WriteLine("Session not connected!"); return; } try { // Write the configured nodes ArrayOf nodesToWrite = [ // Int32 Node - Objects\CTT\Scalar\Scalar_Static\Int32 new WriteValue { NodeId = NodeId.Parse("ns=2;s=Scalar_Static_Int32"), AttributeId = Attributes.Value, Value = new DataValue(Variant.From(100)) }, // Float Node - Objects\CTT\Scalar\Scalar_Static\Float new WriteValue { NodeId = NodeId.Parse("ns=2;s=Scalar_Static_Float"), AttributeId = Attributes.Value, Value = new DataValue(Variant.From(100.5f)) }, // String Node - Objects\CTT\Scalar\Scalar_Static\String new WriteValue { NodeId = NodeId.Parse("ns=2;s=Scalar_Static_String"), AttributeId = Attributes.Value, Value = new DataValue(Variant.From("String Test")) } ]; // Write the node attributes Console.WriteLine("Writing nodes..."); // Call Write Service WriteResponse response = await session.WriteAsync( null, nodesToWrite, ct).ConfigureAwait(false); ArrayOf results = response.Results; ArrayOf diagnosticInfos = response.DiagnosticInfos; // Validate the response ValidateResponse(results, nodesToWrite); // Display the results. Console.WriteLine("Write Results :"); foreach (StatusCode writeResult in results) { Console.WriteLine($" {writeResult}"); } } catch (Exception ex) { // Log Error m_logger.LogInformation(ex, "Write Nodes Error."); } } /// /// Browse Server nodes /// public async Task BrowseAsync(ISession session, CancellationToken ct = default) { if (session == null || !session.Connected) { Console.WriteLine("Session not connected!"); return; } try { // Create a Browser object var browser = new Browser(session) { // Set browse parameters BrowseDirection = BrowseDirection.Forward, NodeClassMask = (int)NodeClass.Object | (int)NodeClass.Variable, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true }; NodeId nodeToBrowse = ObjectIds.Server; // Call Browse service Console.WriteLine($"Browsing {nodeToBrowse} node..."); ArrayOf browseResults = await browser.BrowseAsync(nodeToBrowse, ct).ConfigureAwait(false); // Display the results Console.WriteLine($"Browse returned {browseResults.Count} results:"); foreach (ReferenceDescription result in browseResults) { Console.WriteLine($" DisplayName = {result.DisplayName.Text}, NodeClass = {result.NodeClass}"); } } catch (Exception ex) { // Log Error m_logger.LogError(ex, "Browse Error."); } } /// /// Call UA method /// public async Task CallMethodAsync(ISession session, CancellationToken ct = default) { if (session == null || !session.Connected) { Console.WriteLine("Session not connected!"); return; } try { // Define the UA Method to call // Parent node - Objects\CTT\Methods // Method node - Objects\CTT\Methods\Add var objectId = NodeId.Parse("ns=2;s=Methods"); var methodId = NodeId.Parse("ns=2;s=Methods_Add"); // Define the method parameters // Input argument requires a Float and an UInt32 value // Invoke Call service Console.WriteLine($"Calling UAMethod for node {methodId} ..."); ArrayOf outputArguments = await session.CallAsync( objectId, methodId, ct, (float)10.5, (uint)10).ConfigureAwait(false); // Display results Console.WriteLine($"Method call returned {outputArguments.Count} output argument(s):"); foreach (Variant outputArgument in outputArguments) { Console.WriteLine($" OutputValue = {outputArgument}"); } } catch (Exception ex) { m_logger.LogError(ex, "Method call error"); } } /// /// Call the Start method for Alarming to enable events /// public async Task EnableEventsAsync( ISession session, uint timeToRun, CancellationToken ct = default) { if (session == null || !session.Connected) { Console.WriteLine("Session not connected!"); return; } try { // Define the UA Method to call // Parent node - Objects\CTT\Alarms // Method node - Objects\CTT\Alarms\Start var objectId = NodeId.Parse("ns=7;s=Alarms"); var methodId = NodeId.Parse("ns=7;s=Alarms.Start"); // Define the method parameters // Input argument requires a Float and an UInt32 value // Invoke Call service Console.WriteLine($"Calling UAMethod for node {methodId} ..."); ArrayOf outputArguments = await session.CallAsync( objectId, methodId, ct, timeToRun).ConfigureAwait(false); // Display results Console.WriteLine($"Method call returned {outputArguments.Count} output argument(s):"); foreach (Variant outputArgument in outputArguments) { Console.WriteLine($" OutputValue = {outputArgument}"); } } catch (Exception ex) { m_logger.LogError(ex, "Method call error"); } } /// /// Create Subscription and MonitoredItems for DataChanges /// public async Task SubscribeToDataChangesAsync( ISession session, uint minLifeTime, bool enableDurableSubscriptions, CancellationToken ct = default) { bool isDurable = false; if (session == null || !session.Connected) { Console.WriteLine("Session not connected!"); return isDurable; } try { // Create a subscription for receiving data change notifications const int subscriptionPublishingInterval = 1000; const int itemSamplingInterval = 1000; uint queueSize = 10; uint lifetime = minLifeTime; if (enableDurableSubscriptions) { queueSize = 100; lifetime = 20; } // Define Subscription parameters var subscription = new Subscription(session.DefaultSubscription) { DisplayName = "Console ReferenceClient Subscription", PublishingEnabled = true, PublishingInterval = subscriptionPublishingInterval, LifetimeCount = 0, MinLifetimeInterval = lifetime, KeepAliveCount = 5 }; session.AddSubscription(subscription); // Create the subscription on Server side await subscription.CreateAsync(ct).ConfigureAwait(false); m_logger.LogInformation( "New Subscription created with SubscriptionId = {Id}, Sampling Interval {SamplingInterval}, Publishing Interval {PublishingInterval}.", subscription.Id, itemSamplingInterval, subscriptionPublishingInterval); if (enableDurableSubscriptions) { (bool success, uint revisedLifetimeInHours) = await subscription.SetSubscriptionDurableAsync(1, ct).ConfigureAwait(false); if (success) { isDurable = true; m_logger.LogInformation( "Subscription {SubscriptionId} is now durable, Revised Lifetime {Lifetime} in hours.", subscription.Id, revisedLifetimeInHours); } else { m_logger.LogInformation("Subscription {SubscriptionId} failed durable call", subscription.Id); } } // Create MonitoredItems for data changes (Reference Server) var intMonitoredItem = new MonitoredItem(subscription.DefaultItem) { // Int32 Node - Objects\CTT\Scalar\Simulation\Int32 StartNodeId = NodeId.Parse("ns=2;s=Scalar_Simulation_Int32"), AttributeId = Attributes.Value, DisplayName = "Int32 Variable", SamplingInterval = itemSamplingInterval, QueueSize = queueSize, DiscardOldest = true }; intMonitoredItem.Notification += OnMonitoredItemNotification; subscription.AddItem(intMonitoredItem); var floatMonitoredItem = new MonitoredItem(subscription.DefaultItem) { // Float Node - Objects\CTT\Scalar\Simulation\Float StartNodeId = NodeId.Parse("ns=2;s=Scalar_Simulation_Float"), AttributeId = Attributes.Value, DisplayName = "Float Variable", SamplingInterval = itemSamplingInterval, QueueSize = queueSize }; floatMonitoredItem.Notification += OnMonitoredItemNotification; subscription.AddItem(floatMonitoredItem); var stringMonitoredItem = new MonitoredItem(subscription.DefaultItem) { // String Node - Objects\CTT\Scalar\Simulation\String StartNodeId = NodeId.Parse("ns=2;s=Scalar_Simulation_String"), AttributeId = Attributes.Value, DisplayName = "String Variable", SamplingInterval = itemSamplingInterval, QueueSize = queueSize }; stringMonitoredItem.Notification += OnMonitoredItemNotification; subscription.AddItem(stringMonitoredItem); var eventMonitoredItem = new MonitoredItem(subscription.DefaultItem) { StartNodeId = ObjectIds.Server, AttributeId = Attributes.EventNotifier, DisplayName = "Event Variable", SamplingInterval = itemSamplingInterval, QueueSize = queueSize }; eventMonitoredItem.Notification += OnMonitoredItemEventNotification; var filter = new EventFilter(); var simpleAttributeOperands = new List(); foreach (ArrayOf desiredEventField in m_desiredEventFields.Values) { simpleAttributeOperands.Add( new SimpleAttributeOperand { AttributeId = Attributes.Value, TypeDefinitionId = ObjectTypeIds.BaseEventType, BrowsePath = desiredEventField }); } filter.SelectClauses = simpleAttributeOperands; var whereClause = new ContentFilter(); var existingEventType = new SimpleAttributeOperand { AttributeId = Attributes.Value, TypeDefinitionId = ObjectTypeIds.ExclusiveLevelAlarmType, BrowsePath = [QualifiedName.From("EventType")] }; var desiredEventType = new LiteralOperand { Value = Variant.From(ObjectTypeIds.ExclusiveLevelAlarmType) }; whereClause.Push( FilterOperator.Equals, Variant.FromStructure(existingEventType), Variant.FromStructure(desiredEventType)); filter.WhereClause = whereClause; eventMonitoredItem.Filter = filter; eventMonitoredItem.NodeClass = NodeClass.Object; subscription.AddItem(eventMonitoredItem); // Create the monitored items on Server side await subscription.ApplyChangesAsync(ct).ConfigureAwait(false); m_logger.LogInformation( "MonitoredItems created for SubscriptionId = {SubscriptionId}.", subscription.Id); } catch (Exception ex) { m_logger.LogError(ex, "Subscribe error"); } return isDurable; } /// /// Fetch all references and nodes with attributes except values from the server. /// /// The UAClient with a session to use. /// The node from which the hierarchical nodes are fetched. /// Iterate to fetch all nodes in the tree. /// Adds the root node to the result. /// Filters nodes from namespace 0 from the result. /// The list of nodes on the server. public async Task> FetchAllNodesNodeCacheAsync( IUAClient uaClient, NodeId startingNode, bool fetchTree = false, bool addRootNode = false, bool filterUATypes = true, bool clearNodeCache = true, CancellationToken ct = default) { var stopwatch = new Stopwatch(); var nodeDictionary = new Dictionary(); ArrayOf references = [ReferenceTypeIds.HierarchicalReferences]; ArrayOf nodesToBrowse = [startingNode]; // start stopwatch.Start(); if (clearNodeCache) { // clear NodeCache to fetch all nodes from server uaClient.Session.NodeCache.Clear(); await FetchReferenceIdTypesAsync(uaClient.Session, ct).ConfigureAwait(false); } // add root node if (addRootNode) { INode? rootNode = await uaClient.Session.NodeCache.FindAsync(startingNode, ct) .ConfigureAwait(false); if (rootNode == null) { m_logger.LogWarning("Root node {NodeId} not found, skipping", startingNode); } else { nodeDictionary[rootNode.NodeId] = rootNode; } } int searchDepth = 0; while (nodesToBrowse.Count > 0 && searchDepth < kMaxSearchDepth) { if (m_quitEvent?.WaitOne(0) == true) { m_logger.LogInformation("Browse aborted."); break; } searchDepth++; m_logger.LogInformation( "{Depth}: Find {Count} references after {Duration}ms", searchDepth, nodesToBrowse.Count, stopwatch.ElapsedMilliseconds); ArrayOf response = await uaClient .Session.NodeCache.FindReferencesAsync(nodesToBrowse, references, false, true, ct) .ConfigureAwait(false); var nextNodesToBrowse = new List(); int duplicates = 0; int leafNodes = 0; foreach (INode node in response) { if (!nodeDictionary.ContainsKey(node.NodeId)) { if (fetchTree) { bool leafNode = false; // no need to browse property types if (node is VariableNode variableNode) { IReference? hasTypeDefinition = variableNode.ReferenceTable .FirstOrDefault(r => r.ReferenceTypeId .Equals(ReferenceTypeIds.HasTypeDefinition)); if (hasTypeDefinition != null) { leafNode = hasTypeDefinition.TargetId == VariableTypeIds .PropertyType; } } if (!leafNode) { nextNodesToBrowse.Add(node.NodeId); } else { leafNodes++; } } if (filterUATypes) { if (node.NodeId.NamespaceIndex != 0) { // filter out default namespace nodeDictionary[node.NodeId] = node; } } else { nodeDictionary[node.NodeId] = node; } } else { duplicates++; } } if (duplicates > 0) { m_logger.LogInformation("Find References {Count} duplicate nodes were ignored", duplicates); } if (leafNodes > 0) { m_logger.LogInformation("Find References {Count} leaf nodes were ignored", leafNodes); } nodesToBrowse = nextNodesToBrowse; } stopwatch.Stop(); m_logger.LogInformation( "FetchAllNodesNodeCache found {Count} nodes in {Duration}ms", nodeDictionary.Count, stopwatch.ElapsedMilliseconds); var result = nodeDictionary.Values.ToList(); result.Sort((x, y) => x.NodeId.CompareTo(y.NodeId)); if (m_verbose) { foreach (INode node in result) { m_logger.LogInformation( "NodeId {NodeId} {NodeClass} {BrowseName}", node.NodeId, node.NodeClass, node.BrowseName); } } return result; } /// /// Browse full address space using the ManagedBrowseMethod, which /// will take care of not sending to many nodes to the server, /// calling BrowseNext and dealing with the status codes /// BadNoContinuationPoint and BadInvalidContinuationPoint. /// /// The UAClient with a session to use. /// The node where the browse operation starts. /// An optional BrowseDescription to use. public async Task> ManagedBrowseFullAddressSpaceAsync( IUAClient uaClient, NodeId startingNode = default, BrowseDescription? browseDescription = null, CancellationToken ct = default) { ContinuationPointPolicy policyBackup = uaClient.Session.ContinuationPointPolicy; uaClient.Session.ContinuationPointPolicy = ContinuationPointPolicy.Default; var stopWatch = new Stopwatch(); stopWatch.Start(); BrowseDirection browseDirection = BrowseDirection.Forward; NodeId referenceTypeId = ReferenceTypeIds.HierarchicalReferences; bool includeSubtypes = true; uint nodeClassMask = 0; if (browseDescription != null) { startingNode = browseDescription.NodeId; browseDirection = browseDescription.BrowseDirection; referenceTypeId = browseDescription.ReferenceTypeId; includeSubtypes = browseDescription.IncludeSubtypes; nodeClassMask = browseDescription.NodeClassMask; if (browseDescription.ResultMask != (uint)BrowseResultMask.All) { m_logger.LogWarning( "Setting the BrowseResultMask is not supported by the " + "ManagedBrowse method. Using '{BrowseResultMask}' instead of " + "the mask {BrowseDescriptionResultMask} for the result mask", BrowseResultMask.All, browseDescription.ResultMask); } } ArrayOf nodesToBrowse = [startingNode.IsNull ? ObjectIds.RootFolder : startingNode]; const int kMaxReferencesPerNode = 1000; // Browse var referenceDescriptions = new Dictionary(); int searchDepth = 0; uint maxNodesPerBrowse = uaClient.Session.OperationLimits.MaxNodesPerBrowse; var allReferenceDescriptions = new List>(); var newReferenceDescriptions = new List>(); var allServiceResults = new List(); while (nodesToBrowse.Count != 0 && searchDepth < kMaxSearchDepth) { searchDepth++; m_logger.LogInformation( "{Depth}: Browse {Count} nodes after {Duration}ms", searchDepth, nodesToBrowse.Count, stopWatch.ElapsedMilliseconds); const bool repeatBrowse = false; do { if (m_quitEvent?.WaitOne(0) == true) { m_logger.LogInformation("Browse aborted."); break; } try { // the resultMask defaults to "all" // maybe the API should be extended to // support it. But that will then also be // necessary for BrowseAsync (ArrayOf> descriptions, ArrayOf errors) = await uaClient .Session.ManagedBrowseAsync( null, null, nodesToBrowse, kMaxReferencesPerNode, browseDirection, referenceTypeId, true, nodeClassMask, ct) .ConfigureAwait(false); allReferenceDescriptions.AddRange(descriptions); newReferenceDescriptions.AddRange(descriptions); allServiceResults.AddRange(errors); } catch (ServiceResultException sre) { // the maximum number of nodes per browse is // set in the ManagedBrowse from the configuration // and cannot be influenced from the outside. // if that's desired it would be necessary to provide // an additional parameter to the method. m_logger.LogError(sre, "Browse error"); throw; } } while (repeatBrowse); // Build browse request for next level var nodesForNextManagedBrowse = new List(); int duplicates = 0; foreach (ArrayOf referenceCollection in newReferenceDescriptions) { foreach (ReferenceDescription reference in referenceCollection) { if (!referenceDescriptions.ContainsKey(reference.NodeId)) { referenceDescriptions[reference.NodeId] = reference; if (!reference.ReferenceTypeId.Equals(ReferenceTypeIds.HasProperty)) { nodesForNextManagedBrowse.Add( ExpandedNodeId.ToNodeId( reference.NodeId, uaClient.Session.NamespaceUris)); } } else { duplicates++; } } } newReferenceDescriptions.Clear(); nodesToBrowse = nodesForNextManagedBrowse.ToArrayOf(); if (duplicates > 0) { m_logger.LogInformation( "Managed Browse Result {Count} duplicate nodes were ignored.", duplicates); } } stopWatch.Stop(); var result = new List(referenceDescriptions.Values); result.Sort((x, y) => x.NodeId.CompareTo(y.NodeId)); m_logger.LogInformation( "ManagedBrowseFullAddressSpace found {Count} references on server in {Duration}ms.", result.Count, stopWatch.ElapsedMilliseconds); if (m_verbose) { foreach (ReferenceDescription reference in result) { m_logger.LogInformation( "NodeId {NodeId} {NodeClass} {BrowseName}", reference.NodeId, reference.NodeClass, reference.BrowseName); } } uaClient.Session.ContinuationPointPolicy = policyBackup; return result; } /// /// Browse full address space. /// /// The UAClient with a session to use. /// The node where the browse operation starts. /// An optional BrowseDescription to use. public async Task> BrowseFullAddressSpaceAsync( IUAClient uaClient, NodeId startingNode = default, BrowseDescription? browseDescription = null, CancellationToken ct = default) { var stopWatch = new Stopwatch(); stopWatch.Start(); // Browse template const int kMaxReferencesPerNode = 1000; BrowseDescription browseTemplate = browseDescription ?? new BrowseDescription { NodeId = startingNode.IsNull ? ObjectIds.RootFolder : startingNode, BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, NodeClassMask = 0, ResultMask = (uint)BrowseResultMask.All }; ArrayOf browseDescriptionCollection = CreateBrowseDescriptionCollectionFromNodeId( [startingNode.IsNull ? ObjectIds.RootFolder : startingNode], browseTemplate); // Browse var referenceDescriptions = new Dictionary(); int searchDepth = 0; uint maxNodesPerBrowse = uaClient.Session.OperationLimits.MaxNodesPerBrowse; while (browseDescriptionCollection.Count > 0 && searchDepth < kMaxSearchDepth) { searchDepth++; m_logger.LogInformation( "{Depth}: Browse {Count} nodes after {Duration}ms", searchDepth, browseDescriptionCollection.Count, stopWatch.ElapsedMilliseconds); var allBrowseResults = new List(); bool repeatBrowse; ArrayOf browseResultCollection = default; var unprocessedOperations = new List(); ArrayOf diagnosticsInfoCollection; do { if (m_quitEvent?.WaitOne(0) == true) { m_logger.LogInformation("Browse aborted."); break; } if (maxNodesPerBrowse >= browseDescriptionCollection.Count) { maxNodesPerBrowse = 0; // Do not slice, take all } ArrayOf browseCollection = maxNodesPerBrowse == 0 ? browseDescriptionCollection : browseDescriptionCollection[..(int)maxNodesPerBrowse]; repeatBrowse = false; try { BrowseResponse browseResponse = await uaClient .Session.BrowseAsync( null, null, kMaxReferencesPerNode, browseCollection, ct) .ConfigureAwait(false); browseResultCollection = browseResponse.Results; diagnosticsInfoCollection = browseResponse.DiagnosticInfos; ClientBase.ValidateResponse(browseResultCollection, browseCollection); ClientBase.ValidateDiagnosticInfos( diagnosticsInfoCollection, browseCollection); // separate unprocessed nodes for later int ii = 0; foreach (BrowseResult browseResult in browseResultCollection) { // check for error. StatusCode statusCode = browseResult.StatusCode; if (StatusCode.IsBad(statusCode)) { // this error indicates that the server does not have enough simultaneously active // continuation points. This request will need to be resent after the other operations // have been completed and their continuation points released. if (statusCode == StatusCodes.BadNoContinuationPoints) { unprocessedOperations.Add(browseCollection[ii++]); continue; } } // save results. allBrowseResults.Add(browseResult); ii++; } } catch (ServiceResultException sre) { if (sre.StatusCode == StatusCodes.BadEncodingLimitsExceeded || sre.StatusCode == StatusCodes.BadResponseTooLarge) { // try to address by overriding operation limit maxNodesPerBrowse = maxNodesPerBrowse == 0 ? (uint)browseCollection.Count / 2 : maxNodesPerBrowse / 2; repeatBrowse = true; } else { m_logger.LogError(sre, "Browse error."); throw; } } } while (repeatBrowse); browseDescriptionCollection = maxNodesPerBrowse == 0 ? default : browseDescriptionCollection[(int)maxNodesPerBrowse..]; // Browse next ArrayOf continuationPoints = PrepareBrowseNext(browseResultCollection); while (continuationPoints.Count > 0) { if (m_quitEvent?.WaitOne(0) == true) { m_logger.LogInformation("Browse aborted."); } m_logger.LogInformation("BrowseNext {Count} continuation points.", continuationPoints.Count); BrowseNextResponse browseNextResult = await uaClient .Session.BrowseNextAsync(null, false, continuationPoints, ct) .ConfigureAwait(false); ArrayOf browseNextResultCollection = browseNextResult.Results; diagnosticsInfoCollection = browseNextResult.DiagnosticInfos; ClientBase.ValidateResponse(browseNextResultCollection, continuationPoints); ClientBase.ValidateDiagnosticInfos( diagnosticsInfoCollection, continuationPoints); allBrowseResults.AddRange(browseNextResultCollection); continuationPoints = PrepareBrowseNext(browseNextResultCollection); } // Build browse request for next level var browseTable = new List(); int duplicates = 0; foreach (BrowseResult browseResult in allBrowseResults) { foreach (ReferenceDescription reference in browseResult.References) { if (!referenceDescriptions.ContainsKey(reference.NodeId)) { referenceDescriptions[reference.NodeId] = reference; if (reference.ReferenceTypeId != ReferenceTypeIds.HasProperty) { browseTable.Add( ExpandedNodeId.ToNodeId( reference.NodeId, uaClient.Session.NamespaceUris)); } } else { duplicates++; } } } if (duplicates > 0) { m_logger.LogInformation("Browse Result {Count} duplicate nodes were ignored.", duplicates); } browseDescriptionCollection = ArrayOf.Combine( browseDescriptionCollection, CreateBrowseDescriptionCollectionFromNodeId(browseTable, browseTemplate), unprocessedOperations); // add unprocessed nodes if any } stopWatch.Stop(); var result = new List(referenceDescriptions.Values); result.Sort((x, y) => x.NodeId.CompareTo(y.NodeId)); m_logger.LogInformation( "BrowseFullAddressSpace found {Count} references on server in {Duration}ms.", referenceDescriptions.Count, stopWatch.ElapsedMilliseconds); if (m_verbose) { foreach (ReferenceDescription reference in result) { m_logger.LogInformation( "NodeId {NodeId} {NodeClass} {BrowseName}", reference.NodeId, reference.NodeClass, reference.BrowseName); } } return result; } /// /// Loads the custom type system of the server in the session. /// /// /// Outputs elapsed time information for perf testing and lists all /// types that were successfully added to the session encodeable type factory. /// /// public async Task LoadTypeSystemAsync(ComplexTypeSystem complexTypeSystem, CancellationToken ct = default) { m_logger.LogInformation("Load the server type system."); var stopWatch = new Stopwatch(); stopWatch.Start(); bool loaded = await complexTypeSystem.LoadAsync(throwOnError: true, ct: ct).ConfigureAwait(false); stopWatch.Stop(); m_logger.LogInformation( "Loaded {Count} types took {Duration}ms.", complexTypeSystem.GetDefinedTypes().Count, stopWatch.ElapsedMilliseconds); if (!loaded) { throw new ServiceResultException( StatusCodes.BadTypeMismatch, "ComplexTypeSystem.LoadAsync did not load all custom types."); } if (m_verbose) { m_logger.LogInformation("Custom types defined for this session:"); foreach (XmlQualifiedName type in complexTypeSystem.GetDefinedTypes()) { m_logger.LogInformation("{Namespace}.{TypeName}", type.Namespace, type.Name); } m_logger.LogInformation( "Loaded {Count} dictionaries:", complexTypeSystem.DataTypeSystem.Count); foreach (KeyValuePair dictionary in complexTypeSystem .DataTypeSystem) { m_logger.LogInformation(" + {DictionaryName}", dictionary.Value.Name); foreach (KeyValuePair type in dictionary.Value.DataTypes) { m_logger.LogInformation(" -- {NodeId}:{BrowseName}", type.Key, type.Value); } } } } /// /// Read all ReferenceTypeIds from the server that are not known by the client. /// To reduce the number of calls due to traversal call pyramid, start with all /// known reference types to reduce the number of FetchReferences/FetchNodes calls. /// /// /// The NodeCache needs this information to function properly with subtypes /// of hierarchical calls. /// /// The session to use private static Task FetchReferenceIdTypesAsync( ISession session, CancellationToken ct = default) { NamespaceTable namespaceUris = session.NamespaceUris; var referenceTypes = ReferenceTypeIds.Identifiers .Select(nodeId => NodeId.ToExpandedNodeId(nodeId, namespaceUris)) .ToArrayOf(); return session.FetchTypeTreeAsync(referenceTypes, ct); } /// /// Output all values as JSON. /// public async Task> ReadAllValuesAsync( IUAClient uaClient, ArrayOf variableIds, CancellationToken ct = default) { bool retrySingleRead = false; List? values = null; List? errors = null; do { try { if (retrySingleRead) { values = []; errors = []; foreach (NodeId variableId in variableIds.ToList()) { try { m_logger.LogInformation("Read {NodeId}", variableId); DataValue value = await uaClient .Session.ReadValueAsync(variableId, ct) .ConfigureAwait(false); values.Add(value); errors.Add(value.StatusCode); if (ServiceResult.IsNotBad(value.StatusCode)) { string valueString = FormatValueAsJson( uaClient.Session.MessageContext, variableId.ToString(), value, JsonEncoderOptions.Compact); m_logger.LogInformation("{Value}", valueString); } else { m_logger.LogInformation("Error: {StatusCode}", value.StatusCode); } } catch (ServiceResultException sre) { m_logger.LogError(sre, "Error"); values.Add(new DataValue(sre.StatusCode)); errors.Add(sre.Result); } } } else { (ArrayOf valueList, ArrayOf errorList) = await uaClient.Session.ReadValuesAsync(variableIds, ct) .ConfigureAwait(false); values = valueList.ToList(); errors = errorList.ToList(); int ii = 0; foreach (DataValue value in values) { if (ServiceResult.IsNotBad(errors[ii])) { string valueString = FormatValueAsJson( uaClient.Session.MessageContext, variableIds[ii].ToString(), value, JsonEncoderOptions.Compact); m_logger.LogInformation("{Value}", valueString); } else { m_logger.LogInformation("Error: {StatusCode}", value.StatusCode); } ii++; } } retrySingleRead = false; } catch (ServiceResultException sre) when (sre.StatusCode == StatusCodes .BadEncodingLimitsExceeded) { m_logger.LogInformation("Retry to read the values due to error: {Error}", sre.Message); retrySingleRead = !retrySingleRead; } } while (retrySingleRead); return ResultSet.From(values!, errors!); } /// /// Subscribe to all variables in the list. /// /// The UAClient with a session to use. /// The variables to subscribe. public async Task SubscribeAllValuesAsync( IUAClient uaClient, ArrayOf variableIds, int samplingInterval, int publishingInterval, uint queueSize, uint lifetimeCount, uint keepAliveCount, CancellationToken ct = default) { if (uaClient.Session == null || !uaClient.Session.Connected) { m_logger.LogInformation("Session not connected!"); return; } try { // Create a subscription for receiving data change notifications ISession session = uaClient.Session; // test for deferred ack of sequence numbers session.PublishSequenceNumbersToAcknowledge += DeferSubscriptionAcknowledge; // set a minimum amount of three publish requests per session session.MinPublishRequestCount = 3; // Define Subscription parameters var subscription = new Subscription(session.DefaultSubscription) { DisplayName = "Console ReferenceClient Subscription", PublishingEnabled = true, PublishingInterval = publishingInterval, LifetimeCount = lifetimeCount, KeepAliveCount = keepAliveCount, SequentialPublishing = true, RepublishAfterTransfer = true, DisableMonitoredItemCache = true, MaxNotificationsPerPublish = 1000, MinLifetimeInterval = (uint)session.SessionTimeout, FastDataChangeCallback = FastDataChangeNotification, FastKeepAliveCallback = FastKeepAliveNotification }; session.AddSubscription(subscription); // Create the subscription on Server side await subscription.CreateAsync(ct).ConfigureAwait(false); m_logger.LogInformation( "New Subscription created with SubscriptionId = {SubscriptionId}.", subscription.Id); // Create MonitoredItems for data changes foreach (Node item in variableIds) { var monitoredItem = new MonitoredItem(subscription.DefaultItem) { StartNodeId = item.NodeId, AttributeId = Attributes.Value, SamplingInterval = samplingInterval, DisplayName = item.DisplayName.Text ?? item.BrowseName.Name ?? "unknown", QueueSize = queueSize, DiscardOldest = true, MonitoringMode = MonitoringMode.Reporting }; subscription.AddItem(monitoredItem); if (subscription.CurrentKeepAliveCount > 1000) { break; } } // Create the monitored items on Server side await subscription.ApplyChangesAsync(ct).ConfigureAwait(false); m_logger.LogInformation( "MonitoredItems {Count} created for SubscriptionId = {SubscriptionId}.", subscription.MonitoredItemCount, subscription.Id); } catch (Exception ex) { m_logger.LogError(ex, "Subscribe error"); } } /// /// Create a prettified JSON string of a DataValue. /// /// The key of the Json value. /// The DataValue. /// Use reversible encoding. public static string FormatValueAsJson( IServiceMessageContext messageContext, string name, DataValue value, JsonEncoderOptions? jsonEncodingType = null) { string textbuffer; using (var jsonEncoder = new JsonEncoder(messageContext, jsonEncodingType)) { jsonEncoder.WriteDataValue(name, value); textbuffer = jsonEncoder.CloseAndReturnText(); } using var doc = JsonDocument.Parse(textbuffer); using var stream = new MemoryStream(); using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true })) { doc.WriteTo(writer); } return Encoding.UTF8.GetString(stream.ToArray()); } /// /// The fast keep alive notification callback. /// private void FastKeepAliveNotification( Subscription subscription, NotificationData notification) { try { m_logger.LogInformation( "Keep Alive : Id={SubscriptionId} PublishTime={PublishTime} SequenceNumber={SequenceNumber}.", subscription.Id, notification.PublishTime, notification.SequenceNumber); } catch (Exception ex) { m_logger.LogError(ex, "FastKeepAliveNotification error"); } } /// /// The fast data change notification callback. /// private void FastDataChangeNotification( Subscription subscription, DataChangeNotification notification, ArrayOf stringTable) { try { m_logger.LogInformation( "Notification: Id={SubscriptionId} PublishTime={PublishTime} SequenceNumber={SequenceNumber} Items={Count}.", subscription.Id, notification.PublishTime, notification.SequenceNumber, notification.MonitoredItems.Count); } catch (Exception ex) { m_logger.LogError(ex, "FastDataChangeNotification error"); } } /// /// Handle DataChange notifications from Server /// private void OnMonitoredItemNotification( MonitoredItem monitoredItem, MonitoredItemNotificationEventArgs e) { try { // Log MonitoredItem Notification event if (e.NotificationValue is not MonitoredItemNotification notification) { m_logger.LogWarning( "Unexpected notification type: {Type}", e.NotificationValue?.GetType().Name ?? "null"); return; } DateTime localTime = notification.Value.SourceTimestamp.ToLocalTime(); m_logger.LogInformation( "Notification: {SequenceNumber} \"{NodeId}\" and Value = {Value} at [{CurrentTime}].", notification.Message.SequenceNumber, monitoredItem.ResolvedNodeId, notification.Value, localTime.ToLongTimeString()); } catch (Exception ex) { m_logger.LogError(ex, "OnMonitoredItemNotification error"); } } /// /// Handle Requested Event notifications from Server /// private void OnMonitoredItemEventNotification( MonitoredItem monitoredItem, MonitoredItemNotificationEventArgs e) { try { // Log MonitoredItem Notification event if (e.NotificationValue is not EventFieldList notification) { m_logger.LogWarning( "Unexpected event notification type: {Type}", e.NotificationValue?.GetType().Name ?? "null"); return; } foreach (KeyValuePair> entry in m_desiredEventFields) { Variant field = notification.EventFields[entry.Key]; if (field.TypeInfo.BuiltInType != BuiltInType.Null) { var fieldPath = new StringBuilder(); int lastIndex = entry.Value.Count - 1; for (int index = 0; index < entry.Value.Count; index++) { fieldPath.Append(entry.Value[index].Name); if (index < lastIndex) { fieldPath.Append('.'); } } string fieldName = fieldPath.ToString(); if (fieldName.Equals("Time", StringComparison.Ordinal)) { try { DateTimeUtc currentTime = field.GetDateTime(); TimeSpan timeSpan = currentTime - m_lastEventTime; m_lastEventTime = currentTime; m_processedEvents++; if (m_processedEvents > 1) { m_logger.LogInformation( "Event Received - total count = {Count}, time since last event = {TimeBetweenEvents} seconds", m_processedEvents, timeSpan.Seconds); } else { m_logger.LogInformation( "Event Received - total count = {Count}", m_processedEvents); } } catch (Exception ex) { m_logger.LogError(ex, "Unexpected error retrieving Event Time Field Value"); } } m_logger.LogInformation( "\tField [{Index}] \"{Name}\" = [{Value}]", entry.Key, fieldName, field); } } } catch (Exception ex) { m_logger.LogError(ex, "OnMonitoredItemEventNotification error"); } } /// /// Event handler to defer publish response sequence number acknowledge. /// private static void DeferSubscriptionAcknowledge( ISession session, PublishSequenceNumbersToAcknowledgeEventArgs e) { // for testing keep the latest sequence numbers for a while const int ackDelay = 5; if (e.AcknowledgementsToSend.Count > 0) { // defer latest sequence numbers List deferredItems = [.. e .AcknowledgementsToSend.OrderByDescending(s => s.SequenceNumber) .Take(ackDelay)]; e.DeferredAcknowledgementsToSend.AddRange(deferredItems); foreach (SubscriptionAcknowledgement deferredItem in deferredItems) { e.AcknowledgementsToSend.Remove(deferredItem); } } } /// /// Create a browse description from a node id collection. /// /// The node id collection. /// The template for the browse description for each node id. private static ArrayOf CreateBrowseDescriptionCollectionFromNodeId( ArrayOf nodeIdCollection, BrowseDescription template) { var browseDescriptionCollection = new List(); foreach (NodeId nodeId in nodeIdCollection) { BrowseDescription browseDescription = CoreUtils.Clone(template)!; browseDescription.NodeId = nodeId; browseDescriptionCollection.Add(browseDescription); } return browseDescriptionCollection; } /// /// Exports nodes to a NodeSet2 XML file. /// /// The session to use for exporting. /// The list of nodes to export. /// The path where the NodeSet2 XML file will be saved. public void ExportNodesToNodeSet2(ISession session, IList nodes, string filePath) { m_logger.LogInformation("Exporting {Count} nodes to {FilePath}...", nodes.Count, filePath); var stopwatch = new Stopwatch(); stopwatch.Start(); using var outputStream = new FileStream(filePath, FileMode.Create); var systemContext = new SystemContext(m_telemetry) { NamespaceUris = session.NamespaceUris, ServerUris = session.ServerUris }; CoreClientUtils.ExportNodesToNodeSet2(systemContext, nodes, outputStream); stopwatch.Stop(); m_logger.LogInformation( "Exported {Count} nodes to {FilePath} in {Duration}ms", nodes.Count, filePath, stopwatch.ElapsedMilliseconds); } /// /// Exports nodes to separate NodeSet2 XML files, one per namespace. /// /// /// When is supplied and non-empty, only nodes whose /// namespace URI is in the set are exported. Otherwise, all namespaces except the OPC UA /// base namespace () are exported, which includes any /// companion specifications hosted by the server. /// /// The session to use for exporting. /// The list of nodes to export. /// The directory where NodeSet2 XML files will be saved. /// /// Optional set of namespace URIs to include. When null or empty, all namespaces except /// the OPC UA base namespace are exported. /// /// Optional cancellation token. /// A dictionary mapping namespace URI to the file path of the exported NodeSet2 file. /// Thrown when session, nodes, or outputDirectory is null. /// Thrown when outputDirectory is empty or whitespace. public async Task> ExportNodesToNodeSet2PerNamespaceAsync( ISession session, IList nodes, string outputDirectory, IReadOnlyCollection? targetNamespaces = null, CancellationToken cancellationToken = default) { if (session == null) { throw new ArgumentNullException(nameof(session)); } if (nodes == null) { throw new ArgumentNullException(nameof(nodes)); } if (string.IsNullOrWhiteSpace(outputDirectory)) { throw new ArgumentException("Value cannot be null or whitespace.", nameof(outputDirectory)); } HashSet? targetSet = targetNamespaces is { Count: > 0 } ? new HashSet(targetNamespaces, StringComparer.OrdinalIgnoreCase) : null; m_logger.LogInformation( "Exporting {Count} nodes to separate NodeSet2 files per namespace in {Directory} (filter: {Filter})...", nodes.Count, outputDirectory, targetSet != null ? string.Join(",", targetSet) : "all non-OPC-UA-base"); var stopwatch = Stopwatch.StartNew(); // Ensure output directory exists Directory.CreateDirectory(outputDirectory); // Group nodes by namespace, applying the requested filter. var nodesByNamespace = nodes .Where(node => node.NodeId.NamespaceIndex > 0) // Skip namespace 0 (OPC UA base) .GroupBy(node => node.NodeId.NamespaceIndex) .Where(group => { string? namespaceUri = session.NamespaceUris.GetString(group.Key); if (string.IsNullOrEmpty(namespaceUri)) { return false; } if (targetSet != null) { // Caller asked for a specific set of namespaces. namespaceUri is non-null // after !IsNullOrEmpty (NotNullWhen attribute not available on net48). return targetSet.Contains(namespaceUri!); } // Default: exclude only the OPC UA base namespace; include companion specs. return !string.Equals(namespaceUri, Namespaces.OpcUa, StringComparison.OrdinalIgnoreCase); }) .ToDictionary( group => group.Key, group => group.ToList()); var exportedFiles = new Dictionary(); // Export each namespace to its own file foreach (KeyValuePair> kvp in nodesByNamespace) { cancellationToken.ThrowIfCancellationRequested(); // The Where clause above filtered out null/empty namespace URIs. string namespaceUri = session.NamespaceUris.GetString(kvp.Key)!; // Create a safe filename from the namespace URI string fileName = CreateSafeFileName(namespaceUri, kvp.Key); string filePath = Path.Combine(outputDirectory, fileName); m_logger.LogInformation( "Exporting namespace {NamespaceIndex} ({NamespaceUri}): {Count} nodes to {FilePath}", kvp.Key, namespaceUri, kvp.Value.Count, filePath); await Task.Run(() => { using var outputStream = new FileStream(filePath, FileMode.Create); var systemContext = new SystemContext(m_telemetry) { NamespaceUris = session.NamespaceUris, ServerUris = session.ServerUris }; CoreClientUtils.ExportNodesToNodeSet2(systemContext, kvp.Value, outputStream, NodeSetExportOptions.Complete); }, cancellationToken).ConfigureAwait(false); exportedFiles[namespaceUri] = filePath; } stopwatch.Stop(); m_logger.LogInformation( "Exported {NamespaceCount} namespaces ({NodeCount} total nodes) in {Duration}ms", exportedFiles.Count, nodes.Count, stopwatch.ElapsedMilliseconds); return exportedFiles; } /// /// Creates a safe filename from a namespace URI. /// /// The namespace URI. /// The namespace index (used as fallback). /// A safe filename for the NodeSet2 export. private static string CreateSafeFileName(string namespaceUri, ushort namespaceIndex) { // Extract meaningful part from URI string fileName = namespaceUri .Replace("http://", string.Empty, StringComparison.OrdinalIgnoreCase) .Replace("https://", string.Empty, StringComparison.OrdinalIgnoreCase) .Replace("urn:", string.Empty, StringComparison.OrdinalIgnoreCase); // Replace invalid filename characters foreach (char c in Path.GetInvalidFileNameChars()) { fileName = fileName.Replace(c, '_'); } // Additional cleanup for common URI characters fileName = fileName .Replace('/', '_') .Replace('\\', '_') .Replace(':', '_') .TrimEnd('_'); // Limit length and ensure uniqueness with namespace index if (fileName.Length > 200) { fileName = fileName[..200]; } return $"{fileName}_ns{namespaceIndex}.xml"; } /// /// Create the continuation point collection from the browse result /// collection for the BrowseNext service. /// /// The browse result collection to use. /// The collection of continuation points for the BrowseNext service. private static ArrayOf PrepareBrowseNext( ArrayOf browseResultCollection) { var continuationPoints = new List(); foreach (BrowseResult browseResult in browseResultCollection) { if (!browseResult.ContinuationPoint.IsEmpty) { continuationPoints.Add(browseResult.ContinuationPoint); } } return continuationPoints; } private void ValidateResponse( ArrayOf requests, ArrayOf responses) { if (m_validate != null) { m_validate(requests.ToArray()!, responses.ToArray()!); } else { ClientBase.ValidateResponse(responses, requests); } } private readonly Action? m_validate; private readonly ITelemetryContext m_telemetry; private readonly ILogger m_logger; private readonly ManualResetEvent? m_quitEvent; private readonly bool m_verbose; private readonly Dictionary> m_desiredEventFields; private int m_processedEvents; private DateTimeUtc m_lastEventTime = DateTimeUtc.Now; } }