/* ======================================================================== * 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; using Opc.Ua.Client; namespace Opc.Ua.WotCon.Client { /// /// High-level client for the OPC 10100-1 WoT Connectivity /// management surface. Composes (does not inherit) /// the source-generated /// proxy so callers can rely on the typed method wrappers while /// getting a few ergonomic conveniences (e.g. asset enumeration via /// Browse, opening an asset by NodeId). /// public sealed class WotConnectivityClient { /// /// Creates a new client rooted at . /// /// An open OPC UA session. /// NodeId of the /// WoTAssetConnectionManagement object on the server. /// Telemetry context used for diagnostics. /// is null. /// is empty. /// is null. public WotConnectivityClient( ISession session, NodeId managementObjectId, ITelemetryContext telemetry) { if (session is null) { throw new ArgumentNullException(nameof(session)); } if (managementObjectId.IsNull) { throw new ArgumentException("Management object NodeId is required.", nameof(managementObjectId)); } if (telemetry is null) { throw new ArgumentNullException(nameof(telemetry)); } Session = session; Telemetry = telemetry; ManagementObjectId = managementObjectId; Proxy = new WoTAssetConnectionManagementTypeClient(session, managementObjectId, telemetry); } /// /// Creates a client rooted at the WoT Connectivity entry point /// (Spec §6.2 — the WoTAssetConnectionManagement object /// under Objects) of the connected server. /// /// /// The NodeId of the entry point is server-specific; this /// helper resolves the standard BrowseName /// (WoTAssetConnectionManagement) by translating from /// the Objects folder. /// /// is null. /// is null. /// The WoTAssetConnectionManagement entry point was not found on the connected server. public static async ValueTask ForServerAsync( ISession session, ITelemetryContext telemetry, CancellationToken ct = default) { if (session is null) { throw new ArgumentNullException(nameof(session)); } if (telemetry is null) { throw new ArgumentNullException(nameof(telemetry)); } ushort ns = session.NamespaceUris.GetIndexOrAppend(Namespaces.WotCon); BrowsePath path = new() { StartingNode = Ua.ObjectIds.ObjectsFolder, RelativePath = new RelativePath { Elements = [ new RelativePathElement { ReferenceTypeId = Ua.ReferenceTypeIds.Organizes, IsInverse = false, IncludeSubtypes = true, TargetName = new QualifiedName("WoTAssetConnectionManagement", ns) } ] } }; ArrayOf paths = new[] { path }.ToArrayOf(); TranslateBrowsePathsToNodeIdsResponse response = await session .TranslateBrowsePathsToNodeIdsAsync(null, paths, ct) .ConfigureAwait(false); if (response.Results.Count == 0 || response.Results[0].Targets.Count == 0 || StatusCode.IsBad(response.Results[0].StatusCode)) { throw new ServiceResultException( StatusCodes.BadNodeIdUnknown, "WoTAssetConnectionManagement entry point not found on the connected server."); } var managementId = ExpandedNodeId.ToNodeId( response.Results[0].Targets[0].TargetId, session.NamespaceUris); return new WotConnectivityClient(session, managementId, telemetry); } /// /// The OPC UA session. /// public ISession Session { get; } /// /// Telemetry context. /// public ITelemetryContext Telemetry { get; } /// /// The management object NodeId. /// public NodeId ManagementObjectId { get; } /// /// The underlying generated proxy. /// public WoTAssetConnectionManagementTypeClient Proxy { get; } /// /// Calls CreateAsset and returns a wrapper around the /// newly created asset. /// public async ValueTask CreateAssetAsync( string assetName, CancellationToken ct = default) { NodeId assetId = await Proxy.CreateAssetAsync(assetName, ct).ConfigureAwait(false); return await OpenAssetAsync(assetId, ct).ConfigureAwait(false); } /// Calls DeleteAsset. public ValueTask DeleteAssetAsync(NodeId assetId, CancellationToken ct = default) { return Proxy.DeleteAssetAsync(assetId, ct); } /// Calls DiscoverAssets. public async ValueTask> DiscoverAssetsAsync( CancellationToken ct = default) { ArrayOf endpoints = await Proxy.DiscoverAssetsAsync(ct).ConfigureAwait(false); string[] copy = new string[endpoints.Count]; for (int i = 0; i < endpoints.Count; i++) { copy[i] = endpoints[i]; } return copy; } /// Calls CreateAssetForEndpoint. public async ValueTask CreateAssetForEndpointAsync( string assetName, string assetEndpoint, CancellationToken ct = default) { NodeId assetId = await Proxy .CreateAssetForEndpointAsync(assetName, assetEndpoint, ct) .ConfigureAwait(false); return await OpenAssetAsync(assetId, ct).ConfigureAwait(false); } /// Calls ConnectionTest. public ValueTask<(bool Success, string Status)> ConnectionTestAsync( string assetEndpoint, CancellationToken ct = default) { return Proxy.ConnectionTestAsync(assetEndpoint, ct); } /// /// Builds a wrapper for an existing /// asset NodeId. Browses for the asset's WoTFile child to /// populate the file proxy. /// /// is empty. /// The WoTFile child was not found below the asset object. public async ValueTask OpenAssetAsync( NodeId assetId, CancellationToken ct = default) { if (assetId.IsNull) { throw new ArgumentException("Asset NodeId is required.", nameof(assetId)); } ushort wotConNs = Session.NamespaceUris.GetIndexOrAppend(Namespaces.WotCon); ArrayOf paths = new[] { new BrowsePath { StartingNode = assetId, RelativePath = new RelativePath { Elements = [ new RelativePathElement { ReferenceTypeId = Ua.ReferenceTypeIds.HasComponent, IsInverse = false, IncludeSubtypes = true, TargetName = new QualifiedName("WoTFile", wotConNs) } ] } } }.ToArrayOf(); TranslateBrowsePathsToNodeIdsResponse response = await Session .TranslateBrowsePathsToNodeIdsAsync(null, paths, ct) .ConfigureAwait(false); BrowsePathResult result = response.Results[0]; if (StatusCode.IsBad(result.StatusCode) || result.Targets.Count == 0) { throw new ServiceResultException( StatusCodes.BadNoMatch, "WoTFile child not found below the asset object."); } var fileId = ExpandedNodeId.ToNodeId(result.Targets[0].TargetId, Session.NamespaceUris); string assetName = await ReadDisplayNameAsync(assetId, ct).ConfigureAwait(false); WoTAssetFileTypeClient file = new(Session, fileId, Telemetry); return new WotAssetClient(Session, assetId, assetName, file, Telemetry); } /// /// Enumerates all currently registered asset entries below the /// management object using Browse. /// public async IAsyncEnumerable EnumerateAssetsAsync( [EnumeratorCancellation] CancellationToken ct = default) { (_, _, ArrayOf references) = await Session.BrowseAsync( requestHeader: null, view: null, ManagementObjectId, maxResultsToReturn: 0, BrowseDirection.Forward, Ua.ReferenceTypeIds.Organizes, includeSubtypes: true, (uint)NodeClass.Object, ct).ConfigureAwait(false); var snapshot = new ReferenceDescription[references.Count]; for (int i = 0; i < references.Count; i++) { snapshot[i] = references[i]; } foreach (ReferenceDescription reference in snapshot) { ct.ThrowIfCancellationRequested(); var targetId = ExpandedNodeId.ToNodeId(reference.NodeId, Session.NamespaceUris); yield return new WotAssetEntry(targetId, reference.DisplayName.Text ?? string.Empty); } } private async ValueTask ReadDisplayNameAsync(NodeId nodeId, CancellationToken ct) { ArrayOf nodesToRead = new[] { new ReadValueId { NodeId = nodeId, AttributeId = Attributes.DisplayName } }.ToArrayOf(); ReadResponse response = await Session.ReadAsync( requestHeader: null, maxAge: 0, timestampsToReturn: TimestampsToReturn.Neither, nodesToRead: nodesToRead, ct: ct).ConfigureAwait(false); if (response.Results.Count == 0 || StatusCode.IsBad(response.Results[0].StatusCode)) { return string.Empty; } response.Results[0].WrappedValue.TryGetValue(out LocalizedText displayName); return displayName.IsNull ? string.Empty : displayName.Text ?? string.Empty; } } /// /// A single asset entry exposed by /// . /// /// The asset NodeId. /// The asset display name (matches the /// BrowseName minus the namespace prefix). public sealed record WotAssetEntry(NodeId AssetId, string Name); }