/* ========================================================================
* 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);
}