/* ======================================================================== * 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.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; namespace Opc.Ua.Client.AliasNames { /// /// High-level async client over an OPC UA Part 17 /// AliasNameCategoryType instance — supports /// FindAlias (§6.3.2), FindAliasVerbose (§6.3.3), /// AddAliasesToCategory (§6.3.4), /// DeleteAliasesFromCategory (§6.3.5), /// LastChange (§6.3.1) and sub-category enumeration. /// /// /// /// Built on top of the source-generated /// ObjectType proxy that /// ships with the standard NodeSet. The proxy handles /// CallRequest packing/unpacking and per-call validation /// (BadUnexpectedError on malformed output) — this wrapper /// adds the ergonomic surface: typed exceptions for service-level /// status codes, sub-category browse, and an /// helper for the /// VersionTime property (which the proxy does not cover). /// /// /// Service-level failures map to typed .NET exceptions: /// BadUserAccessDenied → , /// BadNotSupported/BadNotImplemented → /// ; everything else propagates as /// . /// /// public sealed class AliasNameClient { /// /// Initializes a new client rooted at the supplied /// AliasNameCategoryType instance. /// /// The OPC UA session used for all service /// calls. /// The of the /// AliasNameCategoryType instance. /// Optional configuration; defaults applied /// when null. /// is null. /// is null. public AliasNameClient( ISession session, NodeId categoryId, AliasNameClientOptions? options = null) { Session = session ?? throw new ArgumentNullException(nameof(session)); if (categoryId.IsNull) { throw new ArgumentException( "categoryId must not be null.", nameof(categoryId)); } CategoryId = categoryId; Options = (options ?? new AliasNameClientOptions()).Clone(); Proxy = new AliasNameCategoryTypeClient( session, categoryId, session.MessageContext.Telemetry); } /// /// Opens an rooted at the standard /// well-known Aliases (i=23470) object (Part 17 §9.2). /// public static AliasNameClient OpenStandardAliases( ISession session, AliasNameClientOptions? options = null) { return new AliasNameClient(session, ObjectIds.Aliases, options); } /// /// Opens an rooted at the standard /// well-known TagVariables (i=23479) category (Part 17 §9.3). /// public static AliasNameClient OpenStandardTagVariables( ISession session, AliasNameClientOptions? options = null) { return new AliasNameClient(session, ObjectIds.TagVariables, options); } /// /// Opens an rooted at the standard /// well-known Topics (i=23488) category (Part 17 §9.4). /// public static AliasNameClient OpenStandardTopics( ISession session, AliasNameClientOptions? options = null) { return new AliasNameClient(session, ObjectIds.Topics, options); } /// /// The session used for all service calls. /// public ISession Session { get; } /// /// The category NodeId. /// public NodeId CategoryId { get; } /// /// The (cloned, immutable) configuration. /// public AliasNameClientOptions Options { get; } /// /// The underlying source-generated proxy. Exposed for advanced /// scenarios that need direct access to the raw proxy methods. /// public AliasNameCategoryTypeClient Proxy { get; } /// /// Calls FindAlias (Part 17 §6.3.2). An empty /// returns an empty /// result; a null/empty /// matches every reference type. /// /// The server returned an /// error status code. /// The category does not /// expose FindAlias. /// The session does /// not have permission to search aliases in this category. public async Task> FindAliasAsync( string aliasNameSearchPattern, NodeId? referenceTypeFilter = null, CancellationToken ct = default) { try { ArrayOf result = await Proxy .FindAliasAsync( aliasNameSearchPattern ?? string.Empty, referenceTypeFilter ?? NodeId.Null, ct) .ConfigureAwait(false); return ToList(result); } catch (ServiceResultException sre) { throw AliasNameClientErrors.Translate( sre.StatusCode, "FindAlias", CategoryId); } } /// /// Calls FindAliasVerbose (Part 17 §6.3.3). The proxy issues /// the call against MethodIds.AliasNameCategoryType_FindAliasVerbose /// — when the category does not expose the optional method the /// server replies with BadNotImplemented/BadMethodInvalid /// which is translated into . /// /// The server returned an /// error status code. /// The category does not /// expose FindAliasVerbose. /// The session does /// not have permission to search aliases in this category. public async Task> FindAliasVerboseAsync( string aliasNameSearchPattern, NodeId? referenceTypeFilter = null, CancellationToken ct = default) { try { ArrayOf result = await Proxy .FindAliasVerboseAsync( aliasNameSearchPattern ?? string.Empty, referenceTypeFilter ?? NodeId.Null, ct) .ConfigureAwait(false); return ToList(result); } catch (ServiceResultException sre) { throw AliasNameClientErrors.Translate( sre.StatusCode, "FindAliasVerbose", CategoryId); } } /// /// Calls AddAliasesToCategory (Part 17 §6.3.4). /// /// One per input request, in /// input order, indicating the per-entry outcome. /// The category does not /// expose AddAliasesToCategory. /// The session does /// not have permission to add aliases to this category. /// is null. /// Not all entries in /// share the same /// . /// The server returned an /// error status code. public async Task AddAliasesToCategoryAsync( IReadOnlyList requests, CancellationToken ct = default) { if (requests == null) { throw new ArgumentNullException(nameof(requests)); } string[] names = new string[requests.Count]; var targets = new ExpandedNodeId[requests.Count]; string[] servers = new string[requests.Count]; NodeId refType = NodeId.Null; for (int i = 0; i < requests.Count; i++) { AliasNameAddRequest req = requests[i]; names[i] = req.Name ?? string.Empty; targets[i] = req.TargetNode; servers[i] = req.TargetServer ?? string.Empty; if (refType.IsNull) { refType = req.TargetReferenceType; } else if (!refType.Equals(req.TargetReferenceType)) { throw new ArgumentException( "All AliasNameAddRequest entries in a single call " + "must share the same TargetReferenceType (Part 17 " + "§6.3.4 — TargetReferenceType is a scalar input).", nameof(requests)); } } if (refType.IsNull) { refType = ReferenceTypeIds.AliasFor; } try { ArrayOf codes = await Proxy .AddAliasesToCategoryAsync( names.ToArrayOf(), targets.ToArrayOf(), servers.ToArrayOf(), refType, ct) .ConfigureAwait(false); return ToArray(codes); } catch (ServiceResultException sre) { throw AliasNameClientErrors.Translate( sre.StatusCode, "AddAliasesToCategory", CategoryId); } } /// /// Calls DeleteAliasesFromCategory (Part 17 §6.3.5). /// /// One per input request, in /// input order. /// The category does not /// expose DeleteAliasesFromCategory. /// The session does /// not have permission to delete aliases from this category. /// is null. /// The server returned an /// error status code. public async Task DeleteAliasesFromCategoryAsync( IReadOnlyList requests, CancellationToken ct = default) { if (requests == null) { throw new ArgumentNullException(nameof(requests)); } string[] names = new string[requests.Count]; var targets = new ExpandedNodeId[requests.Count]; for (int i = 0; i < requests.Count; i++) { AliasNameDeleteRequest req = requests[i]; names[i] = req.Name ?? string.Empty; targets[i] = req.TargetNode; } try { ArrayOf codes = await Proxy .DeleteAliasesFromCategoryAsync( names.ToArrayOf(), targets.ToArrayOf(), ct) .ConfigureAwait(false); return ToArray(codes); } catch (ServiceResultException sre) { throw AliasNameClientErrors.Translate( sre.StatusCode, "DeleteAliasesFromCategory", CategoryId); } } /// /// Resolves the category's LastChange property NodeId /// (Part 17 §6.3.1). For the standard well-known Aliases /// (i=23470) category the hardcoded i=32852 instance /// is returned without a round-trip; other categories fall back /// to a TranslateBrowsePathsToNodeIds probe. Returns /// when the category does not expose /// the property. /// public async Task ResolveLastChangeNodeIdAsync( CancellationToken ct = default) { NodeId lastChangeId = ResolveStandardLastChange(CategoryId); if (!lastChangeId.IsNull) { return lastChangeId; } return await ResolveChildAsync( BrowseNames.LastChange, ct).ConfigureAwait(false); } /// /// Reads the category's LastChange property (Part 17 /// §6.3.1) — a VersionTime (uint) that advances /// monotonically on every Add/Delete batch. Returns null /// when the category does not expose LastChange. /// public async Task ReadLastChangeAsync(CancellationToken ct = default) { NodeId lastChangeId = await ResolveLastChangeNodeIdAsync(ct) .ConfigureAwait(false); if (lastChangeId.IsNull) { return null; } ArrayOf nodesToRead = [ new ReadValueId { NodeId = lastChangeId, AttributeId = Attributes.Value } ]; ReadResponse response = await Session.ReadAsync( null, 0, TimestampsToReturn.Neither, nodesToRead, ct).ConfigureAwait(false); if (response.Results.Count == 0 || StatusCode.IsBad(response.Results[0].StatusCode)) { return null; } if (response.Results[0].WrappedValue.TryGetValue(out uint value)) { return value; } return null; } /// /// Asynchronously enumerates the sub-categories of this category /// (Part 17 §6.3.1 SubAliasNameCategories) via a forward /// browse on . /// /// The browse operation /// returned an error status code. public async IAsyncEnumerable EnumerateSubCategoriesAsync( [EnumeratorCancellation] CancellationToken ct = default) { var description = new BrowseDescription { NodeId = CategoryId, BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.Organizes, IncludeSubtypes = true, NodeClassMask = (uint)NodeClass.Object, ResultMask = (uint)BrowseResultMask.BrowseName | (uint)BrowseResultMask.DisplayName | (uint)BrowseResultMask.TypeDefinition }; ArrayOf requests = [description]; BrowseResponse response = await Session.BrowseAsync( null, null, 0, requests, ct).ConfigureAwait(false); if (response.Results.Count == 0) { yield break; } BrowseResult br = response.Results[0]; if (StatusCode.IsBad(br.StatusCode)) { throw new ServiceResultException(br.StatusCode); } int refCount = br.References.Count; var refs = new ReferenceDescription[refCount]; for (int i = 0; i < refCount; i++) { refs[i] = br.References[i]; } foreach (ReferenceDescription r in refs) { if (!r.TypeDefinition.Equals(ObjectTypeIds.AliasNameCategoryType)) { continue; } var localId = ExpandedNodeId.ToNodeId( r.NodeId, Session.NamespaceUris); if (localId.IsNull) { continue; } yield return new AliasNameSubCategoryInfo( localId, r.BrowseName, r.DisplayName); } } // -------------------------------------------------------------- // Internal helpers // -------------------------------------------------------------- private async Task ResolveChildAsync( string childBrowseName, CancellationToken ct) { var relativePath = new RelativePath { Elements = [ new RelativePathElement { ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IsInverse = false, IncludeSubtypes = true, TargetName = new QualifiedName(childBrowseName) } ] }; var browsePath = new BrowsePath { StartingNode = CategoryId, RelativePath = relativePath }; ArrayOf requests = [browsePath]; TranslateBrowsePathsToNodeIdsResponse response = await Session.TranslateBrowsePathsToNodeIdsAsync( null, requests, ct).ConfigureAwait(false); if (response.Results.Count == 0 || StatusCode.IsBad(response.Results[0].StatusCode) || response.Results[0].Targets.Count == 0) { return NodeId.Null; } ExpandedNodeId target = response.Results[0].Targets[0].TargetId; return ExpandedNodeId.ToNodeId(target, Session.NamespaceUris); } private static NodeId ResolveStandardLastChange(NodeId categoryId) { // Only the standard Aliases (i=23470) object instantiates // LastChange in the shipped NodeSet (Part 17 §9.2). if (categoryId.Equals(ObjectIds.Aliases)) { return VariableIds.Aliases_LastChange; } return NodeId.Null; } private static List ToList(ArrayOf arr) { var result = new List(arr.Count); for (int i = 0; i < arr.Count; i++) { if (!EqualityComparer.Default.Equals(arr[i], default)) { result.Add(arr[i]); } } return result; } private static StatusCode[] ToArray(ArrayOf arr) { var result = new StatusCode[arr.Count]; for (int i = 0; i < arr.Count; i++) { result[i] = arr[i]; } return result; } } }