/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading; using System.Threading.Tasks; namespace Opc.Ua.Client { /// /// Extensions to ISessionClient that are not dependent on anything internal /// to the client but layer over ISessionClient. /// public static class SessionClientExtensions { /// /// Reads the values for a set of variables. /// public static async ValueTask<( ArrayOf, ArrayOf )> ReadValuesAsync( this ISessionClient session, ArrayOf variableIds, ArrayOf expectedTypes, CancellationToken ct = default) { (ArrayOf dataValues, ArrayOf errorValues) = await session.ReadValuesAsync( variableIds, ct).ConfigureAwait(false); var values = new Variant[dataValues.Count]; var errors = new ServiceResult[errorValues.Count]; for (int ii = 0; ii < variableIds.Count; ii++) { if (dataValues[ii].WrappedValue.TypeInfo != expectedTypes[ii]) { errors[ii] = ServiceResult.Create( StatusCodes.BadTypeMismatch, "Value {0} does not have expected type: {1}.", dataValues[ii], expectedTypes[ii]); continue; } // suitable value found. errors[ii] = errorValues[ii]; values[ii] = dataValues[ii].WrappedValue; } return (values, errors); } /// /// Invokes the Browse service. /// /// public static async ValueTask<( ResponseHeader, ByteString, ArrayOf )> BrowseAsync( this ISessionClient session, RequestHeader? requestHeader, ViewDescription? view, NodeId nodeToBrowse, uint maxResultsToReturn, BrowseDirection browseDirection, NodeId referenceTypeId, bool includeSubtypes, uint nodeClassMask, CancellationToken ct = default) { ( ResponseHeader? responseHeader, ArrayOf continuationPoints, ArrayOf> referencesList, ArrayOf errors ) = await session.BrowseAsync( requestHeader, view, [nodeToBrowse], maxResultsToReturn, browseDirection, referenceTypeId, includeSubtypes, nodeClassMask, ct).ConfigureAwait(false); Debug.Assert(errors.Count <= 1); if (errors.Count > 0 && StatusCode.IsBad(errors[0].StatusCode)) { throw new ServiceResultException(errors[0]); } Debug.Assert(referencesList.Count == 1); Debug.Assert(continuationPoints.Count == 1); return (responseHeader, continuationPoints[0], referencesList[0]); } /// /// Invokes the BrowseNext service. /// /// public static async ValueTask<( ResponseHeader, ByteString, ArrayOf )> BrowseNextAsync( this ISessionClient session, RequestHeader? requestHeader, bool releaseContinuationPoint, ByteString continuationPoint, CancellationToken ct = default) { ( ResponseHeader? responseHeader, ArrayOf revisedContinuationPoints, ArrayOf> referencesList, ArrayOf errors ) = await session.BrowseNextAsync( requestHeader, [continuationPoint], releaseContinuationPoint, ct).ConfigureAwait(false); Debug.Assert(errors.Count <= 1); if (errors.Count > 0 && StatusCode.IsBad(errors[0].StatusCode)) { throw new ServiceResultException(errors[0]); } Debug.Assert(referencesList.Count == 1); Debug.Assert(revisedContinuationPoints.Count == 1); return (responseHeader, revisedContinuationPoints[0], referencesList[0]); } /// /// Managed browsing using browser /// public static async Task<( ArrayOf>, ArrayOf )> ManagedBrowseAsync( this ISessionClient session, RequestHeader? requestHeader, ViewDescription? view, ArrayOf nodesToBrowse, uint maxResultsToReturn, BrowseDirection browseDirection, NodeId referenceTypeId, bool includeSubtypes, uint nodeClassMask, CancellationToken ct = default) { var browser = new Browser(session, new BrowserOptions { RequestHeader = requestHeader, View = view, MaxReferencesReturned = maxResultsToReturn, BrowseDirection = browseDirection, ReferenceTypeId = referenceTypeId, IncludeSubtypes = includeSubtypes, NodeClassMask = (int)nodeClassMask }); ResultSet> result = await browser.BrowseAsync(nodesToBrowse, ct).ConfigureAwait(false); return (result.Results, result.Errors); } /// /// Fetches all references for the specified node. /// /// session to use /// The node id. /// Cancellation token to cancel operation with public static async Task> FetchReferencesAsync( this ISessionClient session, NodeId nodeId, CancellationToken ct = default) { (ArrayOf> descriptions, _) = await session.ManagedBrowseAsync( null, null, [nodeId], 0, BrowseDirection.Both, default, true, 0, ct) .ConfigureAwait(false); return descriptions[0]; } /// /// Fetches all references for the specified nodes. /// /// session to use /// The node ids to collect references for. /// Cancellation token to cancel operation with /// A list of reference description lists and the errors reported /// by the server. public static Task<( ArrayOf>, ArrayOf )> FetchReferencesAsync( this ISessionClient session, ArrayOf nodeIds, CancellationToken ct = default) { return session.ManagedBrowseAsync( null, null, nodeIds, 0, BrowseDirection.Both, default, true, 0, ct); } /// /// Reads the values for the node attributes and returns a list of nodes. /// /// /// If the nodeclass for the nodes in the array of NodeId is already known /// and passed as nodeClass, reads only values of required attributes. /// Otherwise NodeClass.Unspecified should be used. /// /// The session to use /// The array of NodeIds to read. /// The nodeClass of all nodes in the array. /// Set to NodeClass.Unspecified if the nodeclass is unknown. /// Set to true if optional attributes /// should not be omitted. /// The cancellation token. /// The array of nodes and associated errors. public static async Task<(ArrayOf, ArrayOf)> ReadNodesAsync( this ISessionClient session, ArrayOf nodeIds, NodeClass nodeClass, bool optionalAttributes = false, CancellationToken ct = default) { var nodeCacheContext = new NodeCacheContext(session); ResultSet result = await nodeCacheContext.FetchNodesAsync( null, nodeIds, nodeClass, !optionalAttributes, ct).ConfigureAwait(false); return (result.Results, result.Errors); } /// /// Reads the values for the node attributes and returns a array of nodes. /// Reads the nodeclass of the nodeIds, then reads /// the values for the node attributes and returns a array of nodes. /// /// The session to use /// The array of NodeIds to read. /// If optional attributes to read. /// The cancellation token. public static async Task<(ArrayOf, ArrayOf)> ReadNodesAsync( this ISessionClient session, ArrayOf nodeIds, bool optionalAttributes = false, CancellationToken ct = default) { var nodeCacheContext = new NodeCacheContext(session); ResultSet result = await nodeCacheContext.FetchNodesAsync( null, nodeIds, !optionalAttributes, ct).ConfigureAwait(false); return (result.Results, result.Errors); } /// /// Reads the values for the node attributes and returns a node object. /// /// The session to use /// The nodeId. /// The cancellation token for the request. public static Task ReadNodeAsync( this ISessionClient session, NodeId nodeId, CancellationToken ct = default) { return session.ReadNodeAsync(nodeId, NodeClass.Unspecified, true, ct); } /// /// Reads the values for the node attributes and returns a node object. /// /// /// If the nodeclass is known, only the supported attribute values are read. /// /// The session to use /// The nodeId. /// The nodeclass of the node to read. /// Read optional attributes. /// The cancellation token for the request. public static Task ReadNodeAsync( this ISessionClient session, NodeId nodeId, NodeClass nodeClass, bool optionalAttributes = true, CancellationToken ct = default) { var nodeCacheContext = new NodeCacheContext(session); return nodeCacheContext.FetchNodeAsync( null, nodeId, nodeClass, !optionalAttributes, ct).AsTask(); } /// /// Read display name for a set of nodes /// /// The session to use /// node for which to read display name /// Cancellation token to use to cancel the operation /// Paired list of displaynames and potential errors per node public static async Task<(ArrayOf, ArrayOf)> ReadDisplayNameAsync( this ISessionClient session, ArrayOf nodeIds, CancellationToken ct = default) { var displayNames = new List(); var errors = new List(); // build list of values to read. ArrayOf valuesToRead = nodeIds.ConvertAll(n => new ReadValueId { NodeId = n, AttributeId = Attributes.DisplayName, IndexRange = null, DataEncoding = QualifiedName.Null }); // read the values. ReadResponse response = await session.ReadAsync( null, int.MaxValue, TimestampsToReturn.Neither, valuesToRead, ct).ConfigureAwait(false); ArrayOf results = response.Results; ArrayOf diagnosticInfos = response.DiagnosticInfos; ResponseHeader responseHeader = response.ResponseHeader; // verify that the server returned the correct number of results. ClientBase.ValidateResponse(results, valuesToRead); ClientBase.ValidateDiagnosticInfos(diagnosticInfos, valuesToRead); for (int ii = 0; ii < nodeIds.Count; ii++) { displayNames.Add(string.Empty); errors.Add(ServiceResult.Good); // process any diagnostics associated with bad or uncertain data. if (StatusCode.IsNotGood(results[ii].StatusCode)) { errors[ii] = new ServiceResult( results[ii].StatusCode, ii, diagnosticInfos, responseHeader.StringTable); continue; } // extract the name. LocalizedText displayName = results[ii].WrappedValue.GetLocalizedText(); if (!displayName.IsNullOrEmpty) { // IsNullOrEmpty being false guarantees Text is non-null in practice; the // property cannot express that contract because it derives Text from an // optional translation provider. displayNames[ii] = displayName.Text!; } } return (displayNames.ToArrayOf(), errors.ToArrayOf()); } /// /// Returns the data description for the encoding. /// /// The session to use /// The encoding Id. /// Cancellation token to use to cancel the operation /// public static async Task FindDataDescriptionAsync( this ISessionClient session, NodeId encodingId, CancellationToken ct = default) { var browser = new Browser(session, new BrowserOptions { BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HasDescription, IncludeSubtypes = false, NodeClassMask = 0 }); ArrayOf references = await browser.BrowseAsync(encodingId, ct).ConfigureAwait(false); if (references.Count == 0) { throw ServiceResultException.Create( StatusCodes.BadNodeIdInvalid, "Encoding does not refer to a valid data description."); } return references[0]; } /// /// Reads the value for a node of type T or throws if not matching the type. /// /// /// The session to use /// The node Id. /// The cancellation token for the request. /// public static async Task ReadValueAsync( this ISessionClient session, NodeId nodeId, CancellationToken ct = default) { DataValue dataValue = await session.ReadValueAsync(nodeId, ct).ConfigureAwait(false); object? value; if (dataValue.WrappedValue.TryGetValue(out ExtensionObject extension) && extension.TryGetValue(out IEncodeable? encodeable)) { value = encodeable; } else { value = dataValue.WrappedValue.AsBoxedObject(); } if (!typeof(T).IsInstanceOfType(value)) { throw ServiceResultException.Create( StatusCodes.BadTypeMismatch, "Server returned value unexpected type: {0}", value != null ? value.GetType().Name : "(null)"); } // For reference type T, IsInstanceOfType returns false on null, so reaching this // line implies value is non-null. For value type T, value boxes to non-null and // a null cast would have already thrown, so the bang preserves either path. return (T)value!; } /// /// Reads the value for a node. /// /// The session to use /// The node Id. /// The cancellation token for the request. /// public static Task ReadValueAsync( this ISessionClient session, NodeId nodeId, CancellationToken ct = default) { var nodeCacheContext = new NodeCacheContext(session); return nodeCacheContext.FetchValueAsync( null, nodeId, ct).AsTask(); } /// /// Reads the values for a array of nodes. Returns diagnostic errors. /// /// The session to use /// The node Id. /// The cancellation token for the request. public static async Task<(ArrayOf, ArrayOf)> ReadValuesAsync( this ISessionClient session, ArrayOf nodeIds, CancellationToken ct = default) { var nodeCacheContext = new NodeCacheContext(session); ResultSet result = await nodeCacheContext.FetchValuesAsync( null, nodeIds, ct).ConfigureAwait(false); return (result.Results, result.Errors); } /// /// Browses the nodes in the server. /// /// The session to use /// Request header /// View to use /// nodes to browse /// max results to return /// Direction of browse /// Reference type to follow /// Include subtypes /// Node classes to match /// Cancellation token to cancel the operation /// public static async Task<( ResponseHeader responseHeader, ArrayOf continuationPoints, ArrayOf> referencesList, ArrayOf errors )> BrowseAsync( this ISessionClient session, RequestHeader? requestHeader, ViewDescription? view, ArrayOf nodesToBrowse, uint maxResultsToReturn, BrowseDirection browseDirection, NodeId referenceTypeId, bool includeSubtypes, uint nodeClassMask, CancellationToken ct = default) { ArrayOf browseDescriptions = nodesToBrowse. ConvertAll(nodeToBrowse => new BrowseDescription { NodeId = nodeToBrowse, BrowseDirection = browseDirection, ReferenceTypeId = referenceTypeId, IncludeSubtypes = includeSubtypes, NodeClassMask = nodeClassMask, ResultMask = (uint)BrowseResultMask.All }); BrowseResponse browseResponse = await session.BrowseAsync( requestHeader, view, maxResultsToReturn, browseDescriptions, ct).ConfigureAwait(false); ArrayOf results = browseResponse.Results; ArrayOf diagnosticInfos = browseResponse.DiagnosticInfos; ClientBase.ValidateResponse(results, browseDescriptions); ClientBase.ValidateDiagnosticInfos(diagnosticInfos, browseDescriptions); int ii = 0; var errors = new List(); var continuationPoints = new List(); var referencesList = new List>(); foreach (BrowseResult result in results) { if (StatusCode.IsBad(result.StatusCode)) { errors.Add( new ServiceResult( result.StatusCode, ii, diagnosticInfos, browseResponse.ResponseHeader.StringTable)); } else { errors.Add(ServiceResult.Good); } continuationPoints.Add(result.ContinuationPoint); referencesList.Add(result.References); ii++; } return ( browseResponse.ResponseHeader, continuationPoints.ToArrayOf(), referencesList.ToArrayOf(), errors.ToArrayOf()); } /// /// Browse next /// /// The session to use /// /// /// /// /// public static async Task<( ResponseHeader responseHeader, ArrayOf revisedContinuationPoints, ArrayOf> referencesList, ArrayOf errors )> BrowseNextAsync( this ISessionClient session, RequestHeader? requestHeader, ArrayOf continuationPoints, bool releaseContinuationPoint, CancellationToken ct = default) { BrowseNextResponse response = await session.BrowseNextAsync( requestHeader, releaseContinuationPoint, continuationPoints, ct).ConfigureAwait(false); ArrayOf results = response.Results; ArrayOf diagnosticInfos = response.DiagnosticInfos; ClientBase.ValidateResponse(results, continuationPoints); ClientBase.ValidateDiagnosticInfos(diagnosticInfos, continuationPoints); int ii = 0; var errors = new List(); var revisedContinuationPoints = new List(); var referencesList = new List>(); foreach (BrowseResult result in results) { if (StatusCode.IsBad(result.StatusCode)) { errors.Add( new ServiceResult( result.StatusCode, ii, diagnosticInfos, response.ResponseHeader.StringTable)); } else { errors.Add(ServiceResult.Good); } revisedContinuationPoints.Add(result.ContinuationPoint); referencesList.Add(result.References); ii++; } return ( response.ResponseHeader, revisedContinuationPoints.ToArrayOf(), referencesList.ToArrayOf(), errors.ToArrayOf()); } /// /// Reads a byte string value safely in fragments if needed. Uses the byte /// string size limits to chunk the reads if needed. The first read happens /// as usual and no stream is allocated, if the result is below the limits /// the buffer that is read into is returned, otherwise buffers are added /// to a memory stream whose content is finally returned. /// /// session to use /// The node id of a byte string variable /// A chunk size to enforce /// Cancellation token to cancel operation with /// /// public static async ValueTask ReadBytesAsync( this ISessionClient session, NodeId nodeId, int maxByteStringLength, CancellationToken ct = default) { if (maxByteStringLength == 0 || maxByteStringLength > session.MessageContext.MaxByteStringLength) { maxByteStringLength = session.MessageContext.MaxByteStringLength; } if (maxByteStringLength <= 0) { throw new ArgumentOutOfRangeException(nameof(maxByteStringLength), "maxByteStringLength must be a positive integer."); } int offset = 0; MemoryStream? stream = null; try { while (true) { var valueToRead = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value, IndexRange = new NumericRange( offset, // Range is inclusive and starts at 0. Therefore // to read 5 bytes you need to specify 0-4. offset + maxByteStringLength - 1).ToString(), DataEncoding = QualifiedName.Null }; ArrayOf readValueIds = [valueToRead]; ReadResponse result = await session.ReadAsync( null, 0, TimestampsToReturn.Neither, readValueIds, ct) .ConfigureAwait(false); ResponseHeader responseHeader = result.ResponseHeader; ArrayOf results = result.Results; ArrayOf diagnosticInfos = result.DiagnosticInfos; ClientBase.ValidateResponse(results, readValueIds); ClientBase.ValidateDiagnosticInfos(diagnosticInfos, readValueIds); Variant wrappedValue = results[0].WrappedValue; // First call returned bytes, next a string, that we cannot tolerate // But we allow null byte string which signals the end of the stream. if (!wrappedValue.IsNull && (wrappedValue.TypeInfo.BuiltInType != BuiltInType.ByteString || wrappedValue.TypeInfo.ValueRank != ValueRanks.Scalar)) { throw new ServiceResultException( StatusCodes.BadTypeMismatch, "Value is not a scalar ByteString."); } if (StatusCode.IsBad(results[0].StatusCode)) { if (results[0].StatusCode == StatusCodes.BadIndexRangeNoData) { // this happens when the previous read has fetched all remaining data break; } ServiceResult serviceResult = ClientBase.GetResult( results[0].StatusCode, 0, diagnosticInfos, responseHeader); throw new ServiceResultException(serviceResult); } if (!results[0].WrappedValue.TryGetValue(out ByteString chunk) || chunk.Length == 0) { // End of stream - fast path (no stream allocated yet) // will return empty array constant. break; } if (chunk.Length < maxByteStringLength && offset == 0) { // Fast path for small values, just return the chunk return chunk; } stream ??= new MemoryStream(); #if NET8_0_OR_GREATER await stream.WriteAsync(chunk.Memory, ct).ConfigureAwait(false); #else stream.Write(chunk.ToArray(), 0, chunk.Length); #endif if (chunk.Length < maxByteStringLength) { break; } offset += maxByteStringLength; } return stream != null ? stream.ToArray().ToByteString() : ByteString.Empty; } finally { #if NET8_0_OR_GREATER if (stream != null) { await stream.DisposeAsync().ConfigureAwait(false); } #else stream?.Dispose(); #endif } } /// /// Calls the specified method and returns the output arguments. /// /// The session to use /// The NodeId of the object that provides the method. /// The NodeId of the method to call. /// The cancellation token for the request. /// The input arguments. /// The list of output argument values. /// public static async Task> CallAsync( this ISessionClient session, NodeId objectId, NodeId methodId, CancellationToken ct = default, params Variant[] args) { var request = new CallMethodRequest { ObjectId = objectId, MethodId = methodId, InputArguments = args }; ArrayOf requests = [request]; CallResponse response = await session.CallAsync( null, requests, ct).ConfigureAwait(false); ArrayOf results = response.Results; ArrayOf diagnosticInfos = response.DiagnosticInfos; ClientBase.ValidateResponse(results, requests); ClientBase.ValidateDiagnosticInfos(diagnosticInfos, requests); if (StatusCode.IsBad(results[0].StatusCode)) { throw ServiceResultException.Create( results[0].StatusCode, 0, diagnosticInfos, response.ResponseHeader.StringTable); } return results[0].OutputArguments; } /// /// Call the ResendData method on the server for all subscriptions. /// public static async ValueTask> ResendDataAsync( this ISessionClient session, ArrayOf subscriptionIds, CancellationToken ct = default) { ArrayOf requests = subscriptionIds .ConvertAll(subscriptionId => new CallMethodRequest { ObjectId = ObjectIds.Server, MethodId = MethodIds.Server_ResendData, InputArguments = [Variant.From(subscriptionId)] }); var errors = new List(requests.Count); CallResponse response = await session.CallAsync(null, requests, ct).ConfigureAwait(false); ArrayOf results = response.Results; ArrayOf diagnosticInfos = response.DiagnosticInfos; ResponseHeader responseHeader = response.ResponseHeader; ClientBase.ValidateResponse(results, requests); ClientBase.ValidateDiagnosticInfos(diagnosticInfos, requests); int ii = 0; foreach (CallMethodResult value in results) { ServiceResult result = ServiceResult.Good; if (StatusCode.IsNotGood(value.StatusCode)) { result = ClientBase.GetResult(value.StatusCode, ii, diagnosticInfos, responseHeader); } errors.Add(result); ii++; } return errors.ToArrayOf(); } } }